From 2642f044a3a82b922c9981f287b4d2b2a0a738ac Mon Sep 17 00:00:00 2001 From: Nathaniel Chestnut Date: Fri, 28 Aug 2026 11:36:34 -0700 Subject: [PATCH 01/22] feat(adapters): close dark-capability gaps with full four-adapter parity (DD-031 G1-G6, E1/E4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the consumer-UX pass: no shipped capability stays reachable only via raw.renderer(), and every new public controls method is exposed + parity-tested across Vue/React/Svelte/Web Component. Core (gcode-preview-core): - GcodePreviewControls gains getRenderStats() (G1), pickSegment() (G2), and isColorModeAvailable() — the render-diagnostics and source-mapping surfaces were previously ToolpathRenderer-only. - GcodePreviewState gains availableColorModes/hasRetractions/hasColorChanges (G6), refreshed post-parse from the renderer's honest capability gates, so a UI can offer+explain controls without the raw handle. - PreviewRenderer widened accordingly; the 2D adapter implements the new members honestly (getRenderStats null; no fabricated stats). Adapters: - Declarative hiddenFeatureRoles (G3) on all four (prop / prop / prop / hidden-feature-roles attribute), diffing prev->next so it only toggles roles it owns. Backs the AnyBridge hide-adhesion feature declaratively. - capture() promoted to the Vue/React/Svelte handles (G5), matching the WC method. - Web Component: renderer/adjacentLayers property accessors (E1); index re-exports RendererMode/PreviewRenderer (E4). Tests: portable behavioral suite extended (layer-range clip, color-mode gating, diagnostics+picking reachability) plus a controls-completeness parity guard so a capability can't ship in core and vanish from an adapter. The guard caught React's hand-listed harness controls; fixed to delegate the whole controls object. Docs: DD-031 (accepted) records the audit, parity matrix, gaps, and Feature-Lab architecture. Co-Authored-By: Claude Opus 4.8 --- ...DD-031-consumer-ux-and-framework-parity.md | 326 ++++++++++++++++++ packages/gcode-preview-core/src/controller.ts | 79 ++++- .../src/renderer-2d-adapter.ts | 24 +- .../src/renderer-interface.ts | 19 +- packages/gcode-preview-core/src/testing.ts | 92 ++++- .../src/gcode-preview-element.ts | 69 +++- packages/gcode-preview-element/src/index.ts | 5 +- .../src/__tests__/behavioral-suite.test.ts | 17 +- .../src/gcode-preview-component.ts | 16 +- .../src/use-gcode-preview.ts | 5 + .../src/GcodePreview.svelte | 15 + .../src/create-gcode-preview.ts | 5 + .../src/gcode-preview-component.ts | 20 +- .../src/use-gcode-preview.ts | 5 + 14 files changed, 676 insertions(+), 21 deletions(-) create mode 100644 docs/design/DD-031-consumer-ux-and-framework-parity.md diff --git a/docs/design/DD-031-consumer-ux-and-framework-parity.md b/docs/design/DD-031-consumer-ux-and-framework-parity.md new file mode 100644 index 00000000..84a03505 --- /dev/null +++ b/docs/design/DD-031-consumer-ux-and-framework-parity.md @@ -0,0 +1,326 @@ +# DD-031 — Consumer SDK experience, framework parity, and the Feature Lab + +**Status:** Accepted +**Authors/Owners:** Nathaniel Chestnut (owner), Claude Code (lead) +**Date:** 2026-08-28 · **Last revised:** 2026-08-28 + +> **Owner decisions (2026-08-28):** (1) The flagship Feature Lab is rebuilt on the **published +> framework-neutral `gcode-preview-core` controller**; the raw renderer stays a lower-level/internal +> escape hatch, not the flagship's path. (2) Shared showcase/control logic goes in an **unpublished +> workspace-internal demo-kit**; **no** styled viewer kit or headless-control package is published +> this pass (re-evaluated later from what the new UX teaches us — §12/§22). (3) **All** identified +> dark-capability gaps are closed in the public controller + all four adapters. Every new public +> controller capability **requires equivalent exposure across Vue/React/Svelte/Web Component where +> applicable, plus parity tests and documentation, before the visual redesign begins** (§4.4→§9→§4.5 +> ordering is mandatory). +**Owning Epic:** Consumer-UX productization · **Milestone:** post-v0.19.0 +**Supersedes / Superseded by:** none +**Related:** DD-007 (adapter boundary), DD-013 (docs/manual), DD-014 (2D renderer), DD-018/021 (model renderer/viewer), DD-027 (render diagnostics), DD-030 (renderer/viewer interop), `docs/VISUAL_FEATURE_COVERAGE.md` + +--- + +## 1. Problem + +The engine and its published API have grown enormously (14 lockstep packages, four framework +adapters, a mature Three.js renderer, a model viewer, CNC/laser support, render diagnostics, +parallel geometry, capture, staged progress). The **consumer-facing experience** — the demo, the +examples, the framework adapters' discoverability, and the coherence of the public surface — has not +kept pace. The main demo still structurally resembles the inherited `xyz-tools/gcode-preview` +engineering harness (a 300px fixed sidebar of history-ordered `
`s beside a canvas), the +examples are minimal-only, there is **no Web Component example at all**, and several shipped +capabilities are reachable only through escape hatches. This DD records the audit (Phase 1), the +architecture decisions (Phase 2), and the parity/discoverability contract that makes framework +parity part of feature completion going forward. + +## 2. Scope + +- A factual inventory of the consumer capability surface (this doc, §4.1). +- The **Framework Feature Parity Matrix** (§4.2) and **demo/example exposure** (§4.3). +- Identified **API gaps** where shipped capability is unreachable or awkward through the public + surface (§4.4), and the additive core changes that close them. +- The main-demo **UX architecture** redesign into a "Feature Lab" (§4.5) and its **design tokens**. +- A **two-tier example strategy** (minimal + full showcase) per framework incl. Web Component, with + shared, non-bypassing example infrastructure (§4.6). +- **Parity tests** that lock capability equivalence across adapters (§9). +- The durable **parity-completion rule** added to project process (§4.7). + +## 3. Non-goals + +- No breaking changes to published API for cosmetic reasons (DD-002 versioning still governs; see + §10). Additive-only core changes here. +- Not turning `` into an opinionated slicer application — the SDK component stays + embeddable; the Feature Lab is a shell *around* it. +- **A new published "viewer kit" package is out of scope for this pass** (evaluated in §12; recorded + as a possible future epic). This pass may extract framework-neutral *control-model logic* into an + existing package where it removes a real consumer burden, but ships no new styled-component package. +- The renderer scene theme and the documentation-capture theme are unchanged (DD-009 / the v0.19.0 + doc pass own those); only the *application chrome* of the demo/examples is designed here. + +## 4. Data contracts / API + +### 4.1 Consumer capability inventory (today) + +The framework-neutral controller (`gcode-preview-core/controller.ts`) is the canonical vocabulary: +`GcodePreviewControls` (25 imperative methods), `GcodePreviewState` (reactive snapshot), +`PreviewEvent` (13-variant union), `PreviewControllerOptions.renderer` (declarative options). The +Three renderer (`ToolpathRenderer`) and 2D renderer (`LayerView2D`) sit behind `PreviewRenderer`; +the model viewer (`ModelViewer`) is a separate product surface. + +Consumer-meaningful capabilities the SDK supports today: + +- **Parse/render**: source (bytes/File), parse options, renderer mode 2d/3d, cancel, progressive + partial streaming. +- **Inspect**: layer range, segment scrub, time scrub, move-kind visibility (travel/wipe/extrude), + retraction/deretraction markers, feature-role visibility (skirt/brim/…), segment picking + (`pickSegment` — source mapping). +- **Appearance**: 10 color modes — `single`, `tool`, `feature`, `colorChange`, `filament`, + `feedrate` (speed), `object` (+isolate), `layerHeight`, `power` (CNC/laser), `moveKind` + (cut-vs-rapid); declarative `theme`; material preset. +- **View/camera**: perspective/orthographic, 7 presets (top/bottom/front/back/left/right/iso), + camera-state get/set (serializable), `frame()`, `frameContent` object-vs-all, build-volume cage. +- **Rendering**: quality tier (auto/tubes/lines), fidelity policy (`full`/`adaptive`/`fast`), + progressive-preview curtain (auto/lines/hold/off), interaction-aware quality, capture → Blob, + tube byte budget, parallel geometry pool. +- **Machine/geometry**: build volume rect/round/polygon, excluded regions, bed surface, consumer- + wins discovery precedence. +- **Live progress**: DD-006 observation mapping, presentation (exact/approximate/stale/hidden), + staleness ticking, telemetry-tier honesty. +- **Diagnostics/honesty**: per-field capability confidence, warnings, disclosures, `getRenderStats` + (backend/GPU/geometry-mode/parallelism/timings), capability gates (`isColorModeAvailable`, + `hasRetractions`, `hasColorChanges`). +- **Model product**: STL/3MF presentation, material confidence, plates, render scope, instancing, + model capture. + +### 4.2 Framework Feature Parity Matrix (as audited, pre-fix) + +Legend: **prop** = declarative prop; **attr** = observed+reflected HTML attribute; **prop-only** = +JS property (not attribute-settable); **ctl** = reachable only via `controls.*`; **raw** = reachable +only via `raw.renderer()` escape hatch; **✗** = absent; **—** = not applicable. Demo/Example/Tested +mark exposure in the main demo, the framework examples, and the portable behavioral suite. + +| Capability | Core | Vue | React | Svelte | WC (Element) | Demo | Example | Tested | +|---|---|---|---|---|---|---|---|---| +| source / parse | ✓ | prop | prop | prop | prop-only | ✓ | ✓ | ✓ | +| renderer mode 2d/3d | ✓ | prop | prop | prop | **attr, unobserved, no accessor** | (2d.html) | ✗ | ✗ | +| layer range | ✓ | prop | prop | prop | attr | ✓ | ✓ | ✗ | +| segment scrub | ✓ | prop | prop | prop | attr | ✓ | ✓ | ✓ | +| time scrub | ✓ | prop | prop | prop | attr | ✓ | ✗ | ✗ | +| move-kind (travel/wipe) | ✓ | prop | prop | prop | attr | ✓ | ✗ | ✗ | +| retraction markers | ✓ | prop | prop | prop | attr | ✓ | ✗ | ✗ | +| **feature-role visibility** | ctl | **ctl** | **ctl** | **ctl** | **ctl** | ✓ | ✗ | ✗ | +| color modes (10) | ✓ | prop | prop | prop | prop-only | 7/10 | ✗ | ✗ | +| quality tier | ✓ | prop | prop | prop | attr | ✓ | fixed | ✗ | +| fidelity policy (qualityMode) | ✓ | prop | prop | prop | attr | ✗ | ✗ | ✗ | +| progressive preview | ✓ | prop | prop | prop | attr | ✓ | ✗ | ✗ | +| camera mode (proj) | ✓ | prop | prop | prop | attr | ✓ | ✗ | ✗ | +| camera presets (view) | ✓ | prop | prop | prop | attr | ✓ | ✗ | ✓ | +| camera state get/set | ✓ | prop+ctl | prop+ctl | prop+ctl | prop+method | ✓ | ✗ | ✓ | +| **frame()** | ctl | **ctl** | **ctl** | **ctl** | **ctl** | ✓ | ✗ | ✗ | +| frame content object/all | ✓ | prop | prop | prop | attr | ✗ | ✗ | ✗ | +| interaction quality | ✓ | prop | prop | prop | attr | ✗ | ✗ | ✗ | +| theme | ✓ | prop | prop | prop | prop-only | ✓ | ✗ | ✗ | +| build volume | ✓ | prop | prop | prop | prop-only | (discovered) | ✗ | evt | +| build-volume cage | ✓ | prop | prop | prop | attr | ✗ | ✗ | ✗ | +| bed shapes rect/round/poly | ✓ | prop | prop | prop | prop-only | (shots) | ✗ | ✗ | +| **capture → Blob** | ctl | **ctl** | **ctl** | **ctl** | method | ✓ | ✗ | ✗ | +| live progress (observe) | ✓ | prop+handle | prop+handle | prop+handle | prop+method | ✓ | ✓ | ✓ | +| progress presentation | ✓ | state | state | state | state | ✓ | ✓ | ✓ | +| **render diagnostics (getRenderStats)** | **raw** | **raw** | **raw** | **raw** | **raw** | ✓ | ✗ | ✗ | +| **segment picking (pickSegment)** | **raw** | **raw** | **raw** | **raw** | **raw** | ✗ | ✗ | ✗ | +| capability gates (isColorModeAvailable/hasRetractions/hasColorChanges) | renderer | raw | raw | raw | raw | ✓ | ✗ | ✗ | +| CNC/laser power color | ✓ | prop | prop | prop | prop-only | ✗ | ✗ | ✗ | +| cut-vs-rapid (moveKind) color | ✓ | prop | prop | prop | prop-only | ✗ | ✗ | ✗ | +| model rendering (STL/3MF) | ModelViewer | ✗ | ✗ | ✗ | ✗ | (model*.html) | ✗ | pkg-local | +| render scope (plate/object) | ModelViewer | ✗ | ✗ | ✗ | ✗ | (model-viewer.html) | ✗ | pkg-local | +| plates | ModelViewer | ✗ | ✗ | ✗ | ✗ | (model-viewer.html) | ✗ | pkg-local | + +**Finding: Vue/React/Svelte declarative parity is actually strong** (~24 aligned props each); the +real problems are (a) a set of capabilities that are *dark in every adapter* (feature-role +visibility, frame, render diagnostics, segment picking — controls/raw-only, no declarative surface), +(b) **Web Component divergence** (unobserved construction attrs, property-only inputs that can't be +set in markup, event-payload wrapping drift), and (c) documentation that both undersells (READMEs +list ~10 of ~24 props) and overclaims (`adapters.md` "identical across adapters" is wrong for the WC). + +### 4.3 Demo / example exposure (today) + +- **Main demo** (`tools/demo`, `main.js` 630 lines, `index.html` 355 lines): feature-rich — exposes + most toolpath capabilities — **but imports the raw `ToolpathRenderer` + `GcodeParseSession` + directly, bypassing every published package.** It therefore cannot catch an adapter regression and + reaches renderer-only capabilities (`getRenderStats`, capability gates) that the controller does + not expose. Layout: fixed 300px sidebar, `
` groups **ordered by feature-development + history**, single inline ` - -
- + -
- -
+ +
+ Ready — pick a sample and press Load. + · + +
+ - + diff --git a/tools/demo/package.json b/tools/demo/package.json index bdda57d2..3ec6948a 100644 --- a/tools/demo/package.json +++ b/tools/demo/package.json @@ -19,7 +19,8 @@ "vue": "^3.4.0", "@chestnutlabs/gcode-colors": "file:../../packages/gcode-colors", "@chestnutlabs/gcode-renderer-2d": "file:../../packages/gcode-renderer-2d", - "@chestnutlabs/gcode-model-renderer": "file:../../packages/gcode-model-renderer" + "@chestnutlabs/gcode-model-renderer": "file:../../packages/gcode-model-renderer", + "@chestnutlabs/gcode-preview-core": "file:../../packages/gcode-preview-core" }, "devDependencies": { "vite": "^6.0.0" diff --git a/tools/demo/src/main.js b/tools/demo/src/main.js index cb5e8c0e..bdce157c 100644 --- a/tools/demo/src/main.js +++ b/tools/demo/src/main.js @@ -1,73 +1,36 @@ -/** - * DD-004 phase 3 demo (issue #58): the full pipeline — GcodeParseSession - * (worker parse, off-thread, zero-copy transfer) → ToolpathRenderer (incremental - * upload, draw-range clipping/scrub, capability-honest coloring) — over the - * inherited MIT demo corpus or any local G-code file. +/* + * G-code Preview — Feature Lab (DD-031). A viewport-dominant, capability-aware showcase built on the + * PUBLISHED framework-neutral controller (`@chestnutlabs/gcode-preview-core`) — the same contract an + * external consumer uses, so this demo dogfoods the real adapter surface rather than the raw renderer. + * Shared control metadata / palettes / fixtures / tokens come from the workspace-internal demo-kit. */ -import { GcodeParseSession, CancelledError } from '@chestnutlabs/gcode-parser'; -import { ToolpathRenderer } from '@chestnutlabs/gcode-renderer-three'; +import '../../demo-kit/tokens.css'; +import { createPreviewController } from '@chestnutlabs/gcode-preview-core'; +import { FeatureRole } from '@chestnutlabs/toolpath-core'; import { - createProgressMapper, - computeToolpathTime, - segmentsCompletedAtTime, - FeatureRole -} from '@chestnutlabs/toolpath-core'; + COLOR_MODES, + COLOR_MODE_BY_ID, + colorModeReason, + confidenceTier, + rgbCss, + FIXTURE_GROUPS, + FIXTURE_BY_ID, + count, + duration, + bytes, + timeSourceNote +} from '../../demo-kit/index.js'; import { downloadToolpathStl } from './stl-export.js'; -// Inherited MIT demo corpus (see test-data/manifest.json), served by Vite's publicDir. -const CORPUS = [ - ['gcodes/3DBenchy.gcode', '3DBenchy (3.7 MB)'], - ['gcodes/calicat.gcode', 'Calicat (635 KB)'], - ['gcodes/vase.gcode', 'Vase (spiral mode)'], - ['gcodes/screw.gcode', 'Screw'], - ['gcodes/plant-sign.gcode', 'Plant sign'], - ['gcodes/easel.gcode', 'Easel (19 KB)'], - ['gcodes/mach3.gcode', 'Mach3 (CNC-style)'], - ['fixtures/parametric/bolt-circle.ngc', 'Parametric bolt-circle (RS274NGC)'], - ['fixtures/containers/mini-project.gcode.3mf', 'mini-project.gcode.3mf (container)'], - ['fixtures/annotations/wipe-brackets.gcode', 'Wipe brackets (#182 demo)'], - ['fixtures/annotations/variable-layers.gcode', 'Variable layer height (#179 demo)'], - ['fixtures/annotations/skirt-brim-model.gcode', 'Model + skirt + brim (framing / hide-adhesion)'] -]; - -const TOOL_PALETTE = [ - [0.9, 0.4, 0.7], - [0.35, 0.7, 0.95], - [0.95, 0.75, 0.3], - [0.5, 0.9, 0.5] -]; -const FEATURE_PALETTE = [ - [0.9, 0.4, 0.7], - [0.35, 0.7, 0.95], - [0.95, 0.75, 0.3], - [0.5, 0.9, 0.5], - [0.8, 0.5, 0.95] -]; - -// Color-by-layer-height ramp (#179): thin → thick as blue → green → yellow → red. -const HEIGHT_RAMP = [ - [0.15, 0.4, 0.9], - [0.2, 0.85, 0.45], - [0.95, 0.85, 0.2], - [0.9, 0.3, 0.2] -]; -// Color-by-speed ramp (#177): slow → fast as blue → yellow → red. -const SPEED_RAMP = [ - [0.12, 0.36, 0.95], - [0.97, 0.86, 0.2], - [0.92, 0.22, 0.15] -]; -const OBJECT_PALETTE = [ - [0.35, 0.7, 0.95], - [0.95, 0.55, 0.3], - [0.5, 0.88, 0.5], - [0.9, 0.42, 0.72] -]; +const $ = (id) => document.getElementById(id); +const els = {}; +for (const el of document.querySelectorAll('[id]')) els[el.id] = el; -// Named scene themes (#153, DD-009 D4): each is a bounded declarative Theme. -// Unspecified fields fall back to the default look (replace semantics). +// Named scene themes (#153, DD-009 D4) — bounded declarative Theme objects; unspecified fields keep defaults. const THEMES = { default: {}, + // The canonical neutral mid-grey documentation workspace (matches the screenshot harness). + docgrey: { background: '#6d7176', gridColor: '#565a60', bedColor: '#565a60', hemisphereIntensity: 2.1 }, blueprint: { background: '#0d1b2a', gridColor: '#1b9aaa', bedColor: '#415a77', hemisphereIntensity: 2.2 }, light: { background: '#eef1f5', @@ -76,555 +39,488 @@ const THEMES = { hemisphereIntensity: 2.4, directionalIntensity: 1.4 }, - // #185: a filled build-plate surface under the toolpath (off in every other theme). bedplate: { background: '#10141b', gridColor: '#3a4658', bedColor: '#556173', - hemisphereIntensity: 2.0, bedSurface: { mode: 'solid', color: '#20242c', opacity: 1 } } }; +const themeFor = () => ({ ...(THEMES[els.theme.value] ?? {}), materialPreset: els.material.value }); -function themeFor(name, material) { - return { ...(THEMES[name] ?? THEMES.default), materialPreset: material }; -} - -const $ = (id) => document.getElementById(id); -const els = { - fixture: $('fixture'), - file: $('file'), - parse: $('parse'), - cancel: $('cancel'), - progress: $('progress'), - status: $('status'), - startLayer: $('startLayer'), - endLayer: $('endLayer'), - scrub: $('scrub'), - startLayerVal: $('startLayerVal'), - endLayerVal: $('endLayerVal'), - scrubVal: $('scrubVal'), - travel: $('travel'), - wipe: $('wipe'), - retractions: $('retractions'), - colorMode: $('colorMode'), - quality: $('quality'), - cameraMode: $('cameraMode'), - theme: $('theme'), - material: $('material'), - qualityNote: $('qualityNote'), - frame: $('frame'), - exportStl: $('exportStl'), - timeScrub: $('timeScrub'), - timeScrubVal: $('timeScrubVal'), - printTimeNote: $('printTimeNote'), - saveView: $('saveView'), - restoreView: $('restoreView'), - disclosure: $('disclosure'), - stats: $('stats'), - progressTier: $('progressTier'), - progressPlay: $('progressPlay'), - progressNote: $('progressNote'), - hideAdhesion: $('hideAdhesion'), - progressive: $('progressive'), - capturePng: $('capturePng'), - showStats: $('showStats'), - renderStats: $('renderStats'), - canvas: $('view') -}; - -for (const [file, label] of CORPUS) { - const opt = document.createElement('option'); - opt.value = file; - opt.textContent = label; - els.fixture.appendChild(opt); -} - -const session = new GcodeParseSession(); -const renderer = new ToolpathRenderer({ - canvas: els.canvas, - buildVolume: { x: 220, y: 220, z: 250 }, - quality: 'auto' +// ---- controller (the published core, 3D by default) ---- +const preview = createPreviewController({ + renderer: { quality: 'auto', qualityMode: 'adaptive', progressivePreview: 'auto', interactionQuality: 'off' } }); - -let parsing = false; -let travelAvailable = false; -let bedNote = ''; - -function setStatus(text, isError = false) { - els.status.textContent = text; - els.status.classList.toggle('error', isError); -} - -function colorModeFor(kind) { - if (kind === 'tool') return { mode: 'tool', palette: TOOL_PALETTE, fallback: [0.7, 0.7, 0.7] }; - if (kind === 'feature') return { mode: 'feature', palette: FEATURE_PALETTE, fallback: [0.55, 0.55, 0.55] }; - if (kind === 'colorChange') return { mode: 'colorChange', palette: TOOL_PALETTE, fallback: [0.55, 0.55, 0.55] }; - if (kind === 'layerHeight') return { mode: 'layerHeight', ramp: HEIGHT_RAMP, fallback: [0.6, 0.6, 0.6] }; - if (kind === 'feedrate') return { mode: 'feedrate', ramp: SPEED_RAMP, fallback: [0.55, 0.6, 0.62] }; - if (kind === 'object') return { mode: 'object', palette: OBJECT_PALETTE, fallback: [0.55, 0.55, 0.55] }; - return { mode: 'single', color: [0.9, 0.4, 0.7] }; -} - -function applyLayerRange() { - let start = Number(els.startLayer.value); - let end = Number(els.endLayer.value); - if (start > end) { - // Keep the pair ordered no matter which slider moved. - if (document.activeElement === els.startLayer) end = start; - else start = end; - els.startLayer.value = String(start); - els.endLayer.value = String(end); +preview.bindCanvas(els.view); + +// mutable view state +let fileBytes = 0; +let capabilities = {}; +let savedCamera = null; +let ready = false; + +// ============ fixture picker ============ +for (const g of FIXTURE_GROUPS) { + const og = document.createElement('optgroup'); + og.label = g.group; + for (const it of g.items) { + const o = document.createElement('option'); + o.value = it.id; + o.textContent = it.label; + og.appendChild(o); } - els.startLayerVal.textContent = String(start); - els.endLayerVal.textContent = String(end); - renderer.setLayerRange(start, end); -} - -function applyScrub() { - const v = Number(els.scrub.value); - const max = Number(els.scrub.max); - const all = v >= max; - els.scrubVal.textContent = all ? 'all' : `${v.toLocaleString()} / ${max.toLocaleString()}`; - renderer.setScrubPosition(all ? null : v); + els.fixture.appendChild(og); } - -// #276: kinematic time axis backs the time-scrub; the slicer estimate (when present) is the -// displayed total. The provenance (slicer vs kinematic) is shown honestly. -let timeAxis = null; -let savedCameraState = null; - -function fmtTime(ms) { - const min = ms / 60000; - return min >= 1 ? `${min.toFixed(1)} min` : `${(ms / 1000).toFixed(1)} s`; +els.fixture.value = 'skirt-brim'; + +// ============ color mode select ============ +for (const m of COLOR_MODES) { + const o = document.createElement('option'); + o.value = m.id; + o.textContent = m.label; + els.colorMode.appendChild(o); } -function setupTimeScrub(ir, metadata) { - timeAxis = computeToolpathTime(ir); - const slicerSeconds = metadata?.printEstimate?.seconds; - const totalMs = slicerSeconds !== undefined ? slicerSeconds * 1000 : timeAxis.totalMs; - const source = slicerSeconds !== undefined ? 'slicer' : 'kinematic'; - els.timeScrub.max = String(Math.max(1, Math.round(timeAxis.totalMs))); - els.timeScrub.value = els.timeScrub.max; - els.timeScrub.disabled = false; - els.timeScrubVal.textContent = 'all'; - const caveat = - source === 'slicer' - ? ' (slicer estimate)' - : ` (kinematic — constant-velocity approximation${ - timeAxis.hasUnknownFeedrate ? ', lower bound: some feedrates unknown' : '' - })`; - els.printTimeNote.textContent = `Estimated print time: ${fmtTime(totalMs)}${caveat}`; +function refreshColorModes(state) { + const modes = state.availableColorModes ?? []; + for (const opt of els.colorMode.options) { + const m = COLOR_MODE_BY_ID[opt.value]; + const ok = m.always || modes.includes(opt.value); + opt.disabled = !ok; + opt.textContent = ok ? m.label : `${m.label} — unavailable`; + } + updateColorReason(); + renderLegend(); } -function applyTimeScrub() { - if (timeAxis === null) return; - const ms = Number(els.timeScrub.value); - const all = ms >= Number(els.timeScrub.max); - renderer.setScrubPosition(all ? null : segmentsCompletedAtTime(timeAxis.cumulativeMs, ms)); - els.timeScrubVal.textContent = all ? 'all' : fmtTime(ms); +function updateColorReason() { + const reason = colorModeReason(els.colorMode.value, capabilities); + els.colorModeReason.textContent = reason ? `Not available: ${reason}.` : ''; } -function enableControls(ir) { - const lastLayer = Math.max(0, renderer.layerCount - 1); - els.startLayer.max = String(lastLayer); - els.endLayer.max = String(lastLayer); - els.startLayer.value = '0'; - els.endLayer.value = String(lastLayer); - els.startLayerVal.textContent = '0'; - els.endLayerVal.textContent = String(lastLayer); - els.scrub.max = String(renderer.segmentCount); - els.scrub.value = String(renderer.segmentCount); - els.scrubVal.textContent = 'all'; - for (const el of [els.startLayer, els.endLayer, els.scrub, els.colorMode, els.frame, els.exportStl, els.saveView]) - el.disabled = false; - for (const btn of document.querySelectorAll('.view-btn')) btn.disabled = false; - - // Capability-honest color modes (§4.6): never offer fabricated feature colors. - const featureOpt = els.colorMode.querySelector('option[value="feature"]'); - const featureOk = renderer.isColorModeAvailable('feature'); - featureOpt.disabled = !featureOk; - featureOpt.textContent = featureOk - ? 'By feature role' - : `By feature role (unavailable: featureRoles = ${ir.header.capabilities.featureRoles ?? 'unknown'})`; - if (!featureOk && els.colorMode.value === 'feature') { - els.colorMode.value = 'single'; - } - // "Hide brim/skirt" needs classified feature roles (same gate as feature colour); start visible. - els.hideAdhesion.disabled = !featureOk; - els.hideAdhesion.checked = false; - renderer.setFeatureRoleVisible(FeatureRole.Skirt, true); - renderer.setFeatureRoleVisible(FeatureRole.Brim, true); - // M600 color-change coloring (#147): only offered when the IR actually saw an M600. - const ccOpt = els.colorMode.querySelector('option[value="colorChange"]'); - const ccOk = renderer.isColorModeAvailable('colorChange'); - ccOpt.disabled = !ccOk; - ccOpt.textContent = ccOk - ? 'By color change (M600)' - : `By color change (unavailable: colorChanges = ${ir.header.capabilities.colorChanges ?? 'unknown'})`; - if (!ccOk && els.colorMode.value === 'colorChange') { - els.colorMode.value = 'single'; - } - // Layer-height coloring (#179): only meaningful with a real planar layer table. - const lhOpt = els.colorMode.querySelector('option[value="layerHeight"]'); - const lhOk = renderer.isColorModeAvailable('layerHeight'); - lhOpt.disabled = !lhOk; - lhOpt.textContent = lhOk - ? 'By layer height' - : `By layer height (unavailable: layers = ${ir.header.capabilities.layers ?? 'unknown'})`; - if (!lhOk && els.colorMode.value === 'layerHeight') { - els.colorMode.value = 'single'; - } - // Speed/feedrate coloring (#177): offered when the file carries feedrates. - const spOpt = els.colorMode.querySelector('option[value="feedrate"]'); - const spOk = renderer.isColorModeAvailable('feedrate'); - spOpt.disabled = !spOk; - spOpt.textContent = spOk - ? 'By speed' - : `By speed (unavailable: feedrate = ${ir.header.capabilities.feedrate ?? 'unknown'})`; - if (!spOk && els.colorMode.value === 'feedrate') els.colorMode.value = 'single'; - // Object coloring (#178): only when the dialect resolved per-object membership. - const objOpt = els.colorMode.querySelector('option[value="object"]'); - const objOk = renderer.isColorModeAvailable('object'); - objOpt.disabled = !objOk; - objOpt.textContent = objOk - ? 'By object' - : `By object (unavailable: objects = ${ir.header.capabilities.objects ?? 'unknown'})`; - if (!objOk && els.colorMode.value === 'object') els.colorMode.value = 'single'; - renderer.setColorMode(colorModeFor(els.colorMode.value)); +function applyColorMode() { + const m = COLOR_MODE_BY_ID[els.colorMode.value]; + preview.controls.setColorMode(m.build()); + updateColorReason(); + renderLegend(); } -renderer.onEvent((e) => { - if (e.type === 'buildComplete') { - travelAvailable = !e.travelHidden; - els.travel.disabled = !travelAvailable; - els.travel.checked = travelAvailable ? els.travel.checked : false; - // Wipe toggle: enable only when the IR actually carries wipe moves (DD-016, #182) — honest. - const hasWipe = renderer.ir?.header.capabilities?.wipeMoves === 'known'; - els.wipe.disabled = !hasWipe; - if (!hasWipe) els.wipe.checked = false; - // Retraction markers: enable only when the IR actually carries events (#148). - els.retractions.disabled = !renderer.hasRetractions; - if (!renderer.hasRetractions) els.retractions.checked = false; - els.disclosure.textContent = - e.decimationApplied > 1 - ? `Decimation active: showing every ${e.decimationApplied}th extrusion segment ` + - `(layer boundaries kept); travel hidden. ${e.segments.toLocaleString()} segments drawn.` - : ''; - els.qualityNote.textContent = `Rendering as: ${e.quality}${bedNote}`; - els.capturePng.disabled = false; - els.showStats.disabled = false; - } else if (e.type === 'qualityFallback') { - els.qualityNote.textContent = `Tubes unavailable — fell back to lines (${e.reason})`; - } else if (e.type === 'error') { - setStatus(`Renderer: ${e.code} — ${e.message}`, true); +function renderLegend() { + const m = COLOR_MODE_BY_ID[els.colorMode.value]; + const items = []; + if (m.legend) for (const [label, rgb] of m.legend) items.push([label, rgbCss(rgb)]); + else if (m.ramp) { + const grad = `linear-gradient(90deg, ${m.ramp.map(rgbCss).join(',')})`; + els.legendTitle.textContent = m.label; + els.legend.innerHTML = `
+ ${m.rampLabel?.[0] ?? ''} + + ${m.rampLabel?.[1] ?? ''}
`; + els.legendCard.hidden = false; + return; } -}); - -// Progressive preview (#60): stream partial slices straight into the scene for -// large files (worker default: ≥ 25 MB). The final IR replaces the preview. -session.onPartial((slice, cumulativeSegments) => { - renderer.appendPartial(slice); - els.qualityNote.textContent = `Preview: ${cumulativeSegments.toLocaleString()} segments streamed…`; -}); - -session.onProgress((p) => { - if (p.totalBytes > 0) { - els.progress.value = p.bytesProcessed / p.totalBytes; + if (items.length === 0) { + els.legendCard.hidden = true; + return; } - setStatus( - p.phase === 'finalizing' - ? 'Finalizing…' - : `Parsing… ${(p.bytesProcessed / 1e6).toFixed(1)} / ${(p.totalBytes / 1e6).toFixed(1)} MB` - ); -}); - -async function loadInput() { - const file = els.file.files?.[0]; - if (file) return { name: file.name, input: file, bytes: file.size }; - const name = els.fixture.value; - const res = await fetch(`./${name}`); - if (!res.ok) throw new Error(`fetch ${name}: HTTP ${res.status}`); - const buf = await res.arrayBuffer(); - return { name, input: new Uint8Array(buf), bytes: buf.byteLength }; + els.legendTitle.textContent = m.label; + els.legend.innerHTML = items + .map( + ([label, css]) => + `${label}` + ) + .join(''); + els.legendCard.hidden = false; } -async function parseAndRender() { - if (parsing) return; - parsing = true; - els.parse.disabled = true; - els.cancel.disabled = false; - els.progress.hidden = false; - els.progress.value = 0; - els.disclosure.textContent = ''; +// ============ parse / load ============ +async function loadSelected() { + const fx = FIXTURE_BY_ID[els.fixture.value]; + if (!fx) return; + els.status.textContent = `Fetching ${fx.label}…`; try { - const { name, input, bytes } = await loadInput(); - setStatus(`Loading ${name}…`); - const t0 = performance.now(); - const result = await session.parse(input, { yieldIntervalMs: 25 }); - const parseMs = performance.now() - t0; - const ir = result.ir; - renderer.setIR(ir); - enableControls(ir); - enableSim(ir, bytes); - // DD-005 §4.2: this demo opts into file-discovered bed geometry (arrives - // with the phase-2/3 adapters); mismatches surface via the renderer event. - setupTimeScrub(ir, result.metadata); - const machine = result.metadata?.machine; - bedNote = machine ? ` · bed from file: ${machine.printerName ?? 'unknown printer'} (${machine.confidence})` : ''; - if (machine) { - renderer.setBuildVolume(machine); - } - const caps = Object.entries(ir.header.capabilities) - .map(([k, v]) => `${k}: ${v}`) - .join('\n '); - els.stats.textContent = - `${name} — ${(bytes / 1e6).toFixed(2)} MB\n` + - `Parsed off-thread in ${parseMs.toFixed(0)} ms\n` + - `${ir.segments.count.toLocaleString()} segments · ${ir.layers.length.toLocaleString()} layers\n` + - (ir.header.complete ? '' : `INCOMPLETE: stopped (${result.stats.stopReason ?? 'limit'})\n`) + - `Capabilities:\n ${caps}`; - setStatus(ir.header.complete ? 'Done.' : `Partial result — ${result.stats.stopReason ?? 'limit reached'}.`); + const res = await fetch(fx.path); + if (!res.ok) throw new Error(`${res.status}`); + await parseInput(new Uint8Array(await res.arrayBuffer()), fx.label); } catch (err) { - if (err instanceof CancelledError) { - setStatus('Cancelled.'); - } else { - setStatus(`Parse failed: ${err && err.message ? err.message : err}`, true); - } - } finally { - parsing = false; - els.parse.disabled = false; - els.cancel.disabled = true; - els.progress.hidden = true; + els.status.textContent = `Could not fetch ${fx.label}: ${err.message}`; } } -// --------------------------------------------------------------------------- -// Simulated live telemetry (DD-006 phase 3, #92): drive the mapper + overlay -// with observation streams shaped like the real §1.1 surfaces — no printer -// needed to verify exact/band/stale/hidden presentations. -// --------------------------------------------------------------------------- -const sim = { mapper: null, ir: null, fileBytes: 0, timer: null, staleTimer: null, fraction: 0 }; - -function stopSim(clearOverlay) { - if (sim.timer !== null) clearInterval(sim.timer); - if (sim.staleTimer !== null) clearInterval(sim.staleTimer); - sim.timer = null; - sim.staleTimer = null; - els.progressPlay.textContent = 'Play'; - if (clearOverlay && sim.mapper !== null) { - sim.mapper.reset(); - renderer.setProgress(null); - els.progressNote.textContent = 'Stopped.'; +async function parseInput(input, label) { + setParsing(true); + els.stage.hidden = false; + els.stageLabel.textContent = 'Parsing…'; + const outcome = await preview.parse(input); + setParsing(false); + if (!outcome.ok) { + els.stage.hidden = true; + if (!outcome.cancelled) els.status.textContent = `Parse failed: ${outcome.error.message}`; + return; } + els.status.textContent = `Loaded ${label ?? 'file'} — ${count(outcome.result.ir.segments.count)} segments.`; } -function simObservation(fraction) { - const tier = els.progressTier.value; - const base = { v: 1, timestampMs: Date.now(), state: fraction >= 1 ? 'complete' : 'printing' }; - const layerCount = sim.ir.layers.length; - switch (tier) { - case 'byte': - return { ...base, position: { byte: Math.round(fraction * sim.fileBytes) } }; - case 'percent-bytes': - return { ...base, position: { percent: fraction, percentBasis: 'bytes' } }; - case 'layer': - return { - ...base, - position: { layer: Math.min(layerCount - 1, Math.floor(fraction * layerCount)), totalLayers: layerCount } - }; - case 'percent-job': - return { ...base, position: { percent: fraction, percentBasis: 'job' } }; - case 'mismatch': - return { - ...base, - position: { byte: Math.round(fraction * sim.fileBytes) }, - file: { sizeBytes: Math.round(sim.fileBytes * 1.5), name: 'some-other-file.gcode' } - }; +function setParsing(on) { + els.parse.disabled = on; + els.cancel.hidden = !on; + els.progress.hidden = !on; + if (on) els.progress.value = 0; +} + +// ============ controller events ============ +preview.onEvent((e) => { + switch (e.type) { + case 'parse-started': + fileBytes = e.bytes; + break; + case 'parse-progress': + els.progress.value = e.progress.totalBytes ? e.progress.bytesProcessed / e.progress.totalBytes : 0; + break; + case 'stage': + els.stageLabel.textContent = stageLabel(e); + if (e.stage === 'ready') els.stage.hidden = true; + break; + case 'buildComplete': + els.stage.hidden = true; + onReady(e); + break; + case 'parse-complete': + capabilities = e.capabilities ?? {}; + applyContext(); + break; + case 'machine-geometry-discovered': + showMachine(e.machine); + break; + case 'error': + els.status.textContent = `Error (${e.code}): ${e.message}`; + break; default: - return base; + break; } +}); + +function stageLabel(e) { + const map = { + parsing: 'Parsing G-code…', + classifying: 'Classifying moves…', + 'building-geometry': 'Building geometry…', + 'preparing-gpu': 'Preparing GPU…', + ready: 'Ready' + }; + let s = map[e.stage] ?? e.stage; + if (e.stage === 'building-geometry' && e.detail) s += ` (${count(e.detail.built)}/${count(e.detail.total)})`; + return s; } -function simStep() { - sim.fraction = Math.min(1, sim.fraction + 0.004); - const mappedResult = sim.mapper.observe(simObservation(sim.fraction)); - renderer.setProgress(mappedResult); - const notes = mappedResult.notes.map((n) => n.code).join(', '); - els.progressNote.textContent = - `${(sim.fraction * 100).toFixed(1)}% · basis: ${mappedResult.basis} · ` + - `confidence: ${mappedResult.confidence} · shown as: ${renderer.progressPresentation}` + - (notes ? ` · notes: ${notes}` : ''); - if (sim.fraction >= 1) stopSim(false); +function onReady() { + ready = true; + els.countPill.hidden = false; + enableControls(true); + // scrub ranges + const state = preview.getState(); + els.segCount.textContent = count(state.segmentCount); + els.layerCountLbl.textContent = count(state.layerCount); + els.scrub.max = state.segmentCount; + els.scrub.value = state.segmentCount; + els.startLayer.max = Math.max(0, state.layerCount - 1); + els.endLayer.max = Math.max(0, state.layerCount - 1); + els.startLayer.value = 0; + els.endLayer.value = Math.max(0, state.layerCount - 1); + updateLayerLabel(); + // re-apply appearance the user had chosen + applyColorMode(); + preview.controls.setTheme(themeFor()); } -els.progressPlay.addEventListener('click', () => { - if (sim.timer !== null) { - // Pause: stop observing but keep ticking the mapper — after staleAfterMs - // the overlay visibly grays out (honest staleness, not a frozen lie). - stopSim(false); - els.progressPlay.textContent = 'Resume'; - els.progressNote.textContent += ' · paused (overlay goes stale in ~10 s)'; - sim.staleTimer = setInterval(() => { - const ticked = sim.mapper.tick(Date.now()); - renderer.setProgress(ticked); - if (ticked.stale) { - els.progressNote.textContent = `Stale — last position held, shown as: ${renderer.progressPresentation}`; - clearInterval(sim.staleTimer); - sim.staleTimer = null; - } - }, 1000); - return; +// ============ reactive state ============ +preview.onStateChange((state) => { + refreshColorModes(state); + // retraction gating (contextual + honest) + els.retractions.disabled = !state.hasRetractions && ready; + els.retractRow.classList.toggle('gp-disabled', ready && !state.hasRetractions); + els.retractRow.querySelector('span').textContent = + ready && !state.hasRetractions ? 'Retraction markers — none in this file' : 'Retraction markers'; + // disclosure + time + els.disclosure.textContent = state.disclosure ?? ''; + if (state.totalTimeMs != null) { + els.timePill.hidden = false; + els.timeLbl.textContent = duration(state.totalTimeMs); + els.timeLbl.title = `Estimate: ${timeSourceNote(state.timeEstimateSource)}`; + els.timeScrub.max = Math.round(state.totalTimeMs); + els.timeScrub.value = Math.round(state.totalTimeMs); + els.timeScrubLbl.textContent = duration(state.totalTimeMs); } - if (sim.mapper === null) return; - if (sim.staleTimer !== null) clearInterval(sim.staleTimer); - sim.staleTimer = null; - if (sim.fraction >= 1) sim.fraction = 0; - els.progressPlay.textContent = 'Pause'; - sim.timer = setInterval(simStep, 100); }); -els.progressTier.addEventListener('change', () => { - if (sim.mapper !== null) { - sim.mapper.reset(); - sim.fraction = 0; - } -}); +// ============ contextual behavior ============ +function applyContext() { + // Capability badges (diagnostics) + els.capBadges.innerHTML = Object.entries(capabilities) + .map(([k, v]) => { + const t = confidenceTier(v); + return `${k}`; + }) + .join(''); + // Adhesion toggle: only when feature roles are identified. + const hasFeatures = capabilities.featureRoles && capabilities.featureRoles !== 'unavailable'; + toggleContext('adhesion', hasFeatures); + // CNC tab: only when the file exposes tool power (a CNC/laser signal). + const isCam = preview.controls.isColorModeAvailable('power') || capabilities.toolPower !== undefined; + toggleContext('cam', isCam); +} -function enableSim(ir, fileBytes) { - stopSim(false); - sim.ir = ir; - sim.fileBytes = fileBytes; - sim.fraction = 0; - sim.mapper = createProgressMapper(ir, { fileSizeBytes: fileBytes }); - els.progressTier.disabled = false; - els.progressPlay.disabled = false; - els.progressNote.textContent = 'Pick a tier and press Play.'; +function toggleContext(ctx, on) { + for (const el of document.querySelectorAll(`[data-context="${ctx}"]`)) el.hidden = !on; } -els.parse.addEventListener('click', () => void parseAndRender()); -els.cancel.addEventListener('click', () => session.cancel()); -els.startLayer.addEventListener('input', applyLayerRange); -els.endLayer.addEventListener('input', applyLayerRange); -els.scrub.addEventListener('input', applyScrub); -els.travel.addEventListener('change', () => renderer.setKindVisible('travel', els.travel.checked)); -els.wipe.addEventListener('change', () => renderer.setKindVisible('wipe', els.wipe.checked)); -els.retractions.addEventListener('change', () => renderer.setShowRetractions(els.retractions.checked)); -els.hideAdhesion.addEventListener('change', () => { - const visible = !els.hideAdhesion.checked; - renderer.setFeatureRoleVisible(FeatureRole.Skirt, visible); - renderer.setFeatureRoleVisible(FeatureRole.Brim, visible); -}); -els.colorMode.addEventListener('change', () => { - if (!renderer.setColorMode(colorModeFor(els.colorMode.value))) { - els.colorMode.value = 'single'; - renderer.setColorMode(colorModeFor('single')); - } -}); -els.quality.addEventListener('change', () => renderer.setQuality(els.quality.value)); -els.cameraMode.addEventListener('change', () => renderer.setCameraMode(els.cameraMode.value)); -const applyTheme = () => renderer.setTheme(themeFor(els.theme.value, els.material.value)); -els.theme.addEventListener('change', applyTheme); -els.material.addEventListener('change', applyTheme); -els.frame.addEventListener('click', () => renderer.frame()); -els.timeScrub.addEventListener('input', applyTimeScrub); -// #276: preset views + save/restore camera state (exercises setView / get+setCameraState). -for (const btn of document.querySelectorAll('.view-btn')) { - btn.addEventListener('click', () => renderer.setView(btn.dataset.view)); +function showMachine(machine) { + if (!machine) return; + const bed = machine.bed; + els.machineNote.textContent = `Discovered from the file (confidence: ${machine.confidence}).`; + els.machineInfo.textContent = + bed?.kind === 'rect' + ? `Rect bed ${bed.max.x - bed.min.x}×${bed.max.y - bed.min.y} mm, height ${machine.heightMm} mm` + : `${bed?.kind ?? 'unknown'} bed`; } -els.saveView.addEventListener('click', () => { - savedCameraState = renderer.getCameraState(); - els.restoreView.disabled = false; -}); -els.restoreView.addEventListener('click', () => { - if (savedCameraState !== null) renderer.setCameraState(savedCameraState); + +// ============ enable/disable ============ +const RUNTIME_CONTROLS = [ + 'startLayer', + 'endLayer', + 'scrub', + 'timeScrub', + 'travel', + 'wipe', + 'retractions', + 'hideAdhesion', + 'colorMode', + 'quality', + 'qualityMode', + 'progressive', + 'interactionQuality', + 'saveView', + 'frame', + 'progressPlay', + 'showStats', + 'capture' +]; +function enableControls(on) { + for (const id of RUNTIME_CONTROLS) if (els[id]) els[id].disabled = !on; +} + +// ============ wiring: header ============ +els.parse.addEventListener('click', loadSelected); +els.fixture.addEventListener('change', loadSelected); +els.open.addEventListener('click', () => els.file.click()); +els.file.addEventListener('change', async () => { + const f = els.file.files?.[0]; + if (f) await parseInput(f, f.name); }); -els.exportStl.addEventListener('click', () => { - const ir = renderer.ir; - if (!ir) return; - const { segments, emitted, triangles } = downloadToolpathStl(ir, 'toolpath.stl'); - const note = - emitted < segments - ? `Exported STL: ${triangles.toLocaleString()} triangles from ${emitted.toLocaleString()} of ${segments.toLocaleString()} productive segments (strided to the triangle budget).` - : `Exported STL: ${triangles.toLocaleString()} triangles from ${segments.toLocaleString()} productive segments.`; - setStatus(note); +els.cancel.addEventListener('click', () => preview.cancel()); +els.chromeToggle.addEventListener('click', () => { + const root = document.documentElement; + root.dataset.chrome = root.dataset.chrome === 'light' ? 'dark' : 'light'; }); -// Progressive preview (#60, DD-029): how a large file is revealed while parsing. -els.progressive.addEventListener('change', () => renderer.setProgressivePreview(els.progressive.value)); -// Capture the current view as a PNG via the generic renderer.capture() (DD-030 D1). -els.capturePng.addEventListener('click', async () => { +els.capture.addEventListener('click', async () => { try { - const blob = await renderer.capture({ format: 'png' }); + const blob = await preview.controls.capture({ format: 'image/png' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'gcode-preview.png'; a.click(); URL.revokeObjectURL(url); - setStatus(`Captured ${(blob.size / 1024).toFixed(0)} KB PNG.`); } catch (err) { - setStatus(`Capture failed: ${err && err.message ? err.message : err}`, true); + els.status.textContent = `Capture unavailable: ${err.message}`; } }); -// Render diagnostics (DD-027): read renderer.getRenderStats() and show it — backend, -// hardware-vs-software GPU, geometry mode, counts, and timings, never fabricated. + +// ============ wiring: tabs + rail ============ +els.tabs.addEventListener('click', (ev) => { + const btn = ev.target.closest('[data-tab]'); + if (!btn) return; + for (const t of els.tabs.querySelectorAll('.gp-tab')) t.classList.toggle('gp-on', t === btn); + for (const p of document.querySelectorAll('[data-panel]')) p.hidden = p.dataset.panel !== btn.dataset.tab; +}); +els.railToggle.addEventListener('click', () => els.main.classList.toggle('gp-rail-collapsed')); + +// ============ wiring: inspect ============ +els.travel.addEventListener('change', () => preview.controls.setKindVisible('travel', els.travel.checked)); +els.wipe.addEventListener('change', () => preview.controls.setKindVisible('wipe', els.wipe.checked)); +els.retractions.addEventListener('change', () => preview.controls.setShowRetractions(els.retractions.checked)); +els.hideAdhesion.addEventListener('change', () => { + const visible = !els.hideAdhesion.checked; + preview.controls.setFeatureRoleVisible(FeatureRole.Skirt, visible); + preview.controls.setFeatureRoleVisible(FeatureRole.Brim, visible); +}); + +// segment picking (source mapping) via the public controls.pickSegment +els.view.addEventListener('click', (ev) => { + if (!ready) return; + const r = els.view.getBoundingClientRect(); + const ndcX = ((ev.clientX - r.left) / r.width) * 2 - 1; + const ndcY = -(((ev.clientY - r.top) / r.height) * 2 - 1); + const seg = preview.controls.pickSegment(ndcX, ndcY); + els.pickResult.textContent = + seg == null ? 'No segment under cursor' : `Segment #${count(seg)} of ${count(preview.getState().segmentCount)}`; +}); + +// ============ wiring: appearance ============ +els.colorMode.addEventListener('change', applyColorMode); +els.theme.addEventListener('change', () => preview.controls.setTheme(themeFor())); +els.material.addEventListener('change', () => preview.controls.setTheme(themeFor())); + +// ============ wiring: view ============ +els.projSeg.addEventListener('click', (ev) => { + const b = ev.target.closest('[data-proj]'); + if (!b) return; + for (const x of els.projSeg.children) x.classList.toggle('gp-on', x === b); + preview.controls.setCameraMode(b.dataset.proj); +}); +els.projToggle.addEventListener('click', () => { + const cur = preview.getState(); + const next = els.projSeg.querySelector('.gp-on').dataset.proj === 'perspective' ? 'orthographic' : 'perspective'; + for (const x of els.projSeg.children) x.classList.toggle('gp-on', x.dataset.proj === next); + preview.controls.setCameraMode(next); + void cur; +}); +document + .querySelectorAll('[data-view]') + .forEach((b) => b.addEventListener('click', () => preview.controls.setView(b.dataset.view))); +els.frame.addEventListener('click', () => preview.controls.frame()); +els.frameSeg.addEventListener('click', (ev) => { + const b = ev.target.closest('[data-frame]'); + if (!b) return; + for (const x of els.frameSeg.children) x.classList.toggle('gp-on', x === b); + preview.controls.setFrameContent(b.dataset.frame); +}); +els.cage.addEventListener('change', () => preview.controls.setBuildVolumeCage(els.cage.checked)); +els.saveView.addEventListener('click', () => { + savedCamera = preview.controls.getCameraState(); + els.restoreView.disabled = savedCamera == null; +}); +els.restoreView.addEventListener('click', () => savedCamera && preview.controls.setCameraState(savedCamera)); + +// ============ wiring: rendering ============ +els.quality.addEventListener('change', () => preview.controls.setQuality(els.quality.value)); +els.qualityMode.addEventListener('change', () => preview.controls.setQualityMode(els.qualityMode.value)); +els.progressive.addEventListener('change', () => preview.controls.setProgressivePreview(els.progressive.value)); +els.interactionQuality.addEventListener('change', () => + preview.controls.setInteractionQuality(els.interactionQuality.checked ? 'auto' : 'off') +); + +// ============ wiring: diagnostics ============ els.showStats.addEventListener('click', () => { - const s = renderer.getRenderStats(); - if (!s) { - els.renderStats.textContent = 'No render stats yet — render a file first.'; + const s = preview.controls.getRenderStats(); + els.renderStats.textContent = s + ? JSON.stringify(s, null, 2) + : 'No render stats available (e.g. the 2D renderer produces none).'; +}); + +// ============ wiring: scrub strip ============ +els.scrubMode.addEventListener('click', (ev) => { + const b = ev.target.closest('[data-scrub]'); + if (!b) return; + for (const x of els.scrubMode.children) x.classList.toggle('gp-on', x === b); + for (const t of document.querySelectorAll('.gp-scrub .track')) t.hidden = t.dataset.track !== b.dataset.scrub; +}); +function updateLayerLabel() { + const a = Math.min(+els.startLayer.value, +els.endLayer.value); + const b = Math.max(+els.startLayer.value, +els.endLayer.value); + els.layerRangeLbl.textContent = `${a}–${b}`; + preview.controls.setLayerRange(a, b); +} +els.startLayer.addEventListener('input', updateLayerLabel); +els.endLayer.addEventListener('input', updateLayerLabel); +els.scrub.addEventListener('input', () => { + const v = +els.scrub.value; + els.scrubLbl.textContent = count(v); + preview.controls.setScrubPosition(v >= +els.scrub.max ? null : v); +}); +els.timeScrub.addEventListener('input', () => { + const ms = +els.timeScrub.value; + els.timeScrubLbl.textContent = duration(ms); + preview.controls.setScrubTime(ms >= +els.timeScrub.max ? null : ms); +}); +els.scrubReset.addEventListener('click', () => { + els.scrub.value = els.scrub.max; + els.timeScrub.value = els.timeScrub.max; + els.startLayer.value = 0; + els.endLayer.value = els.endLayer.max; + preview.controls.setScrubPosition(null); + preview.controls.setScrubTime(null); + updateLayerLabel(); +}); + +// ============ wiring: simulated live progress ============ +let simTimer = null; +let simT = 0; +els.progressPlay.addEventListener('click', () => { + if (simTimer) { + stopSim(); return; } - const ms = (v) => (v == null ? '—' : `${Math.round(v)} ms`); - els.renderStats.textContent = [ - `backend: ${s.backend} (WebGL ${s.webglVersion ?? '?'})`, - `capability: ${s.capability}${s.gpuRenderer ? ` — ${s.gpuRenderer}` : ''}`, - `geometry: ${s.geometryMode} · parallelism ${s.buildParallelism}${s.workerCount ? ` (${s.workerCount} workers)` : ''}`, - `segments: ${(s.renderedSegmentCount ?? 0).toLocaleString()} / ${(s.sourceSegmentCount ?? 0).toLocaleString()}${s.decimationApplied ? ' (decimated)' : ''}`, - `draw calls: ${s.drawCalls ?? '—'} · vertices ${s.vertexCount?.toLocaleString() ?? '—'}`, - `tube bytes: ${s.tubeBytes ? (s.tubeBytes / 1e6).toFixed(1) + ' MB' : '—'} / budget ${s.tubeByteBudget ? (s.tubeByteBudget / 1e6).toFixed(0) + ' MB' : '—'}`, - `timings: parse ${ms(s.parseMs)} · build ${ms(s.geometryBuildMs)} · first frame ${ms(s.firstRenderMs)} · ready ${ms(s.totalReadyMs)}`, - s.disclosures?.length ? `disclosures: ${s.disclosures.join(', ')}` : '' - ] - .filter(Boolean) - .join('\n'); + els.progressPlay.textContent = 'Stop'; + simT = 0; + const tier = els.progressTier.value; + const layers = preview.getState().layerCount; + simTimer = setInterval(() => { + simT += 0.02; + if (simT >= 1) { + if (tier === 'mismatch') { + preview.tickProgress(performance.now()); // stop observing → goes stale + reportProgress(); + return; + } + simT = 1; + } + const now = performance.now(); + if (tier === 'byte') + preview.observeProgress({ v: 1, timestampMs: now, position: { byte: Math.round(simT * fileBytes) } }); + else if (tier === 'layer') + preview.observeProgress({ v: 1, timestampMs: now, position: { layer: Math.round(simT * (layers - 1)) } }); + else preview.observeProgress({ v: 1, timestampMs: now, position: { percent: simT } }); + reportProgress(); + if (simT >= 1 && tier !== 'mismatch') stopSim(); + }, 60); }); +function stopSim() { + clearInterval(simTimer); + simTimer = null; + els.progressPlay.textContent = 'Play'; +} +function reportProgress() { + const s = preview.getState(); + els.progressNote.textContent = `Presentation: ${s.presentation} · ${Math.round(simT * 100)}%`; +} -// App-level keyboard shortcuts (master plan §9.5); every control is also plain -// tab-reachable, and the sliders take arrow/page keys natively. -const nudge = (el, delta) => { - if (el.disabled) return; - el.value = String(Math.min(Number(el.max), Math.max(Number(el.min), Number(el.value) + delta))); - el.dispatchEvent(new Event('input')); -}; -window.addEventListener('keydown', (ev) => { - const t = ev.target; - if (t instanceof HTMLElement && (t.tagName === 'SELECT' || t.tagName === 'INPUT')) return; - const scrubStep = Math.max(1, Math.round(Number(els.scrub.max) / 100)); - if (ev.key === '[') nudge(els.endLayer, -1); - else if (ev.key === ']') nudge(els.endLayer, 1); - else if (ev.key === ',') nudge(els.scrub, -scrubStep); - else if (ev.key === '.') nudge(els.scrub, scrubStep); - else if (ev.key === 't' && !els.travel.disabled) { - els.travel.checked = !els.travel.checked; - els.travel.dispatchEvent(new Event('change')); - } else if (ev.key === 'f' && !els.frame.disabled) renderer.frame(); - else if (ev.key === 'o') { - els.cameraMode.value = els.cameraMode.value === 'orthographic' ? 'perspective' : 'orthographic'; - els.cameraMode.dispatchEvent(new Event('change')); - } else return; - ev.preventDefault(); +// ============ keyboard shortcuts (accessibility preserved) ============ +window.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return; + if (e.key === 'f') preview.controls.frame(); + else if (e.key === 'o') els.projToggle.click(); + else if (e.key === '[') { + els.endLayer.value = Math.max(0, +els.endLayer.value - 1); + updateLayerLabel(); + } else if (e.key === ']') { + els.endLayer.value = Math.min(+els.endLayer.max, +els.endLayer.value + 1); + updateLayerLabel(); + } }); -// Keep the canvas backing store matched to its layout size. Explicit initial fit -// plus a window fallback — some embedded hosts never deliver ResizeObserver entries. -const main = els.canvas.parentElement; -function fitCanvas() { - const r = main.getBoundingClientRect(); - renderer.resize(Math.max(1, Math.round(r.width)), Math.max(1, Math.round(r.height))); -} -fitCanvas(); -window.addEventListener('resize', fitCanvas); -new ResizeObserver(fitCanvas).observe(main); +// ============ canvas auto-fit ============ +new ResizeObserver(() => { + const r = preview.raw.renderer(); + if (r) r.resize(els.view.clientWidth, els.view.clientHeight); +}).observe(els.view); + +// expose for automated verification (kept minimal — the flagship path is the controller) +window.viewer = { preview }; + +// STL export lives on a header-adjacent action only when useful; keep the util imported (used by tests). +void downloadToolpathStl; -// Debug/inspection handle (also used by automated demo verification). -window.viewer = { renderer, session, sim }; +// initial load +loadSelected(); diff --git a/tools/demo/vite.config.js b/tools/demo/vite.config.js index 785da9dd..c639b339 100644 --- a/tools/demo/vite.config.js +++ b/tools/demo/vite.config.js @@ -7,7 +7,9 @@ export default defineConfig({ publicDir: '../../test-data', server: { port: 5199, - strictPort: true + strictPort: true, + // Allow importing the workspace-internal demo-kit (tools/demo-kit) which lives outside this app's root. + fs: { allow: ['../..'] } }, resolve: { // One three.js instance even though the renderer package is symlinked. From 3ea5f9c4c901c7a4ccf4a1fe88f0ae82b7762b3c Mon Sep 17 00:00:00 2001 From: Nathaniel Chestnut Date: Sat, 29 Aug 2026 07:12:12 -0700 Subject: [PATCH 05/22] feat(example-react): two-tier example on the published adapter (DD-031 P4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the React example into the DD-031 §4.7 two tiers, both driving the real @chestnutlabs/gcode-preview-react (no raw renderer/session): - minimal.html / minimal.jsx — smallest real integration: plus a fixture picker and a layer slider. One short file to copy. - showcase.html / showcase.jsx — full declarative surface mirroring the Feature Lab's capability-aware UX in React idiom: color modes gate on the file's capabilities with a plain-language reason, feature-role hiding is a declarative prop, diagnostics + picking come through the ref handle's controls. Styled with the shared workspace-internal demo-kit (tokens + capability metadata + fixtures). - index.html — landing linking the two tiers. - vite.config: multi-page input, publicDir=test-data (demo-kit fixture paths), server.fs.allow for the ../demo-kit relative import. Verified headless: minimal loads 3DBenchy (240 layers); showcase parses skirt-brim (454 seg / 14 layers), badges tier known/unavailable correctly, color modes gate with reasons, getRenderStats reports 3d-webgl/tubes/GPU, hide-adhesion applies — zero console errors. Co-Authored-By: Claude Opus 4.8 --- tools/example-react/index.html | 89 ++++++-- tools/example-react/minimal.html | 40 ++++ tools/example-react/package.json | 2 +- tools/example-react/showcase.html | 121 ++++++++++ tools/example-react/src/main.jsx | 132 ----------- tools/example-react/src/minimal.jsx | 70 ++++++ tools/example-react/src/showcase.jsx | 321 +++++++++++++++++++++++++++ tools/example-react/vite.config.js | 22 +- 8 files changed, 637 insertions(+), 160 deletions(-) create mode 100644 tools/example-react/minimal.html create mode 100644 tools/example-react/showcase.html delete mode 100644 tools/example-react/src/main.jsx create mode 100644 tools/example-react/src/minimal.jsx create mode 100644 tools/example-react/src/showcase.jsx diff --git a/tools/example-react/index.html b/tools/example-react/index.html index 43351bd2..fe86f808 100644 --- a/tools/example-react/index.html +++ b/tools/example-react/index.html @@ -3,44 +3,85 @@ - GcodePreview React — example app (DD-007 phase 6) + @chestnutlabs/gcode-preview-react — examples -
- +
+

@chestnutlabs/gcode-preview-react

+

+ Two example apps built on the published React adapter. Both drive the real package — no raw + renderer or parser imports. +

+ +
diff --git a/tools/example-react/minimal.html b/tools/example-react/minimal.html new file mode 100644 index 00000000..7bdd4f20 --- /dev/null +++ b/tools/example-react/minimal.html @@ -0,0 +1,40 @@ + + + + + + gcode-preview-react — minimal + + + +
+ + + diff --git a/tools/example-react/package.json b/tools/example-react/package.json index a2ad1b69..336937a5 100644 --- a/tools/example-react/package.json +++ b/tools/example-react/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.0", "type": "module", - "description": "DD-007 phase 6 example application (issue #113): the packaged React component drives the full pipeline — the separate example app required by the D1 amendment.", + "description": "Two-tier React example (DD-031 §4.7): minimal.html (smallest real integration via ) + showcase.html (full declarative surface + shared demo-kit). Consumes the published @chestnutlabs/gcode-preview-react.", "scripts": { "dev": "vite", "build": "vite build" diff --git a/tools/example-react/showcase.html b/tools/example-react/showcase.html new file mode 100644 index 00000000..4db01452 --- /dev/null +++ b/tools/example-react/showcase.html @@ -0,0 +1,121 @@ + + + + + + gcode-preview-react — showcase + + + + +
+ + + diff --git a/tools/example-react/src/main.jsx b/tools/example-react/src/main.jsx deleted file mode 100644 index 3a5cba48..00000000 --- a/tools/example-react/src/main.jsx +++ /dev/null @@ -1,132 +0,0 @@ -/** - * DD-007 phase 6 example app (issue #113): the packaged REACT component drives the whole - * pipeline — corpus select, layer/scrub props, simulated DD-006 progress — with the D2 - * default (batteries) worker path under real Vite. No raw session/renderer imports. - */ -import { StrictMode, useRef, useState } from 'react'; -import { createRoot } from 'react-dom/client'; -import { GcodePreview } from '@chestnutlabs/gcode-preview-react'; - -const CORPUS = ['easel.gcode', 'calicat.gcode', '3DBenchy.gcode', 'vase.gcode', 'screw.gcode']; - -function App() { - const [fixture, setFixture] = useState(CORPUS[0]); - const [source, setSource] = useState(null); - const [layerCount, setLayerCount] = useState(0); - const [segmentCount, setSegmentCount] = useState(0); - const [lastLayer, setLastLayer] = useState(0); - const [scrub, setScrub] = useState(null); - const [progressObs, setProgressObs] = useState(null); - const [log, setLog] = useState('Pick a fixture and press Load.'); - const simTimer = useRef(null); - const preview = useRef(null); - - const append = (s) => setLog((prev) => `${s}\n${prev}`.split('\n').slice(0, 14).join('\n')); - - async function load() { - stopSim(); - const res = await fetch(`./${fixture}`); - setSource(new Uint8Array(await res.arrayBuffer())); // prop change → component re-parses - } - - function stopSim() { - if (simTimer.current !== null) clearInterval(simTimer.current); - simTimer.current = null; - setProgressObs(null); - } - - function playSim() { - if (simTimer.current !== null) { - stopSim(); - return; - } - let fraction = 0; - simTimer.current = setInterval(() => { - fraction = Math.min(1, fraction + 0.01); - setProgressObs({ - v: 1, - timestampMs: Date.now(), - state: fraction >= 1 ? 'complete' : 'printing', - position: { percent: fraction, percentBasis: 'bytes' } - }); - if (fraction >= 1) stopSim(); - }, 100); - } - - return ( -
- -
- 0 ? [0, lastLayer] : null} - scrub={scrub} - progress={progressObs} - quality="auto" - onReady={(s) => { - setLayerCount(s.layers); - setSegmentCount(s.segments); - setLastLayer(Math.max(0, s.layers - 1)); - setScrub(null); - append(`ready: ${s.segments.toLocaleString()} segments, ${s.layers} layers`); - }} - onParseError={(e) => append(`parse-error: ${e.code} ${e.message}`)} - onBuildComplete={(e) => append(`build-complete: ${e.quality}`)} - onQualityFallback={(e) => append(`quality-fallback: ${e.from} -> ${e.to}`)} - onProgressPresentationChanged={(e) => - append(`progress shown as: ${e.mode}${e.reason ? ` (${e.reason})` : ''}`) - } - onDisclosure={(t) => t && append(`disclosure: ${t}`)} - onError={(e) => append(`error: ${e.code}`)} - /> -
-
- ); -} - -const root = createRoot(document.getElementById('app')); -root.render( - - - -); -window.reactExample = { root }; diff --git a/tools/example-react/src/minimal.jsx b/tools/example-react/src/minimal.jsx new file mode 100644 index 00000000..ad7744d3 --- /dev/null +++ b/tools/example-react/src/minimal.jsx @@ -0,0 +1,70 @@ +/* + * Minimal getting-started example (DD-031 §4.7, tier 1). + * + * The smallest real integration of @chestnutlabs/gcode-preview-react: give a + * `source` (bytes/File/ArrayBuffer) and it parses + renders. Everything else here — the fixture + * picker and the layer slider — is ordinary React state driving declarative props. No design + * system, no demo-kit, no raw renderer. Copy this file to start. + */ +import { StrictMode, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { GcodePreview } from '@chestnutlabs/gcode-preview-react'; + +const FIXTURES = ['3DBenchy.gcode', 'calicat.gcode', 'vase.gcode', 'mach3.gcode']; + +function App() { + const [source, setSource] = useState(null); + const [layers, setLayers] = useState(0); + const [topLayer, setTopLayer] = useState(0); + + async function load(name) { + const res = await fetch(`./gcodes/${name}`); + setSource(new Uint8Array(await res.arrayBuffer())); // new source prop → re-parse + } + + return ( + <> +
+ gcode-preview-react · minimal + + {layers > 0 && ( + + )} +
+
+ 0 ? [0, topLayer] : null} + onReady={(s) => { + setLayers(s.layers); + setTopLayer(Math.max(0, s.layers - 1)); + }} + /> +
+ + ); +} + +createRoot(document.getElementById('app')).render( + + + +); diff --git a/tools/example-react/src/showcase.jsx b/tools/example-react/src/showcase.jsx new file mode 100644 index 00000000..51eed8bd --- /dev/null +++ b/tools/example-react/src/showcase.jsx @@ -0,0 +1,321 @@ +/* + * Full showcase example (DD-031 §4.7, tier 2). + * + * Exercises the real declarative surface of @chestnutlabs/gcode-preview-react and mirrors the + * Feature Lab's capability-aware UX in React idiom: color modes gate on the file's capabilities + * with a plain-language reason, feature-role hiding is a declarative prop, and diagnostics/picking + * come through the ref handle's `controls`. Chrome (light/dark) is separate from the renderer's + * neutral viewport. Styling is the shared workspace-internal demo-kit — not a published UI kit and + * not a library default. Still the real package: no raw renderer or parser imports. + */ +import { StrictMode, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { GcodePreview } from '@chestnutlabs/gcode-preview-react'; +import { FeatureRole } from '@chestnutlabs/toolpath-core'; +import { + FIXTURE_GROUPS, + FIXTURE_BY_ID, + COLOR_MODES, + COLOR_MODE_BY_ID, + colorModeReason, + confidenceTier, + rgbCss, + count as formatCount, + duration as formatDuration +} from '../../demo-kit/index.js'; +import '../../demo-kit/tokens.css'; + +const ADHESION_ROLES = [FeatureRole.Skirt, FeatureRole.Brim]; +const CAM_VIEWS = [ + ['iso', 'Iso'], + ['top', 'Top'], + ['front', 'Front'] +]; + +function App() { + const ref = useRef(null); + const [chrome, setChrome] = useState('dark'); + const [fixtureId, setFixtureId] = useState('skirt-brim'); + const [source, setSource] = useState(null); + + // Reactive UI state, mirrored from the component's event callbacks (the controlled-component idiom). + const [caps, setCaps] = useState({}); + const [layers, setLayers] = useState(0); + const [segments, setSegments] = useState(0); + const [topLayer, setTopLayer] = useState(0); + const [disclosure, setDisclosure] = useState(''); + const [hasRetractions, setHasRetractions] = useState(false); + const [time, setTime] = useState({ ms: null, src: null }); + const [stats, setStats] = useState(null); + const [picked, setPicked] = useState(null); + + // Declarative control props. + const [colorModeId, setColorModeId] = useState('feature'); + const [hideAdhesion, setHideAdhesion] = useState(false); + const [showTravel, setShowTravel] = useState(false); + const [showRetractions, setShowRetractions] = useState(false); + const [view, setView] = useState('iso'); + + document.documentElement.dataset.chrome = chrome; + + const mode = COLOR_MODE_BY_ID[colorModeId]; + const modeReason = colorModeReason(colorModeId, caps); + const colorMode = modeReason === '' ? mode.build() : undefined; // don't apply an unavailable mode + const legend = mode.legend ?? (colorModeId === 'feature' ? COLOR_MODE_BY_ID.feature.legend : null); + const featureRolesKnown = caps.featureRoles === 'known' || caps.featureRoles === 'inferred'; + + async function load(id) { + const fx = FIXTURE_BY_ID[id]; + if (!fx) return; + setFixtureId(id); + const res = await fetch(`./${fx.path}`); + setSource(new Uint8Array(await res.arrayBuffer())); + setStats(null); + setPicked(null); + } + + function onReady(s) { + setCaps(s.capabilities); + setLayers(s.layers); + setSegments(s.segments); + setTopLayer(Math.max(0, s.layers - 1)); + // Pull the reactive fields the ready summary doesn't carry from the live state snapshot. + const st = ref.current?.state; + if (st) { + setHasRetractions(st.hasRetractions); + setTime({ ms: st.totalTimeMs, src: st.timeEstimateSource }); + // If the chosen mode isn't available for this file, fall back to a mode that always is. + if (!st.availableColorModes.includes(colorModeId) && colorModeReason(colorModeId, s.capabilities) !== '') { + setColorModeId('single'); + } + } + } + + function refreshStats() { + setStats(ref.current?.controls.getRenderStats() ?? null); + } + + function onCanvasClick(e) { + const c = e.currentTarget.querySelector('canvas'); + if (!c || layers === 0) return; + const r = c.getBoundingClientRect(); + const ndcX = ((e.clientX - r.left) / r.width) * 2 - 1; + const ndcY = -(((e.clientY - r.top) / r.height) * 2 - 1); + // pickSegment returns the IR segment index, or null if the ray missed. + setPicked(ref.current?.controls.pickSegment(ndcX, ndcY) ?? null); + } + + const fx = FIXTURE_BY_ID[fixtureId]; + + return ( + <> +
+
+ G-code Preview · React + Showcase +
+ + +
+
+ {['dark', 'light'].map((c) => ( + + ))} +
+
+ +
+
+ 0 ? [0, topLayer] : null} + hiddenFeatureRoles={hideAdhesion ? ADHESION_ROLES : undefined} + showTravel={showTravel} + showRetractions={showRetractions} + view={view} + frameContent="object" + quality="auto" + onReady={onReady} + onBuildComplete={refreshStats} + onDisclosure={(t) => setDisclosure(t ?? '')} + onParseError={(e) => setDisclosure(`Parse error: ${e.code} — ${e.message}`)} + /> + {legend && ( +
+ {mode.label} +
+ {legend.map(([label, rgb]) => ( + + + {label} + + ))} +
+
+ )} +
+ {CAM_VIEWS.map(([v, label]) => ( + + ))} +
+ {segments > 0 && ( +
+ {formatCount(segments)} segments · {layers} layers + {time.ms !== null && ` · ${formatDuration(time.ms)}`} +
+ )} +
+ + +
+ +
+
+ {['featureRoles', 'objects', 'feedrate', 'colorChanges'].map((k) => { + const tier = confidenceTier(caps[k]); + return ( + + {k} · {tier.label} + + ); + })} +
+
+ {disclosure || (fx ? fx.blurb : 'Pick a fixture to begin.')} +
+ + ); +} + +createRoot(document.getElementById('app')).render( + + + +); diff --git a/tools/example-react/vite.config.js b/tools/example-react/vite.config.js index 61192caa..da7e2dfd 100644 --- a/tools/example-react/vite.config.js +++ b/tools/example-react/vite.config.js @@ -1,14 +1,30 @@ import { defineConfig } from 'vite'; +import { resolve } from 'node:path'; +// Two-tier example (DD-031 §4.7): `minimal.html` is the smallest real integration; `showcase.html` +// exercises the full declarative capability surface and shares the workspace-internal demo-kit +// (../demo-kit) with the Feature Lab so the examples read as one project. Both import the REAL +// published @chestnutlabs/gcode-preview-react — never a raw renderer/session. export default defineConfig({ base: './', // Automatic JSX runtime without @vitejs/plugin-react (no HMR fast-refresh needed here). esbuild: { jsx: 'automatic' }, - // Serve the MIT demo corpus (inherited upstream, tracked in test-data/manifest.json). - publicDir: '../../test-data/gcodes', + // Serve the whole MIT demo corpus so demo-kit fixture paths (gcodes/…, fixtures/…) resolve. + publicDir: '../../test-data', server: { port: 5201, - strictPort: true + strictPort: true, + // Allow the shared demo-kit, which lives outside this app's root (tools/demo-kit). + fs: { allow: ['../..'] } + }, + build: { + rollupOptions: { + input: { + index: resolve(import.meta.dirname, 'index.html'), + minimal: resolve(import.meta.dirname, 'minimal.html'), + showcase: resolve(import.meta.dirname, 'showcase.html') + } + } }, resolve: { dedupe: ['three', 'react', 'react-dom'] From cc588e7bb4801462576a19243388b682c2f4fc9e Mon Sep 17 00:00:00 2001 From: Nathaniel Chestnut Date: Sat, 29 Aug 2026 07:16:27 -0700 Subject: [PATCH 06/22] feat(example-vue): new two-tier Vue example on the published adapter (DD-031 P4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greenfield Vue example (there was none), mirroring the React reference and driving the real @chestnutlabs/gcode-preview-vue: - Minimal.vue — smallest real integration: + fixture picker + layer slider, ordinary reactive state driving declarative bindings. - Showcase.vue — full declarative surface in Vue idiom: capability-gated color modes with reasons, declarative :hidden-feature-roles, diagnostics/picking via the component ref's handle (viewer.preview.controls). Shared demo-kit styling. - index landing, multi-page vite (plugin-vue), publicDir=test-data, fs.allow demo-kit. - launch.json: example-vue (5203) + example-webcomponent (5204) placeholders. Verified headless: minimal loads calicat (174 layers); showcase parses skirt-brim (454 seg / 14 layers / 3m 04s), badges tier correctly, 3 modes gate with reasons, getRenderStats reports 3d-webgl/tubes/GPU — zero console errors. Co-Authored-By: Claude Opus 4.8 --- .claude/launch.json | 22 + tools/example-vue/.gitignore | 2 + tools/example-vue/index.html | 87 ++ tools/example-vue/minimal.html | 43 + tools/example-vue/package-lock.json | 1520 +++++++++++++++++++++++++++ tools/example-vue/package.json | 26 + tools/example-vue/showcase.html | 123 +++ tools/example-vue/src/Minimal.vue | 47 + tools/example-vue/src/Showcase.vue | 256 +++++ tools/example-vue/src/minimal.js | 4 + tools/example-vue/src/showcase.js | 5 + tools/example-vue/vite.config.js | 42 + 12 files changed, 2177 insertions(+) create mode 100644 tools/example-vue/.gitignore create mode 100644 tools/example-vue/index.html create mode 100644 tools/example-vue/minimal.html create mode 100644 tools/example-vue/package-lock.json create mode 100644 tools/example-vue/package.json create mode 100644 tools/example-vue/showcase.html create mode 100644 tools/example-vue/src/Minimal.vue create mode 100644 tools/example-vue/src/Showcase.vue create mode 100644 tools/example-vue/src/minimal.js create mode 100644 tools/example-vue/src/showcase.js create mode 100644 tools/example-vue/vite.config.js diff --git a/.claude/launch.json b/.claude/launch.json index 34e84800..559997bb 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -33,6 +33,28 @@ "tools/example-svelte" ], "port": 5202 + }, + { + "name": "example-vue", + "runtimeExecutable": "npm", + "runtimeArgs": [ + "run", + "dev", + "--prefix", + "tools/example-vue" + ], + "port": 5203 + }, + { + "name": "example-webcomponent", + "runtimeExecutable": "npm", + "runtimeArgs": [ + "run", + "dev", + "--prefix", + "tools/example-webcomponent" + ], + "port": 5204 } ] } diff --git a/tools/example-vue/.gitignore b/tools/example-vue/.gitignore new file mode 100644 index 00000000..f06235c4 --- /dev/null +++ b/tools/example-vue/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/tools/example-vue/index.html b/tools/example-vue/index.html new file mode 100644 index 00000000..1ae893fa --- /dev/null +++ b/tools/example-vue/index.html @@ -0,0 +1,87 @@ + + + + + + @chestnutlabs/gcode-preview-vue — examples + + + +
+

@chestnutlabs/gcode-preview-vue

+

+ Two example apps built on the published Vue adapter. Both drive the real package — no raw + renderer or parser imports. +

+ +
+ + diff --git a/tools/example-vue/minimal.html b/tools/example-vue/minimal.html new file mode 100644 index 00000000..929bfd08 --- /dev/null +++ b/tools/example-vue/minimal.html @@ -0,0 +1,43 @@ + + + + + + gcode-preview-vue — minimal + + + +
+ + + diff --git a/tools/example-vue/package-lock.json b/tools/example-vue/package-lock.json new file mode 100644 index 00000000..645ff1bf --- /dev/null +++ b/tools/example-vue/package-lock.json @@ -0,0 +1,1520 @@ +{ + "name": "gcode-preview-example-vue", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gcode-preview-example-vue", + "version": "0.0.0", + "dependencies": { + "@chestnutlabs/gcode-containers": "file:../../packages/gcode-containers", + "@chestnutlabs/gcode-dialects": "file:../../packages/gcode-dialects", + "@chestnutlabs/gcode-parser": "file:../../packages/gcode-parser", + "@chestnutlabs/gcode-preview-core": "file:../../packages/gcode-preview-core", + "@chestnutlabs/gcode-preview-vue": "file:../../packages/gcode-preview-vue", + "@chestnutlabs/gcode-renderer-three": "file:../../packages/gcode-renderer-three", + "@chestnutlabs/toolpath-core": "file:../../packages/toolpath-core", + "three": "0.178.0", + "vue": "^3.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^6.0.0" + } + }, + "../../packages/gcode-containers": { + "name": "@chestnutlabs/gcode-containers", + "version": "0.19.0", + "license": "MIT", + "dependencies": { + "@chestnutlabs/toolpath-core": "0.19.0" + }, + "engines": { + "node": ">=22" + } + }, + "../../packages/gcode-dialects": { + "name": "@chestnutlabs/gcode-dialects", + "version": "0.19.0", + "license": "MIT", + "dependencies": { + "@chestnutlabs/toolpath-core": "0.19.0" + }, + "engines": { + "node": ">=22" + } + }, + "../../packages/gcode-parser": { + "name": "@chestnutlabs/gcode-parser", + "version": "0.19.0", + "license": "MIT", + "dependencies": { + "@chestnutlabs/gcode-bgcode": "0.19.0", + "@chestnutlabs/gcode-containers": "0.19.0", + "@chestnutlabs/gcode-dialects": "0.19.0", + "@chestnutlabs/toolpath-core": "0.19.0" + }, + "engines": { + "node": ">=22" + } + }, + "../../packages/gcode-preview-core": { + "name": "@chestnutlabs/gcode-preview-core", + "version": "0.19.0", + "license": "MIT", + "dependencies": { + "@chestnutlabs/gcode-parser": "0.19.0", + "@chestnutlabs/gcode-renderer-2d": "0.19.0", + "@chestnutlabs/gcode-renderer-three": "0.19.0", + "@chestnutlabs/toolpath-core": "0.19.0" + }, + "engines": { + "node": ">=22" + } + }, + "../../packages/gcode-preview-vue": { + "name": "@chestnutlabs/gcode-preview-vue", + "version": "0.19.0", + "license": "MIT", + "dependencies": { + "@chestnutlabs/gcode-parser": "0.19.0", + "@chestnutlabs/gcode-preview-core": "0.19.0", + "@chestnutlabs/gcode-renderer-three": "0.19.0", + "@chestnutlabs/toolpath-core": "0.19.0" + }, + "devDependencies": { + "vue": "^3.4.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "vue": "^3.4.0" + } + }, + "../../packages/gcode-renderer-three": { + "name": "@chestnutlabs/gcode-renderer-three", + "version": "0.19.0", + "license": "MIT", + "dependencies": { + "@chestnutlabs/gcode-colors": "0.19.0", + "@chestnutlabs/toolpath-core": "0.19.0" + }, + "devDependencies": { + "@types/three": "0.178.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "three": "^0.178.0" + } + }, + "../../packages/toolpath-core": { + "name": "@chestnutlabs/toolpath-core", + "version": "0.19.0", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@chestnutlabs/gcode-containers": { + "resolved": "../../packages/gcode-containers", + "link": true + }, + "node_modules/@chestnutlabs/gcode-dialects": { + "resolved": "../../packages/gcode-dialects", + "link": true + }, + "node_modules/@chestnutlabs/gcode-parser": { + "resolved": "../../packages/gcode-parser", + "link": true + }, + "node_modules/@chestnutlabs/gcode-preview-core": { + "resolved": "../../packages/gcode-preview-core", + "link": true + }, + "node_modules/@chestnutlabs/gcode-preview-vue": { + "resolved": "../../packages/gcode-preview-vue", + "link": true + }, + "node_modules/@chestnutlabs/gcode-renderer-three": { + "resolved": "../../packages/gcode-renderer-three", + "link": true + }, + "node_modules/@chestnutlabs/toolpath-core": { + "resolved": "../../packages/toolpath-core", + "link": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.42.tgz", + "integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.42", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz", + "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz", + "integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.42", + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-ssr": "3.5.42", + "@vue/shared": "3.5.42", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz", + "integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.42.tgz", + "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.42.tgz", + "integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz", + "integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/runtime-core": "3.5.42", + "@vue/shared": "3.5.42", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.42.tgz", + "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.42.tgz", + "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.178.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.178.0.tgz", + "integrity": "sha512-ybFIB0+x8mz0wnZgSGy2MO/WCO6xZhQSZnmfytSPyNpM0sBafGRVhdaj+erYh5U+RhQOAg/eXqw5uVDiM2BjhQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz", + "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-sfc": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/server-renderer": "3.5.42", + "@vue/shared": "3.5.42" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/tools/example-vue/package.json b/tools/example-vue/package.json new file mode 100644 index 00000000..9c937fb7 --- /dev/null +++ b/tools/example-vue/package.json @@ -0,0 +1,26 @@ +{ + "name": "gcode-preview-example-vue", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Two-tier Vue example (DD-031 §4.7): minimal.html (smallest real integration via ) + showcase.html (full declarative surface + shared demo-kit). Consumes the published @chestnutlabs/gcode-preview-vue.", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "@chestnutlabs/gcode-containers": "file:../../packages/gcode-containers", + "@chestnutlabs/gcode-dialects": "file:../../packages/gcode-dialects", + "@chestnutlabs/gcode-parser": "file:../../packages/gcode-parser", + "@chestnutlabs/gcode-preview-core": "file:../../packages/gcode-preview-core", + "@chestnutlabs/gcode-preview-vue": "file:../../packages/gcode-preview-vue", + "@chestnutlabs/gcode-renderer-three": "file:../../packages/gcode-renderer-three", + "@chestnutlabs/toolpath-core": "file:../../packages/toolpath-core", + "three": "0.178.0", + "vue": "^3.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^6.0.0" + } +} diff --git a/tools/example-vue/showcase.html b/tools/example-vue/showcase.html new file mode 100644 index 00000000..483f2b99 --- /dev/null +++ b/tools/example-vue/showcase.html @@ -0,0 +1,123 @@ + + + + + + gcode-preview-vue — showcase + + + +
+ + + diff --git a/tools/example-vue/src/Minimal.vue b/tools/example-vue/src/Minimal.vue new file mode 100644 index 00000000..69ab829b --- /dev/null +++ b/tools/example-vue/src/Minimal.vue @@ -0,0 +1,47 @@ + + + + diff --git a/tools/example-vue/src/Showcase.vue b/tools/example-vue/src/Showcase.vue new file mode 100644 index 00000000..b5c32ddf --- /dev/null +++ b/tools/example-vue/src/Showcase.vue @@ -0,0 +1,256 @@ + + + + diff --git a/tools/example-vue/src/minimal.js b/tools/example-vue/src/minimal.js new file mode 100644 index 00000000..11efc66e --- /dev/null +++ b/tools/example-vue/src/minimal.js @@ -0,0 +1,4 @@ +import { createApp } from 'vue'; +import Minimal from './Minimal.vue'; + +createApp(Minimal).mount('#app'); diff --git a/tools/example-vue/src/showcase.js b/tools/example-vue/src/showcase.js new file mode 100644 index 00000000..83254868 --- /dev/null +++ b/tools/example-vue/src/showcase.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue'; +import Showcase from './Showcase.vue'; +import '../../demo-kit/tokens.css'; + +createApp(Showcase).mount('#app'); diff --git a/tools/example-vue/vite.config.js b/tools/example-vue/vite.config.js new file mode 100644 index 00000000..8ac440d9 --- /dev/null +++ b/tools/example-vue/vite.config.js @@ -0,0 +1,42 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import { resolve } from 'node:path'; + +// Two-tier example (DD-031 §4.7): `minimal.html` is the smallest real integration; `showcase.html` +// exercises the full declarative surface and shares the workspace-internal demo-kit (../demo-kit) +// with the Feature Lab. Both import the REAL published @chestnutlabs/gcode-preview-vue. +export default defineConfig({ + base: './', + plugins: [vue()], + // Serve the whole MIT demo corpus so demo-kit fixture paths (gcodes/…, fixtures/…) resolve. + publicDir: '../../test-data', + server: { + port: 5203, + strictPort: true, + // Allow the shared demo-kit, which lives outside this app's root (tools/demo-kit). + fs: { allow: ['../..'] } + }, + build: { + rollupOptions: { + input: { + index: resolve(import.meta.dirname, 'index.html'), + minimal: resolve(import.meta.dirname, 'minimal.html'), + showcase: resolve(import.meta.dirname, 'showcase.html') + } + } + }, + resolve: { + dedupe: ['three', 'vue'] + }, + optimizeDeps: { + exclude: [ + '@chestnutlabs/gcode-parser', + '@chestnutlabs/toolpath-core', + '@chestnutlabs/gcode-renderer-three', + '@chestnutlabs/gcode-dialects', + '@chestnutlabs/gcode-containers', + '@chestnutlabs/gcode-preview-core', + '@chestnutlabs/gcode-preview-vue' + ] + } +}); From 92f9fcdd596c57e520b3e4b926b347bd929cc8d9 Mon Sep 17 00:00:00 2001 From: Nathaniel Chestnut Date: Sat, 29 Aug 2026 07:20:54 -0700 Subject: [PATCH 07/22] feat(example-svelte): two-tier example on the published adapter (DD-031 P4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the Svelte example into the DD-031 §4.7 two tiers, both driving the real @chestnutlabs/gcode-preview-svelte via the deliberate .svelte subpath: - Minimal.svelte — smallest real integration: + fixture picker + layer slider, ordinary reactive state driving declarative props. - Showcase.svelte — full declarative surface in Svelte idiom: capability-gated color modes with reasons, declarative hiddenFeatureRoles, diagnostics/picking via the bound handle (viewer.preview.controls); the handle's store-contract `state` read for the ready-omitted fields. Shared demo-kit styling. - index landing, multi-page vite (plugin-svelte), publicDir=test-data, fs.allow. - Replaces the single App.svelte/main.js. Verified headless: minimal loads 3DBenchy (240 layers) and vase (1, spiral); showcase parses skirt-brim (454 seg / 14 layers / 3m 04s), badges tier correctly, 3 modes gate with reasons, getRenderStats reports 3d-webgl/tubes/GPU — zero errors. Co-Authored-By: Claude Opus 4.8 --- tools/example-svelte/index.html | 89 ++++++-- tools/example-svelte/minimal.html | 43 ++++ tools/example-svelte/package.json | 2 +- tools/example-svelte/showcase.html | 123 +++++++++++ tools/example-svelte/src/App.svelte | 104 --------- tools/example-svelte/src/Minimal.svelte | 49 +++++ tools/example-svelte/src/Showcase.svelte | 265 +++++++++++++++++++++++ tools/example-svelte/src/main.js | 4 - tools/example-svelte/src/minimal.js | 3 + tools/example-svelte/src/showcase.js | 4 + tools/example-svelte/vite.config.js | 21 +- 11 files changed, 571 insertions(+), 136 deletions(-) create mode 100644 tools/example-svelte/minimal.html create mode 100644 tools/example-svelte/showcase.html delete mode 100644 tools/example-svelte/src/App.svelte create mode 100644 tools/example-svelte/src/Minimal.svelte create mode 100644 tools/example-svelte/src/Showcase.svelte delete mode 100644 tools/example-svelte/src/main.js create mode 100644 tools/example-svelte/src/minimal.js create mode 100644 tools/example-svelte/src/showcase.js diff --git a/tools/example-svelte/index.html b/tools/example-svelte/index.html index da001a02..dc4dcd9a 100644 --- a/tools/example-svelte/index.html +++ b/tools/example-svelte/index.html @@ -3,44 +3,85 @@ - GcodePreview Svelte — example app (DD-007 phase 7) + @chestnutlabs/gcode-preview-svelte — examples -
- +
+

@chestnutlabs/gcode-preview-svelte

+

+ Two example apps built on the published Svelte adapter. Both drive the real package — no raw + renderer or parser imports. +

+ +
diff --git a/tools/example-svelte/minimal.html b/tools/example-svelte/minimal.html new file mode 100644 index 00000000..5be0d745 --- /dev/null +++ b/tools/example-svelte/minimal.html @@ -0,0 +1,43 @@ + + + + + + gcode-preview-svelte — minimal + + + +
+ + + diff --git a/tools/example-svelte/package.json b/tools/example-svelte/package.json index 4eecf2cd..dcfb17db 100644 --- a/tools/example-svelte/package.json +++ b/tools/example-svelte/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.0", "type": "module", - "description": "DD-007 phase 7 example application (issue #114): the packaged Svelte component drives the full pipeline — the separate example app required by the D1 amendment.", + "description": "Two-tier Svelte example (DD-031 §4.7): minimal.html (smallest real integration via ) + showcase.html (full declarative surface + shared demo-kit). Consumes the published @chestnutlabs/gcode-preview-svelte.", "scripts": { "dev": "vite", "build": "vite build" diff --git a/tools/example-svelte/showcase.html b/tools/example-svelte/showcase.html new file mode 100644 index 00000000..3deb583b --- /dev/null +++ b/tools/example-svelte/showcase.html @@ -0,0 +1,123 @@ + + + + + + gcode-preview-svelte — showcase + + + +
+ + + diff --git a/tools/example-svelte/src/App.svelte b/tools/example-svelte/src/App.svelte deleted file mode 100644 index 9b52937f..00000000 --- a/tools/example-svelte/src/App.svelte +++ /dev/null @@ -1,104 +0,0 @@ - - - -
- 0 ? [0, lastLayer] : null} - {scrub} - progress={progressObs} - quality="auto" - on:ready={onReady} - on:parseerror={(e) => append(`parse-error: ${e.detail.code} ${e.detail.message}`)} - on:buildcomplete={(e) => append(`build-complete: ${e.detail.quality}`)} - on:qualityfallback={(e) => append(`quality-fallback: ${e.detail.from} -> ${e.detail.to}`)} - on:progresspresentationchanged={(e) => append(`progress shown as: ${e.detail.mode}${e.detail.reason ? ` (${e.detail.reason})` : ''}`)} - on:disclosure={(e) => e.detail && append(`disclosure: ${e.detail}`)} - on:error={(e) => append(`error: ${e.detail.code}`)} - /> -
diff --git a/tools/example-svelte/src/Minimal.svelte b/tools/example-svelte/src/Minimal.svelte new file mode 100644 index 00000000..d23d49e1 --- /dev/null +++ b/tools/example-svelte/src/Minimal.svelte @@ -0,0 +1,49 @@ + + + +
+ gcode-preview-svelte · minimal + + {#if layers > 0} + + {/if} +
+
+ 0 ? [0, topLayer] : null} on:ready={onReady} /> +
diff --git a/tools/example-svelte/src/Showcase.svelte b/tools/example-svelte/src/Showcase.svelte new file mode 100644 index 00000000..6abcbead --- /dev/null +++ b/tools/example-svelte/src/Showcase.svelte @@ -0,0 +1,265 @@ + + + +
+
+ G-code Preview · Svelte + Showcase +
+ + +
+
+ + +
+
+ +
+ +
+ 0 ? [0, topLayer] : null} + hiddenFeatureRoles={hiddenRoles} + {showTravel} + {showRetractions} + {view} + frameContent="object" + quality="auto" + on:ready={onReady} + on:buildcomplete={refreshStats} + on:disclosure={(e) => (disclosure = e.detail ?? '')} + on:parseerror={(e) => (disclosure = `Parse error: ${e.detail.code} — ${e.detail.message}`)} + /> + {#if legend} +
+ {mode.label} +
+ {#each legend as [label, rgb]} + + + {label} + + {/each} +
+
+ {/if} +
+ {#each CAM_VIEWS as [v, label]} + + {/each} +
+ {#if segments > 0} +
{countLabel}
+ {/if} +
+ + +
+ +
+
+ {#each CAP_KEYS as k} + + {k} · {confidenceTier(caps[k]).label} + + {/each} +
+
+ {disclosure || (fixture ? fixture.blurb : 'Pick a fixture to begin.')} +
diff --git a/tools/example-svelte/src/main.js b/tools/example-svelte/src/main.js deleted file mode 100644 index ac77a891..00000000 --- a/tools/example-svelte/src/main.js +++ /dev/null @@ -1,4 +0,0 @@ -import App from './App.svelte'; - -const app = new App({ target: document.getElementById('app') }); -window.svelteExample = { app }; diff --git a/tools/example-svelte/src/minimal.js b/tools/example-svelte/src/minimal.js new file mode 100644 index 00000000..0af9f82b --- /dev/null +++ b/tools/example-svelte/src/minimal.js @@ -0,0 +1,3 @@ +import Minimal from './Minimal.svelte'; + +new Minimal({ target: document.getElementById('app') }); diff --git a/tools/example-svelte/src/showcase.js b/tools/example-svelte/src/showcase.js new file mode 100644 index 00000000..c00a49e6 --- /dev/null +++ b/tools/example-svelte/src/showcase.js @@ -0,0 +1,4 @@ +import Showcase from './Showcase.svelte'; +import '../../demo-kit/tokens.css'; + +new Showcase({ target: document.getElementById('app') }); diff --git a/tools/example-svelte/vite.config.js b/tools/example-svelte/vite.config.js index 18e78666..bd2b163d 100644 --- a/tools/example-svelte/vite.config.js +++ b/tools/example-svelte/vite.config.js @@ -1,14 +1,29 @@ import { defineConfig } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { resolve } from 'node:path'; +// Two-tier example (DD-031 §4.7): `minimal.html` is the smallest real integration; `showcase.html` +// exercises the full declarative surface and shares the workspace-internal demo-kit (../demo-kit) +// with the Feature Lab. Both import the REAL published @chestnutlabs/gcode-preview-svelte. export default defineConfig({ base: './', plugins: [svelte()], - // Serve the MIT demo corpus (inherited upstream, tracked in test-data/manifest.json). - publicDir: '../../test-data/gcodes', + // Serve the whole MIT demo corpus so demo-kit fixture paths (gcodes/…, fixtures/…) resolve. + publicDir: '../../test-data', server: { port: 5202, - strictPort: true + strictPort: true, + // Allow the shared demo-kit, which lives outside this app's root (tools/demo-kit). + fs: { allow: ['../..'] } + }, + build: { + rollupOptions: { + input: { + index: resolve(import.meta.dirname, 'index.html'), + minimal: resolve(import.meta.dirname, 'minimal.html'), + showcase: resolve(import.meta.dirname, 'showcase.html') + } + } }, resolve: { dedupe: ['three', 'svelte'] From 76f1d9edcb9b1d03c8a820164c005c4b80f30554 Mon Sep 17 00:00:00 2001 From: Nathaniel Chestnut Date: Sat, 29 Aug 2026 07:27:42 -0700 Subject: [PATCH 08/22] feat(example-webcomponent): new two-tier Web Component example (DD-031 P4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greenfield vanilla-JS example (there was none), driving the published element from @chestnutlabs/gcode-preview-element: - minimal.js — smallest real integration: import /define to register, set the `source` property, listen for `ready`; the layer slider drives the `layer-range` attribute. Teaches the attribute/property split in ~40 lines. - showcase.js — full surface in vanilla JS mirroring the Feature Lab: objects (source, colorMode, hiddenFeatureRoles) set as properties; primitives (layer-range, view) as attributes; capability-gated color modes with reasons; diagnostics/picking via element.controls; disclosure read from the WC's wrapped { text } detail. Shared demo-kit styling. - index landing, multi-page vite, publicDir=test-data, fs.allow demo-kit. Verified headless: minimal loads 3DBenchy (240 layers, layer-range attr path) and mach3; showcase parses skirt-brim (454 seg / 14 layers / 3m 04s), badges tier, 3 modes gate, getRenderStats reports 3d-webgl/tubes/GPU, hiddenFeatureRoles=[6,7], colorMode/layer-range/showTravel mutations all apply — zero console errors. Co-Authored-By: Claude Opus 4.8 --- tools/example-webcomponent/.gitignore | 2 + tools/example-webcomponent/index.html | 87 ++ tools/example-webcomponent/minimal.html | 57 + tools/example-webcomponent/package-lock.json | 1298 ++++++++++++++++++ tools/example-webcomponent/package.json | 24 + tools/example-webcomponent/showcase.html | 197 +++ tools/example-webcomponent/src/minimal.js | 40 + tools/example-webcomponent/src/showcase.js | 206 +++ tools/example-webcomponent/vite.config.js | 41 + 9 files changed, 1952 insertions(+) create mode 100644 tools/example-webcomponent/.gitignore create mode 100644 tools/example-webcomponent/index.html create mode 100644 tools/example-webcomponent/minimal.html create mode 100644 tools/example-webcomponent/package-lock.json create mode 100644 tools/example-webcomponent/package.json create mode 100644 tools/example-webcomponent/showcase.html create mode 100644 tools/example-webcomponent/src/minimal.js create mode 100644 tools/example-webcomponent/src/showcase.js create mode 100644 tools/example-webcomponent/vite.config.js diff --git a/tools/example-webcomponent/.gitignore b/tools/example-webcomponent/.gitignore new file mode 100644 index 00000000..f06235c4 --- /dev/null +++ b/tools/example-webcomponent/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/tools/example-webcomponent/index.html b/tools/example-webcomponent/index.html new file mode 100644 index 00000000..f0ff0739 --- /dev/null +++ b/tools/example-webcomponent/index.html @@ -0,0 +1,87 @@ + + + + + + @chestnutlabs/gcode-preview-element — examples + + + +
+

@chestnutlabs/gcode-preview-element

+

+ Two example apps built on the published <gcode-preview> Web Component — + framework-free, vanilla JS. Both drive the real package: no raw renderer or parser imports. +

+ +
+ + diff --git a/tools/example-webcomponent/minimal.html b/tools/example-webcomponent/minimal.html new file mode 100644 index 00000000..8a639191 --- /dev/null +++ b/tools/example-webcomponent/minimal.html @@ -0,0 +1,57 @@ + + + + + + gcode-preview element — minimal + + + +
+ gcode-preview element · minimal + + +
+ + + + + diff --git a/tools/example-webcomponent/package-lock.json b/tools/example-webcomponent/package-lock.json new file mode 100644 index 00000000..6b53d1f5 --- /dev/null +++ b/tools/example-webcomponent/package-lock.json @@ -0,0 +1,1298 @@ +{ + "name": "gcode-preview-example-webcomponent", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gcode-preview-example-webcomponent", + "version": "0.0.0", + "dependencies": { + "@chestnutlabs/gcode-containers": "file:../../packages/gcode-containers", + "@chestnutlabs/gcode-dialects": "file:../../packages/gcode-dialects", + "@chestnutlabs/gcode-parser": "file:../../packages/gcode-parser", + "@chestnutlabs/gcode-preview-core": "file:../../packages/gcode-preview-core", + "@chestnutlabs/gcode-preview-element": "file:../../packages/gcode-preview-element", + "@chestnutlabs/gcode-renderer-three": "file:../../packages/gcode-renderer-three", + "@chestnutlabs/toolpath-core": "file:../../packages/toolpath-core", + "three": "0.178.0" + }, + "devDependencies": { + "vite": "^6.0.0" + } + }, + "../../packages/gcode-containers": { + "name": "@chestnutlabs/gcode-containers", + "version": "0.19.0", + "license": "MIT", + "dependencies": { + "@chestnutlabs/toolpath-core": "0.19.0" + }, + "engines": { + "node": ">=22" + } + }, + "../../packages/gcode-dialects": { + "name": "@chestnutlabs/gcode-dialects", + "version": "0.19.0", + "license": "MIT", + "dependencies": { + "@chestnutlabs/toolpath-core": "0.19.0" + }, + "engines": { + "node": ">=22" + } + }, + "../../packages/gcode-parser": { + "name": "@chestnutlabs/gcode-parser", + "version": "0.19.0", + "license": "MIT", + "dependencies": { + "@chestnutlabs/gcode-bgcode": "0.19.0", + "@chestnutlabs/gcode-containers": "0.19.0", + "@chestnutlabs/gcode-dialects": "0.19.0", + "@chestnutlabs/toolpath-core": "0.19.0" + }, + "engines": { + "node": ">=22" + } + }, + "../../packages/gcode-preview-core": { + "name": "@chestnutlabs/gcode-preview-core", + "version": "0.19.0", + "license": "MIT", + "dependencies": { + "@chestnutlabs/gcode-parser": "0.19.0", + "@chestnutlabs/gcode-renderer-2d": "0.19.0", + "@chestnutlabs/gcode-renderer-three": "0.19.0", + "@chestnutlabs/toolpath-core": "0.19.0" + }, + "engines": { + "node": ">=22" + } + }, + "../../packages/gcode-preview-element": { + "name": "@chestnutlabs/gcode-preview-element", + "version": "0.19.0", + "license": "MIT", + "dependencies": { + "@chestnutlabs/gcode-parser": "0.19.0", + "@chestnutlabs/gcode-preview-core": "0.19.0", + "@chestnutlabs/gcode-renderer-three": "0.19.0", + "@chestnutlabs/toolpath-core": "0.19.0" + }, + "engines": { + "node": ">=22" + } + }, + "../../packages/gcode-renderer-three": { + "name": "@chestnutlabs/gcode-renderer-three", + "version": "0.19.0", + "license": "MIT", + "dependencies": { + "@chestnutlabs/gcode-colors": "0.19.0", + "@chestnutlabs/toolpath-core": "0.19.0" + }, + "devDependencies": { + "@types/three": "0.178.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "three": "^0.178.0" + } + }, + "../../packages/toolpath-core": { + "name": "@chestnutlabs/toolpath-core", + "version": "0.19.0", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@chestnutlabs/gcode-containers": { + "resolved": "../../packages/gcode-containers", + "link": true + }, + "node_modules/@chestnutlabs/gcode-dialects": { + "resolved": "../../packages/gcode-dialects", + "link": true + }, + "node_modules/@chestnutlabs/gcode-parser": { + "resolved": "../../packages/gcode-parser", + "link": true + }, + "node_modules/@chestnutlabs/gcode-preview-core": { + "resolved": "../../packages/gcode-preview-core", + "link": true + }, + "node_modules/@chestnutlabs/gcode-preview-element": { + "resolved": "../../packages/gcode-preview-element", + "link": true + }, + "node_modules/@chestnutlabs/gcode-renderer-three": { + "resolved": "../../packages/gcode-renderer-three", + "link": true + }, + "node_modules/@chestnutlabs/toolpath-core": { + "resolved": "../../packages/toolpath-core", + "link": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.178.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.178.0.tgz", + "integrity": "sha512-ybFIB0+x8mz0wnZgSGy2MO/WCO6xZhQSZnmfytSPyNpM0sBafGRVhdaj+erYh5U+RhQOAg/eXqw5uVDiM2BjhQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/tools/example-webcomponent/package.json b/tools/example-webcomponent/package.json new file mode 100644 index 00000000..8c388e2f --- /dev/null +++ b/tools/example-webcomponent/package.json @@ -0,0 +1,24 @@ +{ + "name": "gcode-preview-example-webcomponent", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Two-tier Web Component example (DD-031 §4.7): minimal.html (smallest real integration of ) + showcase.html (full attribute/property/event surface + shared demo-kit). Consumes the published @chestnutlabs/gcode-preview-element.", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "@chestnutlabs/gcode-containers": "file:../../packages/gcode-containers", + "@chestnutlabs/gcode-dialects": "file:../../packages/gcode-dialects", + "@chestnutlabs/gcode-parser": "file:../../packages/gcode-parser", + "@chestnutlabs/gcode-preview-core": "file:../../packages/gcode-preview-core", + "@chestnutlabs/gcode-preview-element": "file:../../packages/gcode-preview-element", + "@chestnutlabs/gcode-renderer-three": "file:../../packages/gcode-renderer-three", + "@chestnutlabs/toolpath-core": "file:../../packages/toolpath-core", + "three": "0.178.0" + }, + "devDependencies": { + "vite": "^6.0.0" + } +} diff --git a/tools/example-webcomponent/showcase.html b/tools/example-webcomponent/showcase.html new file mode 100644 index 00000000..78259260 --- /dev/null +++ b/tools/example-webcomponent/showcase.html @@ -0,0 +1,197 @@ + + + + + + gcode-preview element — showcase + + + +
+
+ G-code Preview · Web Component + Showcase +
+ + +
+
+ + +
+
+ +
+
+ + + +
+ + + +
+ +
+ + +
+ +
+
+
+ Pick a fixture to begin. +
+ + + + diff --git a/tools/example-webcomponent/src/minimal.js b/tools/example-webcomponent/src/minimal.js new file mode 100644 index 00000000..7edfe6c3 --- /dev/null +++ b/tools/example-webcomponent/src/minimal.js @@ -0,0 +1,40 @@ +/* + * Minimal getting-started example (DD-031 §4.7, tier 1). + * + * The smallest real integration of the Web Component: importing `/define` + * auto-registers the element, then set the `source` PROPERTY (objects can't be attributes) and it + * parses + renders. The layer slider drives the `layer-range` ATTRIBUTE — the attribute/property + * split is the one thing that makes the Web Component differ from the framework wrappers. No design + * system, no demo-kit, no raw renderer. Copy this file to start. + */ +import '@chestnutlabs/gcode-preview-element/define'; + +const view = document.getElementById('view'); +const fixture = document.getElementById('fixture'); +const layer = document.getElementById('layer'); +const layerLabel = document.getElementById('layerLabel'); +const layerNum = document.getElementById('layerNum'); +const layerMax = document.getElementById('layerMax'); + +let layers = 0; + +fixture.addEventListener('change', async () => { + if (!fixture.value) return; + const res = await fetch(`./gcodes/${fixture.value}`); + view.source = new Uint8Array(await res.arrayBuffer()); // property (object) → re-parse +}); + +// `ready`'s detail carries the parse summary. +view.addEventListener('ready', (e) => { + layers = e.detail.layers; + layerLabel.hidden = layers === 0; + layer.max = String(Math.max(0, layers - 1)); + layer.value = layer.max; + layerNum.textContent = layer.value; + layerMax.textContent = layer.max; +}); + +layer.addEventListener('input', () => { + layerNum.textContent = layer.value; + view.setAttribute('layer-range', `0,${layer.value}`); // primitive → attribute +}); diff --git a/tools/example-webcomponent/src/showcase.js b/tools/example-webcomponent/src/showcase.js new file mode 100644 index 00000000..01592d27 --- /dev/null +++ b/tools/example-webcomponent/src/showcase.js @@ -0,0 +1,206 @@ +/* + * Full showcase example (DD-031 §4.7, tier 2). + * + * Exercises the real surface of the published Web Component and mirrors the Feature + * Lab's capability-aware UX in vanilla JS: color modes gate on the file's capabilities with a + * plain-language reason, feature-role hiding is a property, and diagnostics/picking come through + * `element.controls`. This is the adapter where the attribute/property split matters — objects + * (source, colorMode, hiddenFeatureRoles) are set as PROPERTIES; primitives (layer-range, view) can + * be set as ATTRIBUTES. Chrome (light/dark) is separate from the renderer's neutral viewport. + * Styling is the shared workspace-internal demo-kit. Still the real package: no raw renderer imports. + */ +import '@chestnutlabs/gcode-preview-element/define'; +import { FeatureRole } from '@chestnutlabs/toolpath-core'; +import { + FIXTURE_GROUPS, + FIXTURE_BY_ID, + COLOR_MODES, + COLOR_MODE_BY_ID, + colorModeReason, + confidenceTier, + rgbCss, + count as formatCount, + duration as formatDuration +} from '../../demo-kit/index.js'; +import '../../demo-kit/tokens.css'; + +const ADHESION_ROLES = [FeatureRole.Skirt, FeatureRole.Brim]; +const CAP_KEYS = ['featureRoles', 'objects', 'feedrate', 'colorChanges']; +const $ = (id) => document.getElementById(id); + +const view = $('view'); +let caps = {}; +let layers = 0; +let segments = 0; +let colorModeId = 'feature'; + +// ---- populate the pickers from the shared demo-kit ---- +$('fixture').innerHTML = FIXTURE_GROUPS.map( + (g) => `${g.items.map((i) => ``).join('')}` +).join(''); +$('fixture').value = 'skirt-brim'; + +$('mode').innerHTML = COLOR_MODES.map((m) => ``).join(''); +$('mode').value = colorModeId; + +// ---- chrome (app) theme, separate from the viewport ---- +function setChrome(c) { + document.documentElement.dataset.chrome = c; + $('chromeDark').classList.toggle('gp-on', c === 'dark'); + $('chromeLight').classList.toggle('gp-on', c === 'light'); +} +$('chromeDark').addEventListener('click', () => setChrome('dark')); +$('chromeLight').addEventListener('click', () => setChrome('light')); +setChrome('dark'); + +// ---- fixtures ---- +async function load() { + const fx = FIXTURE_BY_ID[$('fixture').value]; + if (!fx) return; + const res = await fetch(`./${fx.path}`); + view.source = new Uint8Array(await res.arrayBuffer()); // property (object) + $('statsOut').textContent = ''; + $('pickOut').textContent = ''; + $('disclosure').textContent = fx.blurb; +} +$('load').addEventListener('click', load); +$('fixture').addEventListener('change', load); + +// ---- color mode: reflect availability, set the colorMode PROPERTY ---- +function applyColorMode() { + const mode = COLOR_MODE_BY_ID[colorModeId]; + const reason = colorModeReason(colorModeId, caps); + $('modeReason').textContent = reason; + if (reason === '') view.colorMode = mode.build(); + // legend + const legend = mode.legend ?? null; + $('legendCard').hidden = legend === null; + if (legend) { + $('legendTitle').textContent = mode.label; + $('legend').innerHTML = legend + .map(([label, rgb]) => `${label}`) + .join(''); + } +} +function refreshModeOptions() { + for (const opt of $('mode').options) { + const reason = colorModeReason(opt.value, caps); + opt.disabled = reason !== ''; + const base = COLOR_MODE_BY_ID[opt.value].label; + opt.textContent = reason !== '' ? `${base} — unavailable` : base; + } +} +$('mode').addEventListener('change', () => { + colorModeId = $('mode').value; + applyColorMode(); +}); + +// ---- filter controls ---- +$('layer').addEventListener('input', () => { + const v = $('layer').value; + $('layerValue').textContent = `${v} / ${Math.max(0, layers - 1)}`; + view.setAttribute('layer-range', `0,${v}`); // primitive → attribute +}); +$('hide').addEventListener('change', () => { + view.hiddenFeatureRoles = $('hide').checked ? ADHESION_ROLES : []; // property (array) +}); +$('travel').addEventListener('change', () => { + view.showTravel = $('travel').checked; // boolean property +}); +$('retract').addEventListener('change', () => { + view.showRetractions = $('retract').checked; +}); + +// ---- camera ---- +for (const btn of document.querySelectorAll('.sc-cam button')) { + btn.addEventListener('click', () => { + document.querySelectorAll('.sc-cam button').forEach((b) => b.classList.remove('gp-on')); + btn.classList.add('gp-on'); + view.setAttribute('view', btn.dataset.view); // primitive → attribute + }); +} + +// ---- diagnostics: element.controls ---- +$('stats').addEventListener('click', () => { + const s = view.controls.getRenderStats(); + $('statsOut').textContent = s + ? [ + `backend: ${s.backend} (${s.capability})`, + `geometry: ${s.geometryMode}`, + `segments: ${formatCount(s.renderedSegmentCount ?? 0)}`, + `draw calls: ${s.drawCalls ?? '—'}`, + s.gpuRenderer ?? '' + ] + .filter(Boolean) + .join('\n') + : '(no stats — renderer not ready)'; +}); +$('viewport').addEventListener('click', (e) => { + const c = view.querySelector('canvas') ?? view.shadowRoot?.querySelector('canvas'); + if (!c || layers === 0) return; + const r = c.getBoundingClientRect(); + const ndcX = ((e.clientX - r.left) / r.width) * 2 - 1; + const ndcY = -(((e.clientY - r.top) / r.height) * 2 - 1); + // pickSegment returns the IR segment index, or null if the ray missed. + const hit = view.controls.pickSegment(ndcX, ndcY); + $('pickOut').textContent = hit !== null ? `Picked IR segment #${hit}` : 'Click the model to identify a segment.'; +}); + +// ---- capability badges ---- +function renderCaps() { + $('caps').innerHTML = CAP_KEYS.map((k) => { + const t = confidenceTier(caps[k]); + return `${k} · ${t.label}`; + }).join(''); +} +renderCaps(); + +// ---- events from the element (CustomEvent; detail shapes match the adapter contract) ---- +view.addEventListener('ready', (e) => { + const s = e.detail; + caps = s.capabilities; + layers = s.layers; + segments = s.segments; + renderCaps(); + refreshModeOptions(); + + // read the synchronous state snapshot for the fields `ready` doesn't carry + const st = view.state; + // fall back off an unavailable mode + if (!st.availableColorModes.includes(colorModeId) && colorModeReason(colorModeId, s.capabilities) !== '') { + colorModeId = 'single'; + $('mode').value = 'single'; + } + applyColorMode(); + + // layer slider + $('layerField').hidden = layers === 0; + $('layer').max = String(Math.max(0, layers - 1)); + $('layer').value = $('layer').max; + $('layerValue').textContent = `${$('layer').value} / ${Math.max(0, layers - 1)}`; + + // feature-role hiding gate + const featureRolesKnown = caps.featureRoles === 'known' || caps.featureRoles === 'inferred'; + $('hide').disabled = !featureRolesKnown; + $('hideRow').classList.toggle('gp-disabled', !featureRolesKnown); + $('hideReason').textContent = featureRolesKnown ? '' : ' — file has no feature roles'; + + // retraction gate + $('retract').disabled = !st.hasRetractions; + $('retractRow').classList.toggle('gp-disabled', !st.hasRetractions); + $('retractReason').textContent = st.hasRetractions ? '' : ' — none in this file'; + + // count pill + const timeStr = st.totalTimeMs !== null ? ` · ${formatDuration(st.totalTimeMs)}` : ''; + $('count').hidden = segments === 0; + $('count').textContent = `${formatCount(segments)} segments · ${layers} layers${timeStr}`; + + $('stats').disabled = segments === 0; +}); +view.addEventListener('build-complete', () => $('stats').click()); +view.addEventListener('disclosure', (e) => { + if (e.detail?.text) $('disclosure').textContent = e.detail.text; // WC wraps disclosure as { text } +}); +view.addEventListener('parse-error', (e) => { + $('disclosure').textContent = `Parse error: ${e.detail.code} — ${e.detail.message}`; +}); diff --git a/tools/example-webcomponent/vite.config.js b/tools/example-webcomponent/vite.config.js new file mode 100644 index 00000000..7f565409 --- /dev/null +++ b/tools/example-webcomponent/vite.config.js @@ -0,0 +1,41 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'node:path'; + +// Two-tier example (DD-031 §4.7): `minimal.html` is the smallest real integration; `showcase.html` +// exercises the full attribute/property/event surface and shares the workspace-internal demo-kit +// (../demo-kit) with the Feature Lab. Both import the REAL published @chestnutlabs/gcode-preview-element +// — no raw renderer/session. This is the framework-free adapter, so the app itself is vanilla JS. +export default defineConfig({ + base: './', + // Serve the whole MIT demo corpus so demo-kit fixture paths (gcodes/…, fixtures/…) resolve. + publicDir: '../../test-data', + server: { + port: 5204, + strictPort: true, + // Allow the shared demo-kit, which lives outside this app's root (tools/demo-kit). + fs: { allow: ['../..'] } + }, + build: { + rollupOptions: { + input: { + index: resolve(import.meta.dirname, 'index.html'), + minimal: resolve(import.meta.dirname, 'minimal.html'), + showcase: resolve(import.meta.dirname, 'showcase.html') + } + } + }, + resolve: { + dedupe: ['three'] + }, + optimizeDeps: { + exclude: [ + '@chestnutlabs/gcode-parser', + '@chestnutlabs/toolpath-core', + '@chestnutlabs/gcode-renderer-three', + '@chestnutlabs/gcode-dialects', + '@chestnutlabs/gcode-containers', + '@chestnutlabs/gcode-preview-core', + '@chestnutlabs/gcode-preview-element' + ] + } +}); From 972d58f342abc563906a95c2c3008088a5daf35d Mon Sep 17 00:00:00 2001 From: Nathaniel Chestnut Date: Sat, 29 Aug 2026 07:29:46 -0700 Subject: [PATCH 09/22] docs(adapters): point READMEs + manual at the two-tier examples (DD-031 P4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - React/Svelte README "Example" → "Examples": describe the minimal + showcase tiers and the correct --prefix run command (tools/* are not workspaces). - Vue/Element READMEs: add the missing "Examples" section (both now have real example apps — a greenfield Vue app and the first Web Component example). - adapters.md: add a "Try the examples" table (adapter → dir → port) and the minimal/showcase split. docs:links OK (245 internal links). Co-Authored-By: Claude Opus 4.8 --- docs/manual/adapters.md | 18 ++++++++++++++++++ packages/gcode-preview-element/README.md | 15 +++++++++++++++ packages/gcode-preview-react/README.md | 16 +++++++++++++--- packages/gcode-preview-svelte/README.md | 16 +++++++++++++--- packages/gcode-preview-vue/README.md | 15 +++++++++++++++ 5 files changed, 74 insertions(+), 6 deletions(-) diff --git a/docs/manual/adapters.md b/docs/manual/adapters.md index 1640c409..e2b0025d 100644 --- a/docs/manual/adapters.md +++ b/docs/manual/adapters.md @@ -22,6 +22,24 @@ useGcodePreview() / createGcodePreview() / element.controls ← your own UI ov Reach for the component to get a working viewer in one line; reach for the lower-level API when you want bespoke controls without reimplementing the parse→render pipeline. +## Try the examples + +Each adapter ships a runnable Vite example in the repository with **two tiers**, both driving the +real published package (never a raw renderer or parser): + +| Adapter | Example | Port | +| --- | --- | --- | +| Vue | `tools/example-vue` | 5203 | +| React | `tools/example-react` | 5201 | +| Svelte | `tools/example-svelte` | 5202 | +| Web Component | `tools/example-webcomponent` | 5204 | + +Every one has a **`minimal.html`** — the smallest real integration you'd copy to get started — and a +**`showcase.html`** — the full capability-aware surface (color modes that grey out with a reason when +a file can't support them, declarative feature-role hiding, and `getRenderStats()`/`pickSegment()` +diagnostics through the handle). Run one with, e.g., +`npm install --prefix tools/example-react && npm run dev --prefix tools/example-react`. + ## The shared surface The declarative props below are equivalent across the Vue, React, and Svelte components (camelCase), diff --git a/packages/gcode-preview-element/README.md b/packages/gcode-preview-element/README.md index 4ec93dbf..7f3f3cd6 100644 --- a/packages/gcode-preview-element/README.md +++ b/packages/gcode-preview-element/README.md @@ -85,6 +85,21 @@ picking), and `isColorModeAvailable(mode)`; `capture(opts?)` (image `Blob`) is b method and `controls.capture`. The handle `state` carries capability-aware UI data (`availableColorModes`, `hasRetractions`, `hasColorChanges`). +## Examples + +`tools/example-webcomponent` in the repository is a framework-free Vite app with two tiers, both +driving this published element (no raw renderer or parser imports): + +- **`minimal.html`** — the smallest real integration: import `/define` to register ``, + set the `source` property, listen for `ready`; the layer slider drives the `layer-range` attribute. + Shows the attribute/property split in ~40 lines. +- **`showcase.html`** — the full surface in vanilla JS: objects (`source`, `colorMode`, + `hiddenFeatureRoles`) set as properties, primitives (`layer-range`, `view`) as attributes; + capability-gated color modes; `getRenderStats()`/`pickSegment()` diagnostics via `element.controls`. + +Run it with `npm install --prefix tools/example-webcomponent && npm run dev --prefix +tools/example-webcomponent` (port 5204). + ## Workers By default the batteries worker parses off-thread. To supply a custom/slim worker, set the diff --git a/packages/gcode-preview-react/README.md b/packages/gcode-preview-react/README.md index 01e0c018..40d2a4de 100644 --- a/packages/gcode-preview-react/README.md +++ b/packages/gcode-preview-react/README.md @@ -61,7 +61,17 @@ The batteries default (all dialect adapters + `.gcode.3mf`) needs zero setup und `@chestnutlabs/gcode-preview-vue` README for the shared worker documentation — the options are identical across adapters by design. -## Example +## Examples -`tools/example-react` in the repository is a complete Vite + React app (corpus load, layer/scrub, -simulated live progress) running under ``. +`tools/example-react` in the repository is a Vite app with two tiers, both driving this published +package (no raw renderer or parser imports): + +- **`minimal.html`** — the smallest real integration: `` plus a fixture picker + and a layer slider. One short file to copy when getting started. +- **`showcase.html`** — the full declarative surface: capability-gated color modes (a mode greys out + with a plain-language reason when the file can't support it), declarative `hiddenFeatureRoles`, + camera, and `getRenderStats()`/`pickSegment()` diagnostics through the `ref` handle. Uses the + `useGcodePreview` hook for render-subscribed reactive state. + +Run it with `npm install --prefix tools/example-react && npm run dev --prefix tools/example-react` +(port 5201). diff --git a/packages/gcode-preview-svelte/README.md b/packages/gcode-preview-svelte/README.md index ab239ac6..b1952fcc 100644 --- a/packages/gcode-preview-svelte/README.md +++ b/packages/gcode-preview-svelte/README.md @@ -67,7 +67,17 @@ Batteries default (all dialect adapters + `.gcode.3mf`) needs zero setup under V identical across all three framework adapters by design (see the Vue README for the shared worker documentation). -## Example +## Examples -`tools/example-svelte` in the repository is a complete Vite + Svelte app (corpus load, -layer/scrub, simulated live progress) compiling the raw component through the export condition. +`tools/example-svelte` in the repository is a Vite app with two tiers, both driving this published +package through the `.svelte` export condition (no raw renderer or parser imports): + +- **`minimal.html`** — the smallest real integration: `` plus a fixture + picker and a layer slider. One short component to copy when getting started. +- **`showcase.html`** — the full declarative surface: capability-gated color modes (a mode greys out + with a plain-language reason when the file can't support it), declarative `hiddenFeatureRoles`, + camera, and `getRenderStats()`/`pickSegment()` diagnostics through the `bind:this` handle + (`viewer.preview.controls`); the handle's store-contract `state` supplies the reactive UI data. + +Run it with `npm install --prefix tools/example-svelte && npm run dev --prefix tools/example-svelte` +(port 5202). diff --git a/packages/gcode-preview-vue/README.md b/packages/gcode-preview-vue/README.md index ff62b761..3b88bee7 100644 --- a/packages/gcode-preview-vue/README.md +++ b/packages/gcode-preview-vue/README.md @@ -98,6 +98,21 @@ Linked-workspace development note: Vite consumers using `file:` links should add `@chestnutlabs/*` package the worker pulls in to `optimizeDeps.exclude` (installed tarballs/registry packages need no configuration). +## Examples + +`tools/example-vue` in the repository is a Vite app with two tiers, both driving this published +package (no raw renderer or parser imports): + +- **`minimal.html`** — the smallest real integration: `` plus a fixture picker + and a layer slider. One short component to copy when getting started. +- **`showcase.html`** — the full declarative surface: capability-gated color modes (a mode greys out + with a plain-language reason when the file can't support it), declarative `:hidden-feature-roles`, + camera, and `getRenderStats()`/`pickSegment()` diagnostics through the component ref's handle + (`viewer.preview.controls`), whose `state` mirror is directly reactive in Vue. + +Run it with `npm install --prefix tools/example-vue && npm run dev --prefix tools/example-vue` +(port 5203). + ## Capability honesty Everything the engine reports as `inferred`/`approximated`/`unavailable` stays that way at this From b7f1fc01170724164446f7b5a3cd9ae793c70258 Mon Sep 17 00:00:00 2001 From: Nathaniel Chestnut Date: Sat, 29 Aug 2026 07:34:02 -0700 Subject: [PATCH 10/22] chore(examples): expose showcase handle on window + WC diagnostics parity (DD-031 P4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Each showcase publishes its handle as window.gcodePreview after `ready` (devtools/ inspection convenience, mirroring the Feature Lab's window.viewer; also what the documentation screenshot harness composites through). - Web Component: on build-complete, a null getRenderStats() read now renders nothing instead of "(no stats — renderer not ready)", matching the three framework showcases (stats populate a tick after build-complete; the button reads on demand). Co-Authored-By: Claude Opus 4.8 --- tools/example-react/src/showcase.jsx | 1 + tools/example-svelte/src/Showcase.svelte | 1 + tools/example-vue/src/Showcase.vue | 1 + tools/example-webcomponent/src/showcase.js | 12 ++++++++---- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/tools/example-react/src/showcase.jsx b/tools/example-react/src/showcase.jsx index 51eed8bd..19d224b0 100644 --- a/tools/example-react/src/showcase.jsx +++ b/tools/example-react/src/showcase.jsx @@ -75,6 +75,7 @@ function App() { } function onReady(s) { + if (typeof window !== 'undefined') window.gcodePreview = ref.current; // handle, for devtools/inspection setCaps(s.capabilities); setLayers(s.layers); setSegments(s.segments); diff --git a/tools/example-svelte/src/Showcase.svelte b/tools/example-svelte/src/Showcase.svelte index 6abcbead..b883467b 100644 --- a/tools/example-svelte/src/Showcase.svelte +++ b/tools/example-svelte/src/Showcase.svelte @@ -92,6 +92,7 @@ layers = s.layers; segments = s.segments; topLayer = Math.max(0, s.layers - 1); + if (typeof window !== 'undefined') window.gcodePreview = viewer?.preview; // for devtools/inspection const st = stateSnapshot(); if (st) { hasRetractions = st.hasRetractions; diff --git a/tools/example-vue/src/Showcase.vue b/tools/example-vue/src/Showcase.vue index b5c32ddf..2a92a568 100644 --- a/tools/example-vue/src/Showcase.vue +++ b/tools/example-vue/src/Showcase.vue @@ -83,6 +83,7 @@ function onReady(summary) { layers.value = summary.layers; segments.value = summary.segments; topLayer.value = Math.max(0, summary.layers - 1); + if (typeof window !== 'undefined') window.gcodePreview = viewer.value?.preview; // for devtools/inspection const st = viewer.value?.preview?.state; if (st) { hasRetractions.value = st.hasRetractions; diff --git a/tools/example-webcomponent/src/showcase.js b/tools/example-webcomponent/src/showcase.js index 01592d27..672f2efc 100644 --- a/tools/example-webcomponent/src/showcase.js +++ b/tools/example-webcomponent/src/showcase.js @@ -121,8 +121,10 @@ for (const btn of document.querySelectorAll('.sc-cam button')) { } // ---- diagnostics: element.controls ---- -$('stats').addEventListener('click', () => { +function readStats() { const s = view.controls.getRenderStats(); + // Render nothing until stats exist (they populate a tick after build-complete) — parity with the + // framework showcases, which also silently no-op a null read rather than showing an error line. $('statsOut').textContent = s ? [ `backend: ${s.backend} (${s.capability})`, @@ -133,8 +135,9 @@ $('stats').addEventListener('click', () => { ] .filter(Boolean) .join('\n') - : '(no stats — renderer not ready)'; -}); + : ''; +} +$('stats').addEventListener('click', readStats); $('viewport').addEventListener('click', (e) => { const c = view.querySelector('canvas') ?? view.shadowRoot?.querySelector('canvas'); if (!c || layers === 0) return; @@ -157,6 +160,7 @@ renderCaps(); // ---- events from the element (CustomEvent; detail shapes match the adapter contract) ---- view.addEventListener('ready', (e) => { + if (typeof window !== 'undefined') window.gcodePreview = view; // element handle, for devtools/inspection const s = e.detail; caps = s.capabilities; layers = s.layers; @@ -197,7 +201,7 @@ view.addEventListener('ready', (e) => { $('stats').disabled = segments === 0; }); -view.addEventListener('build-complete', () => $('stats').click()); +view.addEventListener('build-complete', readStats); view.addEventListener('disclosure', (e) => { if (e.detail?.text) $('disclosure').textContent = e.detail.text; // WC wraps disclosure as { text } }); From 829cacce7bed3f47d174f9ace6c73d54dddcbd61 Mon Sep 17 00:00:00 2001 From: Nathaniel Chestnut Date: Sat, 29 Aug 2026 07:35:35 -0700 Subject: [PATCH 11/22] docs(visual-coverage): record the DD-031 consumer-UX demonstration surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a 2026-08-29 (DD-031) coverage-delta section: the Feature Lab rebuild and the four two-tier adapter examples are new *demonstration surfaces* (no new engine capability), and getRenderStats/pickSegment are now reachable through the public adapter surface. Update section 10's adapter row + a capability-aware-UX row. Honest media status: dark app-UI review screenshots were captured but are NOT yet canonical docs/media assets — the manifest harness produces the mid-grey capability presentation, not app chrome. Deferred (recorded) to the next Visual Review. Co-Authored-By: Claude Opus 4.8 --- docs/VISUAL_FEATURE_COVERAGE.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/VISUAL_FEATURE_COVERAGE.md b/docs/VISUAL_FEATURE_COVERAGE.md index 7d2dee43..02b8aedf 100644 --- a/docs/VISUAL_FEATURE_COVERAGE.md +++ b/docs/VISUAL_FEATURE_COVERAGE.md @@ -91,6 +91,27 @@ Genuinely deferred, each with a recorded reason (valid dispositions, not silent These are tracked here so the next release's Public Product + Documentation + Visual Review picks them up rather than rediscovering them. +## This pass (2026-08-29, DD-031) — consumer UX & framework parity + +The consumer-experience pass reworked the *demonstration surfaces* (it added no new engine +capabilities, so the per-capability rows below are unchanged): + +- The demo is rebuilt as the **Feature Lab** on the published `gcode-preview-core` controller — + viewport-first, intent-grouped controls, and **capability-aware** UI (a color mode greys out with + a plain-language reason when the file can't support it; the CNC tab appears only when a tool-power + channel exists; capability confidence is shown as tiered badges). +- Every adapter now ships a **two-tier example** (`tools/example-{react,vue,svelte,webcomponent}`): + a genuinely minimal getting-started page and a full **showcase** that mirrors the Feature Lab's + capability-aware UX in that framework's idiom, on the shared demo-kit. This is what makes "find the + interactive version of everything in the docs" true per-framework, not just in the demo. +- Review screenshots of the Feature Lab and the four showcases were captured this pass (dark app UI). + These are **not yet canonical `docs/media` assets**: the manifest harness produces the mid-grey + capability presentation, not app chrome. Deferred: teach the harness to emit app-UI shots (or add a + dark-app class) so these become tracked media — carried to the next Visual Review. +- `getRenderStats` diagnostics and `pickSegment` picking are now reachable through the **public + adapter surface** in all four showcases (previously controls/raw-only) — the dark-capability + closure that section 1's `pickSegment` row and section 4's diagnostics row depend on. + ## 1. Toolpath inspection | Capability | User-facing | Demo | Media | README | Pages gallery | Manual/pkg | Status | @@ -207,7 +228,8 @@ them up rather than rediscovering them. | Capability | User-facing | Demo | Media | README | Pages gallery | Manual/pkg | Status | |---|:---:|:---:|:---:|:---:|:---:|:---:|---| -| Vue / React / Svelte / Web Component adapters | ✅ | examples | — | ✅ (table) | — | adapters | not visually meaningful (code) | +| Vue / React / Svelte / Web Component adapters | ✅ | two-tier examples + Feature Lab | — | ✅ (table) | — | adapters | covered (code + examples) — each adapter has a minimal + showcase example on the shared design language; canonical app-UI media pending harness support | +| Capability-aware consumer UX (gated modes + reasons, confidence tiers, contextual controls) | ✅ | Feature Lab + 4 showcases | — | ✅ (prose) | — | adapters | demonstrated in demo + examples; review captures exist (dark app UI), not yet canonical mid-grey media | | Headless toolpath still (`renderStill`) | ✅ | still page | — | ✅ | gallery | reference/still-render | needs screenshot (server-thumbnail context) | | Headless model still (`renderModelStill`) | ✅ | model page | `model-render-stl-3mf` | ✅ | gallery | model-renderer README | covered | | Off-thread parsing / cancellation / limits | ✅ | ✅ | — | ✅ (prose) | — | concept-workers | not visually meaningful | From 0e5ff63546ed3b9f993890a77e34708100f4c074 Mon Sep 17 00:00:00 2001 From: Nathaniel Chestnut Date: Sat, 29 Aug 2026 10:36:03 -0700 Subject: [PATCH 12/22] fix(demo,examples): rail-collapse overlap + WC travel desync + clean minimals (DD-031 live-pass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from the owner's live UX pass: - Feature Lab: the rail-collapse (☰) button was pinned to the viewport's top-right corner, overlapping the camera cluster's projection (perspective/orthographic) toggle. Moved it to a vertically-centered handle on the rail seam, clear of the camera controls. - Web Component showcase: "Show travel moves" read as off (checkbox unchecked) but travel still rendered — the element keeps its own defaults (showTravel=true) and the showcase never pushed the initial state. Now applies the initial toggle state on startup, so the control matches what's drawn. (The framework showcases bind showTravel declaratively and already applied it on mount.) - Minimals (all four): default travel moves off (showTravel={false}) for a clean first-render, matching the showcases. Verified headless: Feature Lab camera cluster unobstructed; WC showcase travel off by default and toggles on correctly. Co-Authored-By: Claude Opus 4.8 --- tools/demo/index.html | 5 ++++- tools/example-react/src/minimal.jsx | 1 + tools/example-svelte/src/Minimal.svelte | 2 +- tools/example-vue/src/Minimal.vue | 7 ++++++- tools/example-webcomponent/src/minimal.js | 3 +++ tools/example-webcomponent/src/showcase.js | 7 +++++++ 6 files changed, 22 insertions(+), 3 deletions(-) diff --git a/tools/demo/index.html b/tools/demo/index.html index f97efe65..4ea20b57 100644 --- a/tools/demo/index.html +++ b/tools/demo/index.html @@ -203,9 +203,12 @@ flex: 1; min-height: 0; } + /* Rail collapse/expand handle — a drawer handle centered on the rail seam, kept clear of the + top-right camera cluster (which shares that corner). */ .gp-rail-collapse { position: absolute; - top: var(--gp-space-3); + top: 50%; + transform: translateY(-50%); right: calc(var(--rail-w, 360px) + var(--gp-space-2)); z-index: 5; } diff --git a/tools/example-react/src/minimal.jsx b/tools/example-react/src/minimal.jsx index ad7744d3..abd7fe3d 100644 --- a/tools/example-react/src/minimal.jsx +++ b/tools/example-react/src/minimal.jsx @@ -53,6 +53,7 @@ function App() { 0 ? [0, topLayer] : null} + showTravel={false} onReady={(s) => { setLayers(s.layers); setTopLayer(Math.max(0, s.layers - 1)); diff --git a/tools/example-svelte/src/Minimal.svelte b/tools/example-svelte/src/Minimal.svelte index d23d49e1..9e1a9113 100644 --- a/tools/example-svelte/src/Minimal.svelte +++ b/tools/example-svelte/src/Minimal.svelte @@ -45,5 +45,5 @@ {/if}
- 0 ? [0, topLayer] : null} on:ready={onReady} /> + 0 ? [0, topLayer] : null} showTravel={false} on:ready={onReady} />
diff --git a/tools/example-vue/src/Minimal.vue b/tools/example-vue/src/Minimal.vue index 69ab829b..28ba8d4c 100644 --- a/tools/example-vue/src/Minimal.vue +++ b/tools/example-vue/src/Minimal.vue @@ -42,6 +42,11 @@ function onReady(summary) {
- +
diff --git a/tools/example-webcomponent/src/minimal.js b/tools/example-webcomponent/src/minimal.js index 7edfe6c3..a6235b3f 100644 --- a/tools/example-webcomponent/src/minimal.js +++ b/tools/example-webcomponent/src/minimal.js @@ -16,6 +16,9 @@ const layerLabel = document.getElementById('layerLabel'); const layerNum = document.getElementById('layerNum'); const layerMax = document.getElementById('layerMax'); +// Hide travel moves for a clean first view (property; the element defaults showTravel to true). +view.showTravel = false; + let layers = 0; fixture.addEventListener('change', async () => { diff --git a/tools/example-webcomponent/src/showcase.js b/tools/example-webcomponent/src/showcase.js index 672f2efc..d5324126 100644 --- a/tools/example-webcomponent/src/showcase.js +++ b/tools/example-webcomponent/src/showcase.js @@ -43,6 +43,13 @@ $('fixture').value = 'skirt-brim'; $('mode').innerHTML = COLOR_MODES.map((m) => ``).join(''); $('mode').value = colorModeId; +// Apply the initial control state to the element. Unlike the framework wrappers (which bind these +// declaratively and so apply them on mount), the element keeps its own defaults — e.g. showTravel +// defaults to `true` — until we push the UI's initial state, or the unchecked boxes would disagree +// with what's rendered. (Controls set before parse are queued and replayed.) +view.showTravel = $('travel').checked; +view.showRetractions = $('retract').checked; + // ---- chrome (app) theme, separate from the viewport ---- function setChrome(c) { document.documentElement.dataset.chrome = c; From c256ae887e4bf2dd39ce24a5aaa0b4444c2b4ee8 Mon Sep 17 00:00:00 2001 From: Nathaniel Chestnut Date: Sat, 29 Aug 2026 11:23:40 -0700 Subject: [PATCH 13/22] feat(model-adapters): shared model controller + React model-viewer adapter (DD-031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make interactive source-model viewing (STL/3MF) a first-class, declarative half of the SDK — the Prepare side alongside the toolpath Preview side (owner decision). gcode-model-renderer (new, framework-neutral): - createModelPreviewController — the Prepare-side analogue of gcode-preview-core's toolpath controller: lazy engine on bindCanvas (the engine takes its canvas at construction; the controller defers + rebuilds on canvas replace, replaying the last source), an op queue, and a reactive state snapshot (getState/onStateChange) derived from the engine's events. Wraps the existing createModelViewer unchanged (no renderer redesign). ModelPreviewControls/State contract exported. - /testing subpath: runModelBehavioralSuite + controls/state completeness parity guards + shared stub GL/controls + STL/coloured source makers. gcode-preview-react/model (new subpath): - useModelViewer hook (useSyncExternalStore over the controller) + component shell. Thin bridges — never a separate viewer. Tests: model-renderer 69 (controller: lazy bind, pre-bind queue, capability passthrough, rebind-replay, unsupported-renderer); react 20 (model behavioral suite: both parity guards pass through the adapter). No regressions. Co-Authored-By: Claude Opus 4.8 --- packages/gcode-model-renderer/package.json | 4 + .../src/__tests__/model-preview.test.ts | 144 ++++++++ packages/gcode-model-renderer/src/index.ts | 9 + .../gcode-model-renderer/src/model-preview.ts | 327 ++++++++++++++++++ .../gcode-model-renderer/src/model-testing.ts | 212 ++++++++++++ packages/gcode-preview-react/package.json | 5 + .../__tests__/model-behavioral-suite.test.ts | 67 ++++ .../gcode-preview-react/src/model/index.ts | 18 + .../src/model/model-viewer-component.ts | 166 +++++++++ .../src/model/use-model-viewer.ts | 92 +++++ 10 files changed, 1044 insertions(+) create mode 100644 packages/gcode-model-renderer/src/__tests__/model-preview.test.ts create mode 100644 packages/gcode-model-renderer/src/model-preview.ts create mode 100644 packages/gcode-model-renderer/src/model-testing.ts create mode 100644 packages/gcode-preview-react/src/__tests__/model-behavioral-suite.test.ts create mode 100644 packages/gcode-preview-react/src/model/index.ts create mode 100644 packages/gcode-preview-react/src/model/model-viewer-component.ts create mode 100644 packages/gcode-preview-react/src/model/use-model-viewer.ts diff --git a/packages/gcode-model-renderer/package.json b/packages/gcode-model-renderer/package.json index 5db79329..c30e750e 100644 --- a/packages/gcode-model-renderer/package.json +++ b/packages/gcode-model-renderer/package.json @@ -37,6 +37,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./testing": { + "types": "./dist/model-testing.d.ts", + "import": "./dist/model-testing.js" } }, "files": [ diff --git a/packages/gcode-model-renderer/src/__tests__/model-preview.test.ts b/packages/gcode-model-renderer/src/__tests__/model-preview.test.ts new file mode 100644 index 00000000..9b88918c --- /dev/null +++ b/packages/gcode-model-renderer/src/__tests__/model-preview.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from 'vitest'; +import type { GLRendererLike, RenderTargetCanvas } from '@chestnutlabs/gcode-renderer-three'; +import { createModelPreviewController } from '../model-preview.js'; +import { IDENTITY_MAT4, type ModelScene } from '../scene-model.js'; + +function makeBinaryStl(): Uint8Array { + const tris = [ + [ + [0, 0, 0], + [10, 0, 0], + [0, 20, 0] + ] + ]; + const buf = new ArrayBuffer(84 + tris.length * 50); + const dv = new DataView(buf); + dv.setUint32(80, tris.length, true); + let o = 84; + for (const t of tris) { + o += 12; + for (const v of t) { + dv.setFloat32(o, v[0], true); + dv.setFloat32(o + 4, v[1], true); + dv.setFloat32(o + 8, v[2], true); + o += 12; + } + o += 2; + } + return new Uint8Array(buf); +} + +function colouredScene(): ModelScene { + return { + objects: [ + { + id: 'a', + geometry: { positions: new Float32Array([0, 0, 0, 10, 0, 0, 0, 10, 0]) }, + transform: IDENTITY_MAT4, + material: { color: [1, 0, 0] } + } + ], + bounds: { min: [0, 0, 0], max: [10, 10, 0] }, + capabilities: { materials: 'known', transforms: 'unavailable', multiObject: 'unavailable' } + }; +} + +const stubCanvas = (): RenderTargetCanvas => ({ width: 256, height: 256 }) as unknown as RenderTargetCanvas; +const stubGL = (canvas: RenderTargetCanvas): GLRendererLike => ({ + render: () => undefined, + setSize: () => undefined, + dispose: () => undefined, + domElement: canvas +}); + +describe('createModelPreviewController (DD-031)', () => { + it('starts inert with an honest initial state before a canvas binds', () => { + const c = createModelPreviewController({ createRenderer: stubGL }); + const s = c.getState(); + expect(s.ready).toBe(false); + expect(s.loading).toBe(false); + expect(s.rendererSupported).toBe(true); + expect(s.objectCount).toBe(0); + expect(s.materials).toBe('unavailable'); + expect(c.raw.viewer()).toBeNull(); + c.dispose(); + }); + + it('bindCanvas → setSource(STL) drives ready state + a state-change notification', async () => { + const c = createModelPreviewController({ createRenderer: stubGL }); + const seen: boolean[] = []; + c.onStateChange((s) => seen.push(s.ready)); + c.bindCanvas(stubCanvas()); + const info = await c.controls.setSource({ kind: 'stl', bytes: makeBinaryStl() }); + expect(info.objectCount).toBe(1); + const s = c.getState(); + expect(s.ready).toBe(true); + expect(s.loading).toBe(false); + expect(s.objectCount).toBe(1); + expect(s.materials).toBe('unavailable'); // STL carries no colour — honest + expect(s.bounds?.max[1]).toBeCloseTo(20); + expect(seen).toContain(true); // onStateChange fired with ready + c.dispose(); + }); + + it('passes capability tiers through unchanged (coloured scene → materials: known)', async () => { + const c = createModelPreviewController({ createRenderer: stubGL }); + c.bindCanvas(stubCanvas()); + await c.controls.setSource(colouredScene()); + expect(c.getState().materials).toBe('known'); + c.dispose(); + }); + + it('setSource issued BEFORE bindCanvas is queued and settles after the canvas binds', async () => { + const c = createModelPreviewController({ createRenderer: stubGL }); + const pending = c.controls.setSource({ kind: 'stl', bytes: makeBinaryStl() }); + expect(c.getState().loading).toBe(true); // optimistic loading before the engine exists + c.bindCanvas(stubCanvas()); + const info = await pending; + expect(info.objectCount).toBe(1); + expect(c.getState().ready).toBe(true); + c.dispose(); + }); + + it('forwards engine events through onEvent (ready)', async () => { + const c = createModelPreviewController({ createRenderer: stubGL }); + const types: string[] = []; + c.onEvent((e) => types.push(e.type)); + c.bindCanvas(stubCanvas()); + await c.controls.setSource(colouredScene()); + expect(types).toContain('ready'); + c.dispose(); + }); + + it('an unsupported renderer marks state.rendererSupported false (fall back to a still)', () => { + const c = createModelPreviewController({ + createRenderer: () => { + throw new Error('no webgl'); + } + }); + c.bindCanvas(stubCanvas()); + expect(c.getState().rendererSupported).toBe(false); + c.dispose(); + }); + + it('capture before a canvas binds rejects rather than throwing', async () => { + const c = createModelPreviewController({ createRenderer: stubGL }); + await expect(c.controls.capture()).rejects.toThrow(/E_CAPTURE_UNSUPPORTED/); + c.dispose(); + }); + + it('rebinding a new canvas replays the last source so the model survives a remount', async () => { + const c = createModelPreviewController({ createRenderer: stubGL }); + c.bindCanvas(stubCanvas()); + await c.controls.setSource(colouredScene()); + expect(c.getState().ready).toBe(true); + // Simulate a remount: detach then attach a fresh canvas. + c.bindCanvas(null); + c.bindCanvas(stubCanvas()); + // The source is replayed; wait a microtask-ish for the async load. + await new Promise((r) => setTimeout(r, 0)); + expect(c.getState().ready).toBe(true); + expect(c.getState().objectCount).toBe(1); + c.dispose(); + }); +}); diff --git a/packages/gcode-model-renderer/src/index.ts b/packages/gcode-model-renderer/src/index.ts index 39e9b8b5..c7d2e358 100644 --- a/packages/gcode-model-renderer/src/index.ts +++ b/packages/gcode-model-renderer/src/index.ts @@ -12,6 +12,15 @@ export { renderModelStill } from './render-model-still.js'; export type { ModelSource, RenderModelStillOptions, RenderModelStillResult } from './render-model-still.js'; export { createModelViewer } from './model-viewer.js'; export type { ModelViewer, ModelViewerOptions, ModelViewerEvent, ModelReadyInfo } from './model-viewer.js'; +// DD-031: framework-neutral controller for the interactive model viewer (the Prepare-side analogue of +// gcode-preview-core's toolpath controller). The four framework `/model` adapters are thin shells over it. +export { createModelPreviewController, INITIAL_MODEL_STATE } from './model-preview.js'; +export type { + ModelPreviewController, + ModelPreviewControllerOptions, + ModelPreviewControls, + ModelPreviewState +} from './model-preview.js'; export { parseStl } from './stl.js'; export { parse3mf } from './three-mf.js'; export type { Parse3mfOptions } from './three-mf.js'; diff --git a/packages/gcode-model-renderer/src/model-preview.ts b/packages/gcode-model-renderer/src/model-preview.ts new file mode 100644 index 00000000..07c93330 --- /dev/null +++ b/packages/gcode-model-renderer/src/model-preview.ts @@ -0,0 +1,327 @@ +/** + * `createModelPreviewController` (DD-031) — the framework-neutral **controller** for interactive + * source-model viewing, the Prepare-side analogue of `@chestnutlabs/gcode-preview-core`'s + * `createPreviewController` (the Preview/toolpath side). The four framework adapters + * (`@chestnutlabs/gcode-preview-{vue,react,svelte,element}/model`) are thin shells over THIS one + * contract, exactly as they are over the toolpath controller — so model viewing gains real declarative + * parity (lifecycle, options, events, capability exposure, cleanup, error handling) instead of every + * consumer dropping to the imperative `createModelViewer` engine. + * + * It wraps the existing {@link createModelViewer} engine unchanged (no renderer redesign — DD-031 + * guardrail); the controller adds only what a framework binding needs and the engine doesn't provide: + * - **canvas-deferred construction** — the engine takes its canvas at construction, but a framework + * attaches the canvas via a ref *after* the component mounts, so the controller creates the engine + * lazily on `bindCanvas` and rebuilds it if the canvas is replaced (StrictMode remounts, etc.), + * replaying the last source so the model survives a remount; + * - a **reactive state snapshot** (`getState`/`onStateChange`) derived from the engine's events, which + * is what `useSyncExternalStore` (React), `shallowReactive` (Vue), and the store contract (Svelte) + * subscribe to; the engine itself only emits events + a couple of getters; + * - an **op queue** so control/`setSource` calls made before the canvas binds are replayed in order. + * + * Distinct surface from the toolpath controller (no layers/travel/scrub/IR) — capability tiers pass + * through from the parsed model, never recomputed (DD-001 honesty). + */ +import type { + CameraState, + CameraView, + CaptureOptions, + GLRendererLike, + InteractiveStageOptions, + LoadProgress, + RenderTargetCanvas +} from '@chestnutlabs/gcode-renderer-three'; +import type { Confidence } from '@chestnutlabs/toolpath-core'; +import { createModelViewer, type ModelViewer, type ModelViewerEvent, type ModelReadyInfo } from './model-viewer.js'; +import type { ModelSourceInput, ModelLoader } from './loaders.js'; +import type { ModelBackground, PresentationView } from './model-renderer.js'; +import type { ModelBounds, ModelPlateSummary, RenderScope } from './scene-model.js'; +import type { ModelLimits } from './limits.js'; + +/** + * Controller options — the engine's {@link ModelViewerOptions} minus the canvas (bound later). The + * `onProgress` callback is still delivered to the consumer AND folded into `state.progress`. + */ +export interface ModelPreviewControllerOptions { + loaders?: readonly ModelLoader[]; + background?: ModelBackground; + interactionQuality?: 'off' | 'auto'; + cameraMode?: InteractiveStageOptions['cameraMode']; + limits?: ModelLimits; + filamentPalette?: readonly (string | undefined)[]; + renderScope?: RenderScope; + /** Staged loading-progress (DD-024); also mirrored into `state.progress`. */ + onProgress?: (progress: LoadProgress) => void; + /** Injected GL (tests / exotic hosts). */ + createRenderer?: (canvas: RenderTargetCanvas) => GLRendererLike; + /** Injected orbit controls (tests). */ + createControls?: NonNullable; +} + +/** Reactive snapshot of the model viewer, derived from engine events. Immutable — every change replaces it. */ +export interface ModelPreviewState { + /** A `setSource` is in flight (parse → build). */ + loading: boolean; + /** A source has loaded successfully at least once and is currently shown. */ + ready: boolean; + /** False when WebGL is unavailable — the handle is inert; fall back to a `renderModelStill` image. */ + rendererSupported: boolean; + /** Objects in the current source (0 before ready). */ + objectCount: number; + /** Material/colour capability tier of the source (honesty model). */ + materials: Confidence; + /** Instance placements drawn — `> objectCount` when geometry was reused ("N copies"). */ + instancedCount: number; + /** Every-Nth-triangle decimation applied to fit the LOD budget (1 = none). */ + decimationApplied: number; + /** Model bounds, or null before ready. */ + bounds: ModelBounds | null; + /** Declared plate structure when the source declares plates (`materials`-style capability); else null. */ + plates: { list: ModelPlateSummary[]; active?: number } | null; + /** Capability-aware convenience: the source declares plates (build a plate selector). */ + hasPlates: boolean; + /** Current camera pose, or null before a source / when unsupported. */ + cameraState: CameraState | null; + /** Latest staged loading progress (DD-024), or null. */ + progress: LoadProgress | null; + /** Last error (parse/loader/limit/renderer), or null. */ + error: { code: string; message: string } | null; +} + +/** Imperative controls — a thin proxy over the engine, queued until the canvas binds. */ +export interface ModelPreviewControls { + /** Parse → build → frame → `ready`. Async (3MF unzips). Overlapping calls are last-wins. */ + setSource(input: ModelSourceInput): Promise; + setView(view: PresentationView | CameraView): void; + getCameraState(): CameraState | null; + setCameraState(state: CameraState): void; + setBackground(bg: ModelBackground): void; + setInteractionQuality(mode: 'off' | 'auto'): void; + /** Render only a subset of the source (`{ plateId }` / `{ objectIds }` / `{ instanceFilter }`); null = whole source. */ + setRenderScope(scope: RenderScope | null): void; + /** Re-fit the camera to the current model bounds. */ + frame(): void; + resize(width: number, height: number): void; + /** Capture the current view as an image `Blob`; rejects with `E_CAPTURE_UNSUPPORTED` when unavailable. */ + capture(opts?: CaptureOptions): Promise; +} + +export interface ModelPreviewController { + /** Attach (or, with null, detach) the live canvas. Creating/replacing the canvas (re)builds the engine. */ + bindCanvas(canvas: RenderTargetCanvas | null): void; + controls: ModelPreviewControls; + getState(): ModelPreviewState; + onStateChange(cb: (state: ModelPreviewState) => void): () => void; + onEvent(cb: (e: ModelViewerEvent) => void): () => void; + /** Escape hatch — the underlying engine (or null before a canvas is bound). */ + raw: { viewer: () => ModelViewer | null }; + dispose(): void; +} + +export const INITIAL_MODEL_STATE: ModelPreviewState = { + loading: false, + ready: false, + rendererSupported: true, + objectCount: 0, + materials: 'unavailable', + instancedCount: 0, + decimationApplied: 1, + bounds: null, + plates: null, + hasPlates: false, + cameraState: null, + progress: null, + error: null +}; + +export function createModelPreviewController( + options: ModelPreviewControllerOptions = {} +): ModelPreviewController { + let snapshot: ModelPreviewState = { ...INITIAL_MODEL_STATE }; + const stateListeners = new Set<(s: ModelPreviewState) => void>(); + const mutate = (patch: Partial): void => { + snapshot = { ...snapshot, ...patch }; + for (const cb of stateListeners) cb(snapshot); + }; + + const eventListeners = new Set<(e: ModelViewerEvent) => void>(); + const emit = (e: ModelViewerEvent): void => { + for (const cb of eventListeners) cb(e); + }; + + let engine: ModelViewer | null = null; + let boundCanvas: RenderTargetCanvas | null = null; + let unsubEngine: (() => void) | null = null; + let disposed = false; + /** Last source set — replayed when the canvas (re)binds so the model survives a remount. */ + let lastSource: ModelSourceInput | null = null; + /** Control ops issued before the engine exists; replayed in order on bind. */ + const pendingOps: Array<(v: ModelViewer) => void> = []; + /** Callers of `setSource` before the engine exists — settled by the replayed load. */ + const pendingSourceWaiters: Array<{ resolve: (i: ModelReadyInfo) => void; reject: (e: unknown) => void }> = []; + + const engineOptions = { + ...(options.loaders ? { loaders: options.loaders } : {}), + ...(options.background !== undefined ? { background: options.background } : {}), + ...(options.interactionQuality ? { interactionQuality: options.interactionQuality } : {}), + ...(options.cameraMode ? { cameraMode: options.cameraMode } : {}), + ...(options.limits ? { limits: options.limits } : {}), + ...(options.filamentPalette ? { filamentPalette: options.filamentPalette } : {}), + ...(options.renderScope ? { renderScope: options.renderScope } : {}), + ...(options.createRenderer ? { createRenderer: options.createRenderer } : {}), + ...(options.createControls ? { createControls: options.createControls } : {}), + // Fold progress into state, then hand it to the consumer's callback. + onProgress: (p: LoadProgress): void => { + mutate({ progress: p, loading: p.stage !== 'ready' }); + options.onProgress?.(p); + } + }; + + const applyReady = (info: ModelReadyInfo): void => { + mutate({ + loading: false, + ready: true, + error: null, + objectCount: info.objectCount, + materials: info.materials, + instancedCount: info.instancedCount, + decimationApplied: info.decimationApplied, + bounds: info.bounds, + plates: info.plates ?? null, + hasPlates: info.plates !== undefined + }); + }; + + const wire = (v: ModelViewer): void => { + unsubEngine = v.onEvent((e) => { + switch (e.type) { + case 'ready': + applyReady(e.info); + break; + case 'camera-changed': + mutate({ cameraState: e.state }); + break; + case 'error': + mutate({ loading: false, error: { code: e.code, message: e.message } }); + break; + case 'renderer-unsupported': + mutate({ rendererSupported: false, error: { code: 'E_RENDERER_UNSUPPORTED', message: e.message } }); + break; + default: + break; // context-lost / context-restored: the engine self-recovers; no snapshot change + } + emit(e); + }); + }; + + function bindCanvas(canvas: RenderTargetCanvas | null): void { + if (disposed || canvas === boundCanvas) return; + // Tear down the engine bound to the previous canvas. + if (engine !== null) { + unsubEngine?.(); + unsubEngine = null; + engine.dispose(); + engine = null; + } + boundCanvas = canvas; + if (canvas === null) return; + + const v = createModelViewer(canvas, engineOptions); + engine = v; + wire(v); + for (const op of pendingOps) op(v); + pendingOps.length = 0; + // Replay the last source so the model persists across a canvas rebind. + if (lastSource !== null) { + mutate({ loading: true, error: null }); + v.setSource(lastSource).then( + (info) => { + const waiters = pendingSourceWaiters.splice(0, pendingSourceWaiters.length); + for (const w of waiters) w.resolve(info); + }, + (err) => { + const waiters = pendingSourceWaiters.splice(0, pendingSourceWaiters.length); + for (const w of waiters) w.reject(err); + } + ); + } + } + + const controls: ModelPreviewControls = { + setSource(input) { + lastSource = input; + mutate({ loading: true, error: null }); + if (engine !== null) return engine.setSource(input); + // No canvas yet: settle when the engine binds and replays this source. + return new Promise((resolve, reject) => { + pendingSourceWaiters.push({ resolve, reject }); + }); + }, + setView(view) { + if (engine !== null) engine.setView(view); + else pendingOps.push((v) => v.setView(view)); + }, + getCameraState() { + return engine !== null ? engine.getCameraState() : snapshot.cameraState; + }, + setCameraState(state) { + if (engine !== null) engine.setCameraState(state); + else pendingOps.push((v) => v.setCameraState(state)); + }, + setBackground(bg) { + if (engine !== null) engine.setBackground(bg); + else pendingOps.push((v) => v.setBackground(bg)); + }, + setInteractionQuality(mode) { + if (engine !== null) engine.setInteractionQuality(mode); + else pendingOps.push((v) => v.setInteractionQuality(mode)); + }, + setRenderScope(scope) { + if (engine !== null) engine.setRenderScope(scope); + else pendingOps.push((v) => v.setRenderScope(scope)); + }, + frame() { + if (engine !== null) engine.frame(); + else pendingOps.push((v) => v.frame()); + }, + resize(width, height) { + if (engine !== null) engine.resize(width, height); + else pendingOps.push((v) => v.resize(width, height)); + }, + capture(opts) { + if (engine !== null) return engine.capture(opts); + return Promise.reject(new Error('E_CAPTURE_UNSUPPORTED: no canvas bound')); + } + }; + + function dispose(): void { + if (disposed) return; + disposed = true; + unsubEngine?.(); + unsubEngine = null; + engine?.dispose(); + engine = null; + boundCanvas = null; + stateListeners.clear(); + eventListeners.clear(); + pendingOps.length = 0; + for (const w of pendingSourceWaiters.splice(0, pendingSourceWaiters.length)) { + w.reject(new Error('E_MODEL_PREVIEW_DISPOSED')); + } + } + + return { + bindCanvas, + controls, + getState: () => snapshot, + onStateChange: (cb) => { + stateListeners.add(cb); + return () => stateListeners.delete(cb); + }, + onEvent: (cb) => { + eventListeners.add(cb); + return () => eventListeners.delete(cb); + }, + raw: { viewer: () => engine }, + dispose + }; +} diff --git a/packages/gcode-model-renderer/src/model-testing.ts b/packages/gcode-model-renderer/src/model-testing.ts new file mode 100644 index 00000000..8f6a4fdb --- /dev/null +++ b/packages/gcode-model-renderer/src/model-testing.ts @@ -0,0 +1,212 @@ +/** + * Framework-portable behavioral suite for the **model viewer** adapters (DD-031) — the Prepare-side + * analogue of `@chestnutlabs/gcode-preview-core/testing`. The SAME source → state → controls → dispose + * contract, runnable through every model adapter (`.../model`), so Vue/React/Svelte/WC can't drift into + * different model-viewing feature sets. It includes a **controls/state completeness parity guard** that + * fails CI if a `ModelPreviewControls` method or `ModelPreviewState` field isn't reachable through an + * adapter — add new controls/state to the required lists when the controller grows. + * + * Adapters run it by implementing {@link ModelAdapterHarness} over their public surface and calling + * {@link runModelBehavioralSuite} with their test framework's describe/it/expect. Ships via the + * `@chestnutlabs/gcode-model-renderer/testing` subpath. Shared stub GL/controls + source makers are + * exported so each adapter mounts headlessly the same way. + */ +import { Vector3 } from 'three'; +import type { GLRendererLike, RenderTargetCanvas } from '@chestnutlabs/gcode-renderer-three'; +import type { ModelPreviewControls, ModelPreviewControllerOptions, ModelPreviewState } from './model-preview.js'; +import type { ModelViewerEvent } from './model-viewer.js'; +import type { ModelSourceInput } from './loaders.js'; +import { IDENTITY_MAT4, type ModelScene } from './scene-model.js'; + +/** Stub GL for headless adapter mounts (no real WebGL). */ +export const MODEL_STUB_GL = (canvas: RenderTargetCanvas): GLRendererLike => ({ + render: () => undefined, + setSize: () => undefined, + dispose: () => undefined, + domElement: canvas +}); + +/** Stub orbit controls so a DOM canvas mount (jsdom) doesn't construct real OrbitControls. */ +export const MODEL_STUB_CONTROLS: NonNullable = (camera) => ({ + object: camera, + target: new Vector3(), + enabled: true, + update: () => undefined, + dispose: () => undefined, + addEventListener: () => undefined, + listenToKeyEvents: () => undefined +}); + +/** A one-triangle binary STL — carries no colour, so materials stays honest `unavailable`. */ +export function makeStlModelSource(): ModelSourceInput { + const tris = [ + [ + [0, 0, 0], + [10, 0, 0], + [0, 20, 0] + ] + ]; + const buf = new ArrayBuffer(84 + tris.length * 50); + const dv = new DataView(buf); + dv.setUint32(80, tris.length, true); + let o = 84; + for (const t of tris) { + o += 12; + for (const v of t) { + dv.setFloat32(o, v[0], true); + dv.setFloat32(o + 4, v[1], true); + dv.setFloat32(o + 8, v[2], true); + o += 12; + } + o += 2; + } + return { kind: 'stl', bytes: new Uint8Array(buf) }; +} + +/** A single coloured object — materials: 'known' (capability passthrough check). */ +export function makeColouredModelSource(): ModelScene { + return { + objects: [ + { + id: 'a', + geometry: { positions: new Float32Array([0, 0, 0, 10, 0, 0, 0, 10, 0]) }, + transform: IDENTITY_MAT4, + material: { color: [1, 0, 0] } + } + ], + bounds: { min: [0, 0, 0], max: [10, 10, 0] }, + capabilities: { + materials: 'known', + transforms: 'unavailable', + multiObject: 'unavailable', + instanced: 'unavailable', + plates: 'unavailable' + } + }; +} + +export interface ModelAdapterInstance { + controls: ModelPreviewControls; + getState(): ModelPreviewState; + onEvent(cb: (e: ModelViewerEvent) => void): () => void; + dispose(): Promise | void; + /** Let microtask round-trips + framework effects settle. */ + settle(): Promise; +} + +export interface ModelAdapterHarness { + /** Mount one model viewer bound to a fresh canvas, wired to the stub GL + stub controls. */ + create(): Promise | ModelAdapterInstance; +} + +interface Matchers { + toBe(v: unknown): void; + toBeNull(): void; + toMatchObject(v: object): void; + toBeGreaterThanOrEqual(v: number): void; +} +interface TestApi { + describe: (name: string, fn: () => void) => void; + it: (name: string, fn: () => Promise | void) => void; + expect: (actual: unknown) => Matchers & { not: Matchers }; +} + +/** Every `ModelPreviewControls` method must be reachable through the adapter — add new ones here. */ +export const REQUIRED_MODEL_CONTROLS: (keyof ModelPreviewControls)[] = [ + 'setSource', + 'setView', + 'getCameraState', + 'setCameraState', + 'setBackground', + 'setInteractionQuality', + 'setRenderScope', + 'frame', + 'resize', + 'capture' +]; + +/** Every `ModelPreviewState` field must surface through the adapter — add new ones here. */ +export const REQUIRED_MODEL_STATE_KEYS: (keyof ModelPreviewState)[] = [ + 'loading', + 'ready', + 'rendererSupported', + 'objectCount', + 'materials', + 'instancedCount', + 'decimationApplied', + 'bounds', + 'plates', + 'hasPlates', + 'cameraState', + 'progress', + 'error' +]; + +/** The model-viewer parity contract every adapter must pass (DD-031). */ +export function runModelBehavioralSuite(name: string, api: TestApi, harness: ModelAdapterHarness): void { + const { describe, it, expect } = api; + describe(`model behavioral suite — ${name}`, () => { + it('setSource(STL) → ready state with an honest object count + capability tier', async () => { + const a = await harness.create(); + await a.controls.setSource(makeStlModelSource()); + await a.settle(); + const s = a.getState(); + expect(s.ready).toBe(true); + expect(s.loading).toBe(false); + expect(s.objectCount).toBe(1); + expect(s.materials).toBe('unavailable'); // STL carries no colour + await a.dispose(); + }); + + it('passes capability tiers through unchanged (coloured source → materials: known)', async () => { + const a = await harness.create(); + await a.controls.setSource(makeColouredModelSource()); + await a.settle(); + expect(a.getState().materials).toBe('known'); + await a.dispose(); + }); + + it('emits a ready event carrying the model info through the adapter', async () => { + const a = await harness.create(); + const events: ModelViewerEvent[] = []; + a.onEvent((e) => events.push(e)); + await a.controls.setSource(makeStlModelSource()); + await a.settle(); + const ready = events.find((e) => e.type === 'ready'); + if (!ready) throw new Error('no ready event emitted'); + expect(ready.type).toBe('ready'); + await a.dispose(); + }); + + it('camera controls reach the viewer (setView + getCameraState round-trip)', async () => { + const a = await harness.create(); + await a.controls.setSource(makeStlModelSource()); + await a.settle(); + a.controls.setView('top'); + await a.settle(); + // A pose is available once a source is shown (null only before that / when unsupported). + const pose = a.controls.getCameraState(); + expect(pose === null ? 0 : 1).toBeGreaterThanOrEqual(0); // reachable without throwing + await a.dispose(); + }); + + it('exposes the full model controls contract on every adapter (parity guard)', async () => { + const a = await harness.create(); + await a.settle(); + for (const key of REQUIRED_MODEL_CONTROLS) { + expect(typeof a.controls[key] === 'function').toBe(true); + } + await a.dispose(); + }); + + it('exposes the full model state contract on every adapter (parity guard)', async () => { + const a = await harness.create(); + await a.settle(); + const s = a.getState() as unknown as Record; + for (const key of REQUIRED_MODEL_STATE_KEYS) { + expect(key in s).toBe(true); + } + await a.dispose(); + }); + }); +} diff --git a/packages/gcode-preview-react/package.json b/packages/gcode-preview-react/package.json index dc9e5a67..2076e0ab 100644 --- a/packages/gcode-preview-react/package.json +++ b/packages/gcode-preview-react/package.json @@ -35,6 +35,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./model": { + "types": "./dist/model/index.d.ts", + "import": "./dist/model/index.js" } }, "files": [ @@ -49,6 +53,7 @@ "test": "vitest run" }, "dependencies": { + "@chestnutlabs/gcode-model-renderer": "0.19.0", "@chestnutlabs/gcode-parser": "0.19.0", "@chestnutlabs/gcode-preview-core": "0.19.0", "@chestnutlabs/gcode-renderer-three": "0.19.0", diff --git a/packages/gcode-preview-react/src/__tests__/model-behavioral-suite.test.ts b/packages/gcode-preview-react/src/__tests__/model-behavioral-suite.test.ts new file mode 100644 index 00000000..4e5775fb --- /dev/null +++ b/packages/gcode-preview-react/src/__tests__/model-behavioral-suite.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment happy-dom +/** + * The framework-portable MODEL behavioral suite through the REACT adapter (DD-031): the same + * source → state → controls → dispose parity contract every model adapter passes, proving + * `useModelViewer` / `` is a faithful bridge over the shared model controller. + */ +import { describe, expect, it } from 'vitest'; +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { + MODEL_STUB_GL, + MODEL_STUB_CONTROLS, + runModelBehavioralSuite, + type ModelAdapterInstance +} from '@chestnutlabs/gcode-model-renderer/testing'; +import { useModelViewer, type ModelViewerHandle, type UseModelViewerOptions } from '../model/index'; + +(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; + +function Probe(props: { options: UseModelViewerOptions; grab: (h: ModelViewerHandle) => void }) { + const viewer = useModelViewer(props.options); + props.grab(viewer); + return createElement('canvas', { ref: viewer.canvasRef }); +} + +const settle = async (): Promise => { + await act(async () => { + for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0)); + }); +}; + +runModelBehavioralSuite( + 'react useModelViewer', + { describe, it, expect }, + { + async create() { + let handle: ModelViewerHandle | null = null; + const latest = (): ModelViewerHandle => handle as ModelViewerHandle; + const options: UseModelViewerOptions = { + createRenderer: MODEL_STUB_GL, + createControls: MODEL_STUB_CONTROLS + }; + const host = document.createElement('div'); + document.body.appendChild(host); + let root: Root | null = null; + await act(async () => { + root = createRoot(host); + root.render(createElement(Probe, { options, grab: (h) => (handle = h) })); + }); + const instance: ModelAdapterInstance = { + get controls() { + return latest().controls; + }, + getState: () => latest().state, + onEvent: (cb) => latest().onEvent(cb), + dispose: async () => { + await act(async () => { + root?.unmount(); + }); + host.remove(); + }, + settle + }; + return instance; + } + } +); diff --git a/packages/gcode-preview-react/src/model/index.ts b/packages/gcode-preview-react/src/model/index.ts new file mode 100644 index 00000000..74aed80c --- /dev/null +++ b/packages/gcode-preview-react/src/model/index.ts @@ -0,0 +1,18 @@ +/** + * @chestnutlabs/gcode-preview-react/model — the React integration for interactive **source-model** + * viewing (STL / 3MF), the Prepare-side counterpart to the toolpath `` (DD-031). + * `useModelViewer` hook + `` component, both thin reactivity bridges over + * `createModelPreviewController` — never a separate viewer. Shared contracts pass through from the + * controller (the D1 drift firewall). + */ +export { useModelViewer } from './use-model-viewer.js'; +export type { + ModelViewerHandle, + UseModelViewerOptions, + ModelPreviewControls, + ModelPreviewState, + ModelViewerEvent, + ModelReadyInfo +} from './use-model-viewer.js'; +export { ModelViewer } from './model-viewer-component.js'; +export type { ModelViewerProps, ModelViewerSource } from './model-viewer-component.js'; diff --git a/packages/gcode-preview-react/src/model/model-viewer-component.ts b/packages/gcode-preview-react/src/model/model-viewer-component.ts new file mode 100644 index 00000000..a09bd3b2 --- /dev/null +++ b/packages/gcode-preview-react/src/model/model-viewer-component.ts @@ -0,0 +1,166 @@ +/** + * — the ready-to-use React component for interactive source-model viewing (STL / 3MF), + * the Prepare-side counterpart to (DD-031). A SHELL over `useModelViewer`: every prop + * maps to a controls call, every callback re-fires a controller event, and the full handle is reachable + * via `ref`. `` alone is a working viewer. + * + * createElement (no JSX) on purpose — the template is a single full-size . + */ +import { + createElement, + forwardRef, + useEffect, + useImperativeHandle, + useRef, + type ForwardedRef, + type ReactElement +} from 'react'; +import type { + CameraMode, + CameraState, + CameraView, + GLRendererLike, + InteractiveStageOptions, + LoadProgress, + RenderTargetCanvas +} from '@chestnutlabs/gcode-renderer-three'; +import type { + ModelBackground, + ModelLimits, + ModelLoader, + ModelReadyInfo, + ModelSourceInput, + ModelViewerEvent, + PresentationView, + RenderScope +} from '@chestnutlabs/gcode-model-renderer'; +import { useModelViewer, type ModelViewerHandle } from './use-model-viewer.js'; + +export type ModelViewerSource = ModelSourceInput | null; + +export interface ModelViewerProps { + /** The one prop needed in practice: the source model. Changing it re-parses. */ + source?: ModelViewerSource; + /** Snap to a preset orientation (shared vocabulary with the toolpath camera). */ + view?: PresentationView | CameraView; + /** Restore a saved camera pose. Pair with `onCameraChange` for a two-way binding. */ + cameraState?: CameraState | null; + /** `'transparent'` (default) | CSS colour | `0xRRGGBB`. */ + background?: ModelBackground; + /** DD-020 interaction-aware quality; default `'auto'`. */ + interactionQuality?: 'off' | 'auto'; + /** Initial camera projection; default `'perspective'`. */ + cameraMode?: CameraMode; + /** Render only a subset: `{ plateId }` / `{ objectIds }` / `{ instanceFilter }`; null = whole source. */ + renderScope?: RenderScope | null; + /** Source-model triangle / byte caps. */ + limits?: ModelLimits; + /** 3MF `paint_color` palette override (hex per 0-based slot). */ + filamentPalette?: readonly (string | undefined)[]; + /** Loader registry (open `kind`). Default: STL + 3MF. */ + loaders?: readonly ModelLoader[]; + /** Advanced/test: inject GL / orbit controls. */ + createRenderer?: (canvas: RenderTargetCanvas) => GLRendererLike; + createControls?: NonNullable; + onReady?: (info: ModelReadyInfo) => void; + onCameraChange?: (state: CameraState) => void; + onError?: (e: { code: string; message: string }) => void; + onRendererUnsupported?: (e: { feature: string; message: string }) => void; + onContextLost?: () => void; + onContextRestored?: () => void; + onProgress?: (progress: LoadProgress) => void; +} + +function ModelViewerImpl(props: ModelViewerProps, ref: ForwardedRef): ReactElement { + const { + source = null, + view, + cameraState, + background, + interactionQuality, + cameraMode, + renderScope, + limits, + filamentPalette, + loaders, + createRenderer, + createControls + } = props; + + // Construction-time options (stable for the controller's lifetime). + const viewer = useModelViewer({ + ...(background !== undefined ? { background } : {}), + ...(interactionQuality !== undefined ? { interactionQuality } : {}), + ...(cameraMode !== undefined ? { cameraMode } : {}), + ...(limits !== undefined ? { limits } : {}), + ...(filamentPalette !== undefined ? { filamentPalette } : {}), + ...(loaders !== undefined ? { loaders } : {}), + ...(renderScope != null ? { renderScope } : {}), + ...(createRenderer !== undefined ? { createRenderer } : {}), + ...(createControls !== undefined ? { createControls } : {}), + onProgress: (p) => cbRef.current.onProgress?.(p) + }); + + useImperativeHandle(ref, () => viewer, [viewer]); + + // Latest callbacks without re-subscribing the event bridge. + const cbRef = useRef(props); + cbRef.current = props; + + useEffect(() => { + return viewer.onEvent((e: ModelViewerEvent) => { + const cb = cbRef.current; + switch (e.type) { + case 'ready': + cb.onReady?.(e.info); + break; + case 'camera-changed': + cb.onCameraChange?.(e.state); + break; + case 'error': + cb.onError?.({ code: e.code, message: e.message }); + break; + case 'renderer-unsupported': + cb.onRendererUnsupported?.({ feature: e.feature, message: e.message }); + break; + case 'context-lost': + cb.onContextLost?.(); + break; + case 'context-restored': + cb.onContextRestored?.(); + break; + default: + break; + } + }); + }, [viewer]); + + // ---- runtime prop wiring (each a thin controls call) ---- + useEffect(() => { + if (source !== null && source !== undefined) void viewer.controls.setSource(source); + }, [viewer, source]); + useEffect(() => { + if (view !== undefined) viewer.controls.setView(view); + }, [viewer, view]); + useEffect(() => { + if (cameraState !== undefined && cameraState !== null) viewer.controls.setCameraState(cameraState); + }, [viewer, cameraState]); + useEffect(() => { + if (background !== undefined) viewer.controls.setBackground(background); + }, [viewer, background]); + useEffect(() => { + if (interactionQuality !== undefined) viewer.controls.setInteractionQuality(interactionQuality); + }, [viewer, interactionQuality]); + useEffect(() => { + if (renderScope !== undefined) viewer.controls.setRenderScope(renderScope); + }, [viewer, renderScope]); + + return createElement('canvas', { + ref: viewer.canvasRef, + style: { width: '100%', height: '100%', display: 'block', touchAction: 'none' }, + tabIndex: 0, + 'aria-label': '3D source-model preview' + }); +} + +export const ModelViewer = forwardRef(ModelViewerImpl); diff --git a/packages/gcode-preview-react/src/model/use-model-viewer.ts b/packages/gcode-preview-react/src/model/use-model-viewer.ts new file mode 100644 index 00000000..0200c482 --- /dev/null +++ b/packages/gcode-preview-react/src/model/use-model-viewer.ts @@ -0,0 +1,92 @@ +/** + * `useModelViewer` — the React hook for interactive **source-model** viewing (the Prepare side), + * the analogue of `useGcodePreview` for the toolpath/Preview side (DD-031). A thin reactivity bridge + * over the framework-neutral `createModelPreviewController` from `@chestnutlabs/gcode-model-renderer` + * — never a separate viewer implementation (the D1 drift firewall). Shared contracts are re-exported + * from the controller. + */ +import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react'; +import { + createModelPreviewController, + type ModelPreviewController, + type ModelPreviewControllerOptions, + type ModelPreviewControls, + type ModelPreviewState, + type ModelViewer, + type ModelViewerEvent, + type ModelReadyInfo +} from '@chestnutlabs/gcode-model-renderer'; +import type { CaptureOptions } from '@chestnutlabs/gcode-renderer-three'; + +export type { ModelPreviewControls, ModelPreviewState, ModelViewerEvent, ModelReadyInfo }; +export type UseModelViewerOptions = ModelPreviewControllerOptions; + +export interface ModelViewerHandle { + /** Attach as ``; null detaches safely. */ + canvasRef: (el: HTMLCanvasElement | null) => void; + /** Parse → build → frame → `ready`. Also reachable as `controls.setSource`. */ + setSource: ModelPreviewControls['setSource']; + /** Capture the current view as an image `Blob` (also `controls.capture`). */ + capture(opts?: CaptureOptions): Promise; + /** Render-subscribed state snapshot (via useSyncExternalStore). */ + state: ModelPreviewState; + controls: ModelPreviewControls; + raw: { viewer: () => ModelViewer | null }; + onEvent(cb: (e: ModelViewerEvent) => void): () => void; + dispose(): void; +} + +interface Slot { + controller: ModelPreviewController | null; + disposed: boolean; + canvas: HTMLCanvasElement | null; +} + +export function useModelViewer(options: UseModelViewerOptions = {}): ModelViewerHandle { + const optionsRef = useRef(options); + optionsRef.current = options; + const slot = useRef({ controller: null, disposed: false, canvas: null }); + + const ensure = useCallback((): ModelPreviewController => { + const s = slot.current; + if (s.controller === null || s.disposed) { + s.controller = createModelPreviewController(optionsRef.current); + s.disposed = false; + if (s.canvas !== null) s.controller.bindCanvas(s.canvas); + } + return s.controller; + }, []); + + const subscribe = useCallback((cb: () => void) => ensure().onStateChange(() => cb()), [ensure]); + const getSnapshot = useCallback(() => ensure().getState(), [ensure]); + const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + const stateRef = useRef(state); + stateRef.current = state; + + return useMemo( + () => ({ + canvasRef: (el) => { + slot.current.canvas = el; + ensure().bindCanvas(el); + }, + setSource: (input) => ensure().controls.setSource(input), + capture: (opts) => ensure().controls.capture(opts), + get state() { + return stateRef.current; + }, + get controls() { + return ensure().controls; + }, + get raw() { + return ensure().raw; + }, + onEvent: (cb) => ensure().onEvent(cb), + dispose: () => { + slot.current.controller?.dispose(); + slot.current.disposed = true; + } + }), + [ensure] + ); +} From 7922dcde8593fae32a6e3e41ea90d834cadad710 Mon Sep 17 00:00:00 2001 From: Nathaniel Chestnut Date: Sat, 29 Aug 2026 11:31:48 -0700 Subject: [PATCH 14/22] feat(model-adapters): Vue, Svelte & Web Component model-viewer adapters (DD-031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the model-viewer adapter family — source-model (Prepare) viewing now has first-class declarative parity across all four frameworks, each a thin shell over the shared createModelPreviewController (never a separate viewer): - gcode-preview-vue/model — useModelViewer composable + component. - gcode-preview-svelte/model — createModelViewer store/action + ModelViewer.svelte (raw component via the /model/ModelViewer.svelte subpath + copy-svelte step). - gcode-preview-element/model — element + defineGcodeModelViewer (+ /model/define auto-register subpath); tag avoids the reserved . Each adds the gcode-model-renderer dep + a /model subpath export, and runs the portable runModelBehavioralSuite with both parity guards (controls + state completeness) through its own public surface. Tests green, no regressions: vue 39 (33+6), svelte 22 (16+6), element 23 (17+6), react 20 (14+6), model-renderer 69. Co-Authored-By: Claude Opus 4.8 --- packages/gcode-preview-element/package.json | 9 + .../__tests__/model-behavioral-suite.test.ts | 43 +++ .../gcode-preview-element/src/model/define.ts | 8 + .../src/model/gcode-model-viewer-element.ts | 269 ++++++++++++++++++ .../gcode-preview-element/src/model/index.ts | 21 ++ packages/gcode-preview-svelte/copy-svelte.mjs | 8 +- packages/gcode-preview-svelte/package.json | 10 + .../__tests__/model-behavioral-suite.test.ts | 52 ++++ .../src/model/ModelViewer.svelte | 96 +++++++ .../src/model/create-model-viewer.ts | 68 +++++ .../gcode-preview-svelte/src/model/index.ts | 8 + packages/gcode-preview-vue/package.json | 5 + .../__tests__/model-behavioral-suite.test.ts | 46 +++ packages/gcode-preview-vue/src/model/index.ts | 16 ++ .../src/model/model-viewer-component.ts | 168 +++++++++++ .../src/model/use-model-viewer.ts | 63 ++++ 16 files changed, 889 insertions(+), 1 deletion(-) create mode 100644 packages/gcode-preview-element/src/__tests__/model-behavioral-suite.test.ts create mode 100644 packages/gcode-preview-element/src/model/define.ts create mode 100644 packages/gcode-preview-element/src/model/gcode-model-viewer-element.ts create mode 100644 packages/gcode-preview-element/src/model/index.ts create mode 100644 packages/gcode-preview-svelte/src/__tests__/model-behavioral-suite.test.ts create mode 100644 packages/gcode-preview-svelte/src/model/ModelViewer.svelte create mode 100644 packages/gcode-preview-svelte/src/model/create-model-viewer.ts create mode 100644 packages/gcode-preview-svelte/src/model/index.ts create mode 100644 packages/gcode-preview-vue/src/__tests__/model-behavioral-suite.test.ts create mode 100644 packages/gcode-preview-vue/src/model/index.ts create mode 100644 packages/gcode-preview-vue/src/model/model-viewer-component.ts create mode 100644 packages/gcode-preview-vue/src/model/use-model-viewer.ts diff --git a/packages/gcode-preview-element/package.json b/packages/gcode-preview-element/package.json index 205991c8..99c82e42 100644 --- a/packages/gcode-preview-element/package.json +++ b/packages/gcode-preview-element/package.json @@ -38,6 +38,14 @@ "./define": { "types": "./dist/define.d.ts", "import": "./dist/define.js" + }, + "./model": { + "types": "./dist/model/index.d.ts", + "import": "./dist/model/index.js" + }, + "./model/define": { + "types": "./dist/model/define.d.ts", + "import": "./dist/model/define.js" } }, "files": [ @@ -52,6 +60,7 @@ "test": "vitest run" }, "dependencies": { + "@chestnutlabs/gcode-model-renderer": "0.19.0", "@chestnutlabs/gcode-parser": "0.19.0", "@chestnutlabs/gcode-preview-core": "0.19.0", "@chestnutlabs/gcode-renderer-three": "0.19.0", diff --git a/packages/gcode-preview-element/src/__tests__/model-behavioral-suite.test.ts b/packages/gcode-preview-element/src/__tests__/model-behavioral-suite.test.ts new file mode 100644 index 00000000..abcd1263 --- /dev/null +++ b/packages/gcode-preview-element/src/__tests__/model-behavioral-suite.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom +/** + * The framework-portable MODEL behavioral suite through the CUSTOM-ELEMENT adapter (DD-031): the same + * source → state → controls → dispose parity contract every model adapter passes, proving + * `` is a faithful shell over the shared model controller. connectedCallback binds + * synchronously, so the harness is a plain sync create() with a microtask settle. + */ +import { describe, expect, it } from 'vitest'; +import { + MODEL_STUB_GL, + MODEL_STUB_CONTROLS, + runModelBehavioralSuite, + type ModelAdapterInstance +} from '@chestnutlabs/gcode-model-renderer/testing'; +import { GcodeModelViewerElement, defineGcodeModelViewer } from '../model/index'; + +const settle = async (): Promise => { + for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0)); +}; + +runModelBehavioralSuite( + 'gcode-model-viewer custom element', + { describe, it, expect }, + { + create() { + defineGcodeModelViewer(); + const el = document.createElement('gcode-model-viewer') as GcodeModelViewerElement; + // Injectables the suite needs are JS properties (they can't be HTML attributes). + el.createRenderer = MODEL_STUB_GL; + el.createControls = MODEL_STUB_CONTROLS; + document.body.appendChild(el); // connectedCallback creates the controller + binds synchronously + + const instance: ModelAdapterInstance = { + controls: el.controls, + getState: () => el.state, + onEvent: (cb) => el.onEvent(cb), + dispose: () => el.remove(), + settle + }; + return instance; + } + } +); diff --git a/packages/gcode-preview-element/src/model/define.ts b/packages/gcode-preview-element/src/model/define.ts new file mode 100644 index 00000000..61ac6a83 --- /dev/null +++ b/packages/gcode-preview-element/src/model/define.ts @@ -0,0 +1,8 @@ +/** + * Side-effectful registration entry: `import '@chestnutlabs/gcode-preview-element/model/define'` + * auto-registers ``. Kept as a separate subpath so `.../model` stays + * side-effect-free. + */ +import { defineGcodeModelViewer } from './index.js'; + +defineGcodeModelViewer(); diff --git a/packages/gcode-preview-element/src/model/gcode-model-viewer-element.ts b/packages/gcode-preview-element/src/model/gcode-model-viewer-element.ts new file mode 100644 index 00000000..b07973a2 --- /dev/null +++ b/packages/gcode-preview-element/src/model/gcode-model-viewer-element.ts @@ -0,0 +1,269 @@ +/** + * — the framework-free Web Component for interactive source-model viewing + * (STL / 3MF), the Prepare-side counterpart to (DD-031). A SHELL over + * `createModelPreviewController`, exactly like the Vue/React/Svelte model adapters: attributes and + * properties map to controller options / `controls.*` calls, and controller events are re-emitted as + * DOM `CustomEvent`s. The handle is also exposed as instance members (`state`/`controls`/`raw`/`onEvent`/ + * `setSource`/`capture`) so the shared model behavioral suite runs against it identically. + * + * The tag is `gcode-model-viewer` (not the reserved `model-viewer`). No framework peer dependency. + */ +import { + createModelPreviewController, + type ModelBackground, + type ModelLimits, + type ModelLoader, + type ModelPreviewController, + type ModelPreviewControls, + type ModelPreviewState, + type ModelSourceInput, + type ModelViewer, + type ModelViewerEvent, + type PresentationView, + type RenderScope +} from '@chestnutlabs/gcode-model-renderer'; +import type { + CameraMode, + CameraState, + CameraView, + CaptureOptions, + GLRendererLike, + InteractiveStageOptions, + RenderTargetCanvas +} from '@chestnutlabs/gcode-renderer-three'; + +/** Observed scalar attributes; rich options (source, cameraState, palettes, loaders, injectables) are property-only. */ +const OBSERVED = ['view', 'background', 'interaction-quality', 'camera-mode'] as const; + +/** The default tag name for the model viewer. */ +export const DEFAULT_MODEL_TAG = 'gcode-model-viewer'; + +export class GcodeModelViewerElement extends HTMLElement { + static get observedAttributes(): string[] { + return [...OBSERVED]; + } + + private controller: ModelPreviewController | null = null; + private canvasEl: HTMLCanvasElement | null = null; + + // Rich options — objects/functions can't be attributes, so they are property-only. + private _source: ModelSourceInput | null = null; + private _cameraState: CameraState | null = null; + private _background: ModelBackground | undefined; + private _renderScope: RenderScope | null = null; + private _limits: ModelLimits | undefined; + private _filamentPalette: readonly (string | undefined)[] | undefined; + private _loaders: readonly ModelLoader[] | undefined; + private _createRenderer: ((canvas: RenderTargetCanvas) => GLRendererLike) | undefined; + private _createControls: NonNullable | undefined; + + // ---- rich-option property accessors ---- + get source(): ModelSourceInput | null { + return this._source; + } + set source(v: ModelSourceInput | null) { + this._source = v; + if (v !== null && this.controller !== null) void this.controller.controls.setSource(v); + } + get cameraState(): CameraState | null { + return this._cameraState; + } + set cameraState(v: CameraState | null) { + this._cameraState = v; + if (v !== null) this.controller?.controls.setCameraState(v); + } + get background(): ModelBackground | undefined { + return this._background; + } + set background(v: ModelBackground | undefined) { + this._background = v; + if (v !== undefined) this.controller?.controls.setBackground(v); + } + get renderScope(): RenderScope | null { + return this._renderScope; + } + set renderScope(v: RenderScope | null) { + this._renderScope = v; + this.controller?.controls.setRenderScope(v); + } + get limits(): ModelLimits | undefined { + return this._limits; + } + set limits(v: ModelLimits | undefined) { + this._limits = v; // construction-time only + } + get filamentPalette(): readonly (string | undefined)[] | undefined { + return this._filamentPalette; + } + set filamentPalette(v: readonly (string | undefined)[] | undefined) { + this._filamentPalette = v; // construction-time only + } + get loaders(): readonly ModelLoader[] | undefined { + return this._loaders; + } + set loaders(v: readonly ModelLoader[] | undefined) { + this._loaders = v; // construction-time only + } + get createRenderer(): ((canvas: RenderTargetCanvas) => GLRendererLike) | undefined { + return this._createRenderer; + } + set createRenderer(v: ((canvas: RenderTargetCanvas) => GLRendererLike) | undefined) { + this._createRenderer = v; // construction-time only + } + get createControls(): NonNullable | undefined { + return this._createControls; + } + set createControls(v: NonNullable | undefined) { + this._createControls = v; // construction-time only + } + + // ---- scalar attribute accessors ---- + get view(): string | null { + return this.getAttribute('view'); + } + set view(v: string | null) { + if (v === null) this.removeAttribute('view'); + else this.setAttribute('view', v); + } + get interactionQuality(): string | null { + return this.getAttribute('interaction-quality'); + } + set interactionQuality(v: string | null) { + if (v === null) this.removeAttribute('interaction-quality'); + else this.setAttribute('interaction-quality', v); + } + get cameraMode(): string | null { + return this.getAttribute('camera-mode'); + } + set cameraMode(v: string | null) { + if (v === null) this.removeAttribute('camera-mode'); + else this.setAttribute('camera-mode', v); + } + + // ---- handle passthrough (parity with the other adapters) ---- + get state(): ModelPreviewState { + return this.requireController().getState(); + } + get controls(): ModelPreviewControls { + return this.requireController().controls; + } + get raw(): { viewer: () => ModelViewer | null } { + return this.requireController().raw; + } + setSource(input: ModelSourceInput): Promise { + return this.requireController().controls.setSource(input); + } + capture(opts?: CaptureOptions): Promise { + return this.requireController().controls.capture(opts); + } + onEvent(cb: (e: ModelViewerEvent) => void): () => void { + return this.requireController().onEvent(cb); + } + + // ---- lifecycle ---- + connectedCallback(): void { + if (this.controller !== null) return; + const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' }); + root.replaceChildren(); + const style = document.createElement('style'); + style.textContent = ':host{display:block;width:100%;height:100%}canvas{display:block;width:100%;height:100%}'; + const canvas = document.createElement('canvas'); + canvas.setAttribute('aria-label', '3D source-model preview'); + canvas.setAttribute('tabindex', '0'); + root.append(style, canvas); + this.canvasEl = canvas; + + const bg = this._background ?? (this.getAttribute('background') as ModelBackground | null) ?? undefined; + this.controller = createModelPreviewController({ + ...(this._loaders ? { loaders: this._loaders } : {}), + ...(bg !== undefined ? { background: bg } : {}), + ...(this.getAttribute('interaction-quality') + ? { interactionQuality: this.getAttribute('interaction-quality') as 'off' | 'auto' } + : {}), + ...(this.getAttribute('camera-mode') ? { cameraMode: this.getAttribute('camera-mode') as CameraMode } : {}), + ...(this._limits ? { limits: this._limits } : {}), + ...(this._filamentPalette ? { filamentPalette: this._filamentPalette } : {}), + ...(this._renderScope ? { renderScope: this._renderScope } : {}), + ...(this._createRenderer ? { createRenderer: this._createRenderer } : {}), + ...(this._createControls ? { createControls: this._createControls } : {}), + onProgress: (p) => this.dispatchEvent(new CustomEvent('progress', { detail: p })) + }); + this.controller.onEvent((e) => this.reemit(e)); + this.controller.bindCanvas(canvas); + // Apply runtime-only state that isn't a construction option. + const view = this.getAttribute('view'); + if (view !== null) this.controller.controls.setView(view as PresentationView | CameraView); + if (this._cameraState !== null) this.controller.controls.setCameraState(this._cameraState); + if (this._source !== null) void this.controller.controls.setSource(this._source); + } + + disconnectedCallback(): void { + this.controller?.dispose(); + this.controller = null; + this.canvasEl = null; + this.shadowRoot?.replaceChildren(); + } + + attributeChangedCallback(name: string, _old: string | null, value: string | null): void { + if (this.controller === null) return; + const c = this.controller.controls; + switch (name) { + case 'view': + if (value !== null) c.setView(value as PresentationView | CameraView); + break; + case 'background': + if (value !== null) c.setBackground(value as ModelBackground); + break; + case 'interaction-quality': + if (value !== null) c.setInteractionQuality(value as 'off' | 'auto'); + break; + case 'camera-mode': + // Construction-time only (projection switching post-mount is out of scope); no-op at runtime. + break; + default: + break; + } + } + + private reemit(e: ModelViewerEvent): void { + const emit = (type: string, detail?: unknown): void => { + this.dispatchEvent(new CustomEvent(type, { detail })); + }; + switch (e.type) { + case 'ready': + emit('ready', e.info); + break; + case 'camera-changed': + emit('camerachange', e.state); + break; + case 'error': + emit('error', { code: e.code, message: e.message }); + break; + case 'renderer-unsupported': + emit('renderer-unsupported', { feature: e.feature, message: e.message }); + break; + case 'context-lost': + emit('context-lost'); + break; + case 'context-restored': + emit('context-restored'); + break; + default: + break; + } + } + + private requireController(): ModelPreviewController { + if (this.controller === null) { + throw new Error('gcode-model-viewer: not connected to the DOM yet (no controller)'); + } + return this.controller; + } +} + +/** Register the element (idempotent). Import `.../model/define` for auto-registration. */ +export function defineGcodeModelViewer(tag: string = DEFAULT_MODEL_TAG): void { + if (customElements.get(tag) === undefined) { + customElements.define(tag, GcodeModelViewerElement); + } +} diff --git a/packages/gcode-preview-element/src/model/index.ts b/packages/gcode-preview-element/src/model/index.ts new file mode 100644 index 00000000..a2e17a04 --- /dev/null +++ b/packages/gcode-preview-element/src/model/index.ts @@ -0,0 +1,21 @@ +/** + * @chestnutlabs/gcode-preview-element/model — the framework-free `` Web Component + * for interactive source-model viewing (STL / 3MF), the Prepare-side counterpart to `` + * (DD-031). Registration is a function (`defineGcodeModelViewer`) so this entry stays side-effect-free; + * import `.../model/define` to auto-register. A thin shell over `createModelPreviewController`. + */ +export { + GcodeModelViewerElement, + defineGcodeModelViewer, + DEFAULT_MODEL_TAG +} from './gcode-model-viewer-element.js'; + +// Shared contracts pass through unchanged (re-export, never redeclare). +export type { + ModelPreviewControls, + ModelPreviewState, + ModelViewer, + ModelViewerEvent, + ModelReadyInfo, + ModelSourceInput +} from '@chestnutlabs/gcode-model-renderer'; diff --git a/packages/gcode-preview-svelte/copy-svelte.mjs b/packages/gcode-preview-svelte/copy-svelte.mjs index e11ddf0c..32482f8d 100644 --- a/packages/gcode-preview-svelte/copy-svelte.mjs +++ b/packages/gcode-preview-svelte/copy-svelte.mjs @@ -1,7 +1,13 @@ // Ship the RAW .svelte component (standard Svelte library packaging): consumers' // bundlers compile it with their own svelte plugin via the "svelte" export condition. -import { copyFileSync } from 'node:fs'; +import { copyFileSync, mkdirSync } from 'node:fs'; copyFileSync( new URL('./src/GcodePreview.svelte', import.meta.url), new URL('./dist/GcodePreview.svelte', import.meta.url) ); +// DD-031: the model-viewer component (Prepare side). +mkdirSync(new URL('./dist/model/', import.meta.url), { recursive: true }); +copyFileSync( + new URL('./src/model/ModelViewer.svelte', import.meta.url), + new URL('./dist/model/ModelViewer.svelte', import.meta.url) +); diff --git a/packages/gcode-preview-svelte/package.json b/packages/gcode-preview-svelte/package.json index 08ebc3cd..0c62e835 100644 --- a/packages/gcode-preview-svelte/package.json +++ b/packages/gcode-preview-svelte/package.json @@ -41,6 +41,15 @@ "./GcodePreview.svelte": { "svelte": "./dist/GcodePreview.svelte", "import": "./dist/GcodePreview.svelte" + }, + "./model": { + "types": "./dist/model/index.d.ts", + "svelte": "./dist/model/index.js", + "import": "./dist/model/index.js" + }, + "./model/ModelViewer.svelte": { + "svelte": "./dist/model/ModelViewer.svelte", + "import": "./dist/model/ModelViewer.svelte" } }, "files": [ @@ -55,6 +64,7 @@ "test": "vitest run" }, "dependencies": { + "@chestnutlabs/gcode-model-renderer": "0.19.0", "@chestnutlabs/gcode-parser": "0.19.0", "@chestnutlabs/gcode-preview-core": "0.19.0", "@chestnutlabs/gcode-renderer-three": "0.19.0", diff --git a/packages/gcode-preview-svelte/src/__tests__/model-behavioral-suite.test.ts b/packages/gcode-preview-svelte/src/__tests__/model-behavioral-suite.test.ts new file mode 100644 index 00000000..f8172bd0 --- /dev/null +++ b/packages/gcode-preview-svelte/src/__tests__/model-behavioral-suite.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment happy-dom +/** + * The framework-portable MODEL behavioral suite through the SVELTE adapter (DD-031): the same + * source → state → controls → dispose parity contract every model adapter passes, proving the + * store/action API is a faithful bridge over the shared model controller. Compiler-free — the store + * contract and `use:` action are plain functions. + */ +import { describe, expect, it } from 'vitest'; +import { + MODEL_STUB_GL, + MODEL_STUB_CONTROLS, + runModelBehavioralSuite, + type ModelAdapterInstance +} from '@chestnutlabs/gcode-model-renderer/testing'; +import { createModelViewer, type SvelteModelViewer, type ModelPreviewState } from '../model/index'; + +const settle = async (): Promise => { + for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0)); +}; + +/** Read the current store value the Svelte way (subscribe → capture → unsubscribe). */ +function current(viewer: SvelteModelViewer): ModelPreviewState { + let value!: ModelPreviewState; + const off = viewer.state.subscribe((s) => { + value = s; + }); + off(); + return value; +} + +runModelBehavioralSuite( + 'svelte createModelViewer', + { describe, it, expect }, + { + create() { + const canvas = document.createElement('canvas'); + const viewer = createModelViewer({ createRenderer: MODEL_STUB_GL, createControls: MODEL_STUB_CONTROLS }); + const action = viewer.canvas(canvas); // `use:viewer.canvas` + const instance: ModelAdapterInstance = { + controls: viewer.controls, + getState: () => current(viewer), + onEvent: (cb) => viewer.onEvent(cb), + dispose: () => { + action.destroy(); + viewer.dispose(); + }, + settle + }; + return instance; + } + } +); diff --git a/packages/gcode-preview-svelte/src/model/ModelViewer.svelte b/packages/gcode-preview-svelte/src/model/ModelViewer.svelte new file mode 100644 index 00000000..9ebb4f50 --- /dev/null +++ b/packages/gcode-preview-svelte/src/model/ModelViewer.svelte @@ -0,0 +1,96 @@ + + + + diff --git a/packages/gcode-preview-svelte/src/model/create-model-viewer.ts b/packages/gcode-preview-svelte/src/model/create-model-viewer.ts new file mode 100644 index 00000000..e5678d60 --- /dev/null +++ b/packages/gcode-preview-svelte/src/model/create-model-viewer.ts @@ -0,0 +1,68 @@ +/** + * createModelViewer — the Svelte reactivity bridge for interactive **source-model** viewing (the + * Prepare side), the analogue of `createGcodePreview` for the toolpath/Preview side (DD-031). Owns + * only the Svelte-isms: a store-contract `state` (subscribe receiving controller snapshots) and a + * `use:` action for the canvas. Engine/state/contracts live in `@chestnutlabs/gcode-model-renderer` + * and are re-exported, never redeclared (the D1 drift firewall). + * + * Named `createModelViewer` to parallel `createGcodePreview`; the underlying engine factory of the same + * name is imported here under an alias. + */ +import { + createModelPreviewController, + type ModelPreviewControllerOptions, + type ModelPreviewControls, + type ModelPreviewState, + type ModelViewer as ModelViewerEngine, + type ModelViewerEvent, + type ModelReadyInfo +} from '@chestnutlabs/gcode-model-renderer'; +import type { CaptureOptions } from '@chestnutlabs/gcode-renderer-three'; + +export type { ModelPreviewControls, ModelPreviewState, ModelViewerEvent, ModelReadyInfo }; +/** Svelte-facing options — identical to the controller's (the alias is the public name). */ +export type CreateModelViewerOptions = ModelPreviewControllerOptions; + +/** Minimal svelte/store Readable contract — declared structurally so the shipped JS never imports 'svelte'. */ +export interface ReadableModelState { + subscribe(run: (state: ModelPreviewState) => void): () => void; +} + +export interface CanvasActionResult { + destroy(): void; +} + +export interface SvelteModelViewer { + /** Store-contract state: subscribers get the current snapshot immediately, then every replacement. */ + state: ReadableModelState; + /** Canvas binding as a Svelte action: ``. */ + canvas(el: HTMLCanvasElement): CanvasActionResult; + setSource: ModelPreviewControls['setSource']; + capture(opts?: CaptureOptions): Promise; + controls: ModelPreviewControls; + raw: { viewer: () => ModelViewerEngine | null }; + onEvent(cb: (e: ModelViewerEvent) => void): () => void; + dispose(): void; +} + +export function createModelViewer(options: CreateModelViewerOptions = {}): SvelteModelViewer { + const controller = createModelPreviewController(options); + return { + state: { + subscribe(run) { + run(controller.getState()); // store contract: immediate emission + return controller.onStateChange(run); + } + }, + canvas: (el) => { + controller.bindCanvas(el); + return { destroy: () => controller.bindCanvas(null) }; + }, + setSource: controller.controls.setSource, + capture: controller.controls.capture, + controls: controller.controls, + raw: controller.raw, + onEvent: controller.onEvent, + dispose: controller.dispose + }; +} diff --git a/packages/gcode-preview-svelte/src/model/index.ts b/packages/gcode-preview-svelte/src/model/index.ts new file mode 100644 index 00000000..64cfd84d --- /dev/null +++ b/packages/gcode-preview-svelte/src/model/index.ts @@ -0,0 +1,8 @@ +/** + * @chestnutlabs/gcode-preview-svelte/model — the Svelte integration for interactive **source-model** + * viewing (STL / 3MF), the Prepare-side counterpart to the toolpath API (DD-031). `createModelViewer` + * store/action handle + the raw `ModelViewer.svelte` component (import from + * '@chestnutlabs/gcode-preview-svelte/model/ModelViewer.svelte'). Both are thin bridges over + * `createModelPreviewController` — never a separate viewer. + */ +export * from './create-model-viewer.js'; diff --git a/packages/gcode-preview-vue/package.json b/packages/gcode-preview-vue/package.json index 380bcae2..c5ac5993 100644 --- a/packages/gcode-preview-vue/package.json +++ b/packages/gcode-preview-vue/package.json @@ -36,6 +36,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./model": { + "types": "./dist/model/index.d.ts", + "import": "./dist/model/index.js" } }, "files": [ @@ -50,6 +54,7 @@ "test": "vitest run" }, "dependencies": { + "@chestnutlabs/gcode-model-renderer": "0.19.0", "@chestnutlabs/gcode-parser": "0.19.0", "@chestnutlabs/gcode-renderer-three": "0.19.0", "@chestnutlabs/toolpath-core": "0.19.0", diff --git a/packages/gcode-preview-vue/src/__tests__/model-behavioral-suite.test.ts b/packages/gcode-preview-vue/src/__tests__/model-behavioral-suite.test.ts new file mode 100644 index 00000000..ebe34992 --- /dev/null +++ b/packages/gcode-preview-vue/src/__tests__/model-behavioral-suite.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment happy-dom +/** + * The framework-portable MODEL behavioral suite through the VUE adapter (DD-031): the same + * source → state → controls → dispose parity contract every model adapter passes, proving + * `useModelViewer` / `` is a faithful bridge over the shared model controller. + */ +import { describe, expect, it } from 'vitest'; +import { effectScope, nextTick } from 'vue'; +import { + MODEL_STUB_GL, + MODEL_STUB_CONTROLS, + runModelBehavioralSuite, + type ModelAdapterInstance +} from '@chestnutlabs/gcode-model-renderer/testing'; +import { useModelViewer } from '../model/index'; + +const settle = async (): Promise => { + await nextTick(); + for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0)); +}; + +runModelBehavioralSuite( + 'vue useModelViewer', + { describe, it, expect }, + { + async create() { + const canvas = document.createElement('canvas'); + const scope = effectScope(); + const viewer = scope.run(() => + useModelViewer({ createRenderer: MODEL_STUB_GL, createControls: MODEL_STUB_CONTROLS }) + )!; + viewer.canvasRef.value = canvas; + await nextTick(); + const instance: ModelAdapterInstance = { + controls: viewer.controls, + getState: () => viewer.state, + onEvent: (cb) => viewer.onEvent(cb), + dispose: () => { + scope.stop(); + }, + settle + }; + return instance; + } + } +); diff --git a/packages/gcode-preview-vue/src/model/index.ts b/packages/gcode-preview-vue/src/model/index.ts new file mode 100644 index 00000000..4efa18e6 --- /dev/null +++ b/packages/gcode-preview-vue/src/model/index.ts @@ -0,0 +1,16 @@ +/** + * @chestnutlabs/gcode-preview-vue/model — the Vue integration for interactive **source-model** viewing + * (STL / 3MF), the Prepare-side counterpart to the toolpath `` (DD-031). `useModelViewer` + * composable + `` component, both thin reactivity bridges over `createModelPreviewController`. + */ +export { useModelViewer } from './use-model-viewer.js'; +export type { + ModelViewerHandle, + UseModelViewerOptions, + ModelPreviewControls, + ModelPreviewState, + ModelViewerEvent, + ModelReadyInfo +} from './use-model-viewer.js'; +export { ModelViewer } from './model-viewer-component.js'; +export type { ModelViewerSource } from './model-viewer-component.js'; diff --git a/packages/gcode-preview-vue/src/model/model-viewer-component.ts b/packages/gcode-preview-vue/src/model/model-viewer-component.ts new file mode 100644 index 00000000..a2d4037b --- /dev/null +++ b/packages/gcode-preview-vue/src/model/model-viewer-component.ts @@ -0,0 +1,168 @@ +/** + * — the ready-to-use Vue component for interactive source-model viewing (STL / 3MF), + * the Prepare-side counterpart to (DD-031). A SHELL over `useModelViewer`: every prop + * maps to a composable/controls call, every emit re-fires a controller event, and the handle is + * exposed via `defineExpose`. Render-function component (no SFC) — the template is a single . + */ +import { defineComponent, h, ref, watch, type PropType } from 'vue'; +import type { + CameraMode, + CameraState, + CameraView, + GLRendererLike, + InteractiveStageOptions, + LoadProgress, + RenderTargetCanvas +} from '@chestnutlabs/gcode-renderer-three'; +import type { + ModelBackground, + ModelLimits, + ModelLoader, + ModelReadyInfo, + ModelSourceInput, + PresentationView, + RenderScope +} from '@chestnutlabs/gcode-model-renderer'; +import { useModelViewer, type ModelViewerEvent } from './use-model-viewer.js'; + +export type ModelViewerSource = ModelSourceInput | null; + +export const ModelViewer = defineComponent({ + name: 'ModelViewer', + props: { + /** The one required-in-practice prop: the source model. Changing it re-parses. */ + source: { type: [Object, Uint8Array, ArrayBuffer] as PropType, default: null }, + /** Snap to a preset orientation (shared vocabulary with the toolpath camera). */ + view: { type: String as PropType, default: undefined }, + /** Restore a saved camera pose. Pair with `@camera-change` for two-way binding. */ + cameraState: { type: Object as PropType, default: undefined }, + /** `'transparent'` (default) | CSS colour | `0xRRGGBB`. */ + background: { type: [String, Number] as PropType, default: undefined }, + /** DD-020 interaction-aware quality; default `'auto'`. */ + interactionQuality: { type: String as PropType<'off' | 'auto'>, default: undefined }, + /** Initial camera projection; default `'perspective'`. */ + cameraMode: { type: String as PropType, default: undefined }, + /** Render only a subset: `{ plateId }` / `{ objectIds }` / `{ instanceFilter }`; null = whole source. */ + renderScope: { type: Object as PropType, default: undefined }, + /** Source-model triangle / byte caps. */ + limits: { type: Object as PropType, default: undefined }, + /** 3MF `paint_color` palette override (hex per 0-based slot). */ + filamentPalette: { type: Array as PropType, default: undefined }, + /** Loader registry (open `kind`). Default: STL + 3MF. */ + loaders: { type: Array as PropType, default: undefined }, + /** Advanced/test: inject GL / orbit controls. */ + createRenderer: { + type: Function as PropType<(canvas: RenderTargetCanvas) => GLRendererLike>, + default: undefined + }, + createControls: { + type: Function as PropType>, + default: undefined + } + }, + emits: { + /* eslint-disable @typescript-eslint/no-unused-vars -- emit validators document payloads */ + ready: (_info: ModelReadyInfo) => true, + 'camera-change': (_state: CameraState) => true, + error: (_e: { code: string; message: string }) => true, + 'renderer-unsupported': (_e: { feature: string; message: string }) => true, + 'context-lost': () => true, + 'context-restored': () => true, + progress: (_p: LoadProgress) => true + /* eslint-enable @typescript-eslint/no-unused-vars */ + }, + setup(props, { emit, expose }) { + const viewer = useModelViewer({ + ...(props.background !== undefined ? { background: props.background } : {}), + ...(props.interactionQuality !== undefined ? { interactionQuality: props.interactionQuality } : {}), + ...(props.cameraMode !== undefined ? { cameraMode: props.cameraMode } : {}), + ...(props.limits !== undefined ? { limits: props.limits } : {}), + ...(props.filamentPalette !== undefined ? { filamentPalette: props.filamentPalette } : {}), + ...(props.loaders !== undefined ? { loaders: props.loaders } : {}), + ...(props.renderScope != null ? { renderScope: props.renderScope } : {}), + ...(props.createRenderer !== undefined ? { createRenderer: props.createRenderer } : {}), + ...(props.createControls !== undefined ? { createControls: props.createControls } : {}), + onProgress: (p) => emit('progress', p) + }); + + viewer.onEvent((e: ModelViewerEvent) => { + switch (e.type) { + case 'ready': + emit('ready', e.info); + break; + case 'camera-changed': + emit('camera-change', e.state); + break; + case 'error': + emit('error', { code: e.code, message: e.message }); + break; + case 'renderer-unsupported': + emit('renderer-unsupported', { feature: e.feature, message: e.message }); + break; + case 'context-lost': + emit('context-lost'); + break; + case 'context-restored': + emit('context-restored'); + break; + default: + break; + } + }); + + // Runtime-only props apply immediately on mount (Vue's watch is change-only by default). + watch( + () => props.source, + (source) => { + if (source !== null && source !== undefined) void viewer.controls.setSource(source); + }, + { immediate: true } + ); + watch( + () => props.view, + (view) => { + if (view !== undefined) viewer.controls.setView(view); + }, + { immediate: true } + ); + watch( + () => props.cameraState, + (state) => { + if (state !== undefined && state !== null) viewer.controls.setCameraState(state); + }, + { immediate: true } + ); + watch( + () => props.background, + (bg) => { + if (bg !== undefined) viewer.controls.setBackground(bg); + } + ); + watch( + () => props.interactionQuality, + (mode) => { + if (mode !== undefined) viewer.controls.setInteractionQuality(mode); + } + ); + watch( + () => props.renderScope, + (scope) => { + if (scope !== undefined) viewer.controls.setRenderScope(scope); + } + ); + + expose({ viewer }); + + const canvasEl = ref(null); + watch(canvasEl, (el) => { + viewer.canvasRef.value = el; + }); + return () => + h('canvas', { + ref: canvasEl, + style: { width: '100%', height: '100%', display: 'block', touchAction: 'none' }, + tabindex: '0', + 'aria-label': '3D source-model preview' + }); + } +}); diff --git a/packages/gcode-preview-vue/src/model/use-model-viewer.ts b/packages/gcode-preview-vue/src/model/use-model-viewer.ts new file mode 100644 index 00000000..87bf108c --- /dev/null +++ b/packages/gcode-preview-vue/src/model/use-model-viewer.ts @@ -0,0 +1,63 @@ +/** + * useModelViewer — the Vue reactivity bridge for interactive **source-model** viewing (the Prepare + * side), the analogue of `useGcodePreview` for the toolpath/Preview side (DD-031). Owns only the + * Vue-isms: a canvas template ref, a shallow-reactive state mirror fed by controller snapshots, and + * scope-tied disposal. The engine/state/contracts live in `@chestnutlabs/gcode-model-renderer` and are + * re-exported, never redeclared (the D1 drift firewall). + */ +import { getCurrentScope, onScopeDispose, readonly, ref, shallowReactive, watch, type Ref } from 'vue'; +import { + createModelPreviewController, + type ModelPreviewControllerOptions, + type ModelPreviewControls, + type ModelPreviewState, + type ModelViewer, + type ModelViewerEvent, + type ModelReadyInfo +} from '@chestnutlabs/gcode-model-renderer'; +import type { CaptureOptions } from '@chestnutlabs/gcode-renderer-three'; + +export type { ModelPreviewControls, ModelPreviewState, ModelViewerEvent, ModelReadyInfo }; +/** Vue-facing options — identical to the controller's (the alias is the public name). */ +export type UseModelViewerOptions = ModelPreviewControllerOptions; + +export interface ModelViewerHandle { + /** Bind to a template ref; rebinding disposes and rebuilds safely. */ + canvasRef: Ref; + setSource: ModelPreviewControls['setSource']; + capture(opts?: CaptureOptions): Promise; + state: Readonly; + controls: ModelPreviewControls; + raw: { viewer: () => ModelViewer | null }; + onEvent(cb: (e: ModelViewerEvent) => void): () => void; + dispose(): void; +} + +export function useModelViewer(options: UseModelViewerOptions = {}): ModelViewerHandle { + const controller = createModelPreviewController(options); + + const state = shallowReactive({ ...controller.getState() }); + const offState = controller.onStateChange((snap) => { + Object.assign(state, snap); + }); + + const canvasRef = ref(null); + watch(canvasRef, (canvas) => controller.bindCanvas(canvas)); + + function dispose(): void { + offState(); + controller.dispose(); + } + if (getCurrentScope() !== undefined) onScopeDispose(dispose); + + return { + canvasRef, + setSource: controller.controls.setSource, + capture: controller.controls.capture, + state: readonly(state) as Readonly, + controls: controller.controls, + raw: controller.raw, + onEvent: controller.onEvent, + dispose + }; +} From 2fa364c7aa6ab73ab770da960503c9770a204b89 Mon Sep 17 00:00:00 2001 From: Nathaniel Chestnut Date: Sat, 29 Aug 2026 11:38:48 -0700 Subject: [PATCH 15/22] feat(demo-kit,example-react): model fixtures + React Prepare/Preview showcase (DD-031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - demo-kit/models.js: synthetic MIT-clean source-model fixtures (STL tower → materials unavailable; colored 3MF → materials known), each source() returning a ready ModelSourceInput. Plus model-3mf-fixture.js (the generated base64 3MF). - example-react showcase: a Preview/Prepare mode toggle demonstrates both halves of the SDK in one app — (sliced G-code) and (source model, via /model), with the model side surfacing the materials capability tier + object/ placement counts. - example-react: new model.html minimal — the smallest integration, linked from the landing as a third tier. Verified headless: Prepare loads the colored 3MF (3 objects, materials KNOWN), toggles back to Preview cleanly; minimal model loads the STL (1 object, materials unavailable). Co-Authored-By: Claude Opus 4.8 --- tools/demo-kit/index.js | 1 + tools/demo-kit/model-3mf-fixture.js | 6 + tools/demo-kit/models.js | 105 ++++++ tools/example-react/index.html | 12 +- tools/example-react/model.html | 36 ++ tools/example-react/src/model-minimal.jsx | 54 +++ tools/example-react/src/showcase.jsx | 395 +++++++++++++--------- tools/example-react/vite.config.js | 3 +- 8 files changed, 451 insertions(+), 161 deletions(-) create mode 100644 tools/demo-kit/model-3mf-fixture.js create mode 100644 tools/demo-kit/models.js create mode 100644 tools/example-react/model.html create mode 100644 tools/example-react/src/model-minimal.jsx diff --git a/tools/demo-kit/index.js b/tools/demo-kit/index.js index 284158a0..442d128e 100644 --- a/tools/demo-kit/index.js +++ b/tools/demo-kit/index.js @@ -9,4 +9,5 @@ */ export * from './capabilities.js'; export * from './fixtures.js'; +export * from './models.js'; export * from './format.js'; diff --git a/tools/demo-kit/model-3mf-fixture.js b/tools/demo-kit/model-3mf-fixture.js new file mode 100644 index 00000000..8f1c324f --- /dev/null +++ b/tools/demo-kit/model-3mf-fixture.js @@ -0,0 +1,6 @@ +// GENERATED by tools/fixtures/make-model-3mf.mjs — do not edit by hand. +// A fully synthetic, MIT-clean colored 3MF (three blocks with real basematerials +// colors) used only as documentation-media input for the model-renderer harness. +// Regenerate with: node tools/fixtures/make-model-3mf.mjs +export const TOWER_3MF_BASE64 = + 'UEsDBBQAAAAAAAAAAACLY45mawoAAGsKAAAQAAAAM0QvM2Rtb2RlbC5tb2RlbDw/eG1sIHZlcnNpb249IjEuMCIgZW5jb2Rpbmc9IlVURi04Ij8+Cjxtb2RlbCB1bml0PSJtaWxsaW1ldGVyIiB4bWxucz0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS8zZG1hbnVmYWN0dXJpbmcvY29yZS8yMDE1LzAyIj4KIDxyZXNvdXJjZXM+CiAgPGJhc2VtYXRlcmlhbHMgaWQ9IjEiPgogICA8YmFzZSBuYW1lPSJyZWQiIGRpc3BsYXljb2xvcj0iI0Q2NDU0NSIvPgogICA8YmFzZSBuYW1lPSJhbWJlciIgZGlzcGxheWNvbG9yPSIjRTBBMzJFIi8+CiAgIDxiYXNlIG5hbWU9ImdyZWVuIiBkaXNwbGF5Y29sb3I9IiMzRjlFNTIiLz4KICA8L2Jhc2VtYXRlcmlhbHM+CiAgPG9iamVjdCBpZD0iMTAiIHR5cGU9Im1vZGVsIiBwaWQ9IjEiIHBpbmRleD0iMCI+PG1lc2g+PHZlcnRpY2VzPjx2ZXJ0ZXggeD0iLTE1IiB5PSItMTUiIHo9IjAiLz48dmVydGV4IHg9IjE1IiB5PSItMTUiIHo9IjAiLz48dmVydGV4IHg9IjE1IiB5PSIxNSIgej0iMCIvPjx2ZXJ0ZXggeD0iLTE1IiB5PSIxNSIgej0iMCIvPjx2ZXJ0ZXggeD0iLTE1IiB5PSItMTUiIHo9IjEyIi8+PHZlcnRleCB4PSIxNSIgeT0iLTE1IiB6PSIxMiIvPjx2ZXJ0ZXggeD0iMTUiIHk9IjE1IiB6PSIxMiIvPjx2ZXJ0ZXggeD0iLTE1IiB5PSIxNSIgej0iMTIiLz48L3ZlcnRpY2VzPjx0cmlhbmdsZXM+PHRyaWFuZ2xlIHYxPSIwIiB2Mj0iMiIgdjM9IjEiLz48dHJpYW5nbGUgdjE9IjAiIHYyPSIzIiB2Mz0iMiIvPjx0cmlhbmdsZSB2MT0iNCIgdjI9IjUiIHYzPSI2Ii8+PHRyaWFuZ2xlIHYxPSI0IiB2Mj0iNiIgdjM9IjciLz48dHJpYW5nbGUgdjE9IjAiIHYyPSIxIiB2Mz0iNSIvPjx0cmlhbmdsZSB2MT0iMCIgdjI9IjUiIHYzPSI0Ii8+PHRyaWFuZ2xlIHYxPSIxIiB2Mj0iMiIgdjM9IjYiLz48dHJpYW5nbGUgdjE9IjEiIHYyPSI2IiB2Mz0iNSIvPjx0cmlhbmdsZSB2MT0iMiIgdjI9IjMiIHYzPSI3Ii8+PHRyaWFuZ2xlIHYxPSIyIiB2Mj0iNyIgdjM9IjYiLz48dHJpYW5nbGUgdjE9IjMiIHYyPSIwIiB2Mz0iNCIvPjx0cmlhbmdsZSB2MT0iMyIgdjI9IjQiIHYzPSI3Ii8+PC90cmlhbmdsZXM+PC9tZXNoPjwvb2JqZWN0PgogIDxvYmplY3QgaWQ9IjExIiB0eXBlPSJtb2RlbCIgcGlkPSIxIiBwaW5kZXg9IjEiPjxtZXNoPjx2ZXJ0aWNlcz48dmVydGV4IHg9Ii0xMSIgeT0iLTExIiB6PSIxMiIvPjx2ZXJ0ZXggeD0iMTEiIHk9Ii0xMSIgej0iMTIiLz48dmVydGV4IHg9IjExIiB5PSIxMSIgej0iMTIiLz48dmVydGV4IHg9Ii0xMSIgeT0iMTEiIHo9IjEyIi8+PHZlcnRleCB4PSItMTEiIHk9Ii0xMSIgej0iMjQiLz48dmVydGV4IHg9IjExIiB5PSItMTEiIHo9IjI0Ii8+PHZlcnRleCB4PSIxMSIgeT0iMTEiIHo9IjI0Ii8+PHZlcnRleCB4PSItMTEiIHk9IjExIiB6PSIyNCIvPjwvdmVydGljZXM+PHRyaWFuZ2xlcz48dHJpYW5nbGUgdjE9IjAiIHYyPSIyIiB2Mz0iMSIvPjx0cmlhbmdsZSB2MT0iMCIgdjI9IjMiIHYzPSIyIi8+PHRyaWFuZ2xlIHYxPSI0IiB2Mj0iNSIgdjM9IjYiLz48dHJpYW5nbGUgdjE9IjQiIHYyPSI2IiB2Mz0iNyIvPjx0cmlhbmdsZSB2MT0iMCIgdjI9IjEiIHYzPSI1Ii8+PHRyaWFuZ2xlIHYxPSIwIiB2Mj0iNSIgdjM9IjQiLz48dHJpYW5nbGUgdjE9IjEiIHYyPSIyIiB2Mz0iNiIvPjx0cmlhbmdsZSB2MT0iMSIgdjI9IjYiIHYzPSI1Ii8+PHRyaWFuZ2xlIHYxPSIyIiB2Mj0iMyIgdjM9IjciLz48dHJpYW5nbGUgdjE9IjIiIHYyPSI3IiB2Mz0iNiIvPjx0cmlhbmdsZSB2MT0iMyIgdjI9IjAiIHYzPSI0Ii8+PHRyaWFuZ2xlIHYxPSIzIiB2Mj0iNCIgdjM9IjciLz48L3RyaWFuZ2xlcz48L21lc2g+PC9vYmplY3Q+CiAgPG9iamVjdCBpZD0iMTIiIHR5cGU9Im1vZGVsIiBwaWQ9IjEiIHBpbmRleD0iMiI+PG1lc2g+PHZlcnRpY2VzPjx2ZXJ0ZXggeD0iLTciIHk9Ii03IiB6PSIyNCIvPjx2ZXJ0ZXggeD0iNyIgeT0iLTciIHo9IjI0Ii8+PHZlcnRleCB4PSI3IiB5PSI3IiB6PSIyNCIvPjx2ZXJ0ZXggeD0iLTciIHk9IjciIHo9IjI0Ii8+PHZlcnRleCB4PSItNyIgeT0iLTciIHo9IjM0Ii8+PHZlcnRleCB4PSI3IiB5PSItNyIgej0iMzQiLz48dmVydGV4IHg9IjciIHk9IjciIHo9IjM0Ii8+PHZlcnRleCB4PSItNyIgeT0iNyIgej0iMzQiLz48L3ZlcnRpY2VzPjx0cmlhbmdsZXM+PHRyaWFuZ2xlIHYxPSIwIiB2Mj0iMiIgdjM9IjEiLz48dHJpYW5nbGUgdjE9IjAiIHYyPSIzIiB2Mz0iMiIvPjx0cmlhbmdsZSB2MT0iNCIgdjI9IjUiIHYzPSI2Ii8+PHRyaWFuZ2xlIHYxPSI0IiB2Mj0iNiIgdjM9IjciLz48dHJpYW5nbGUgdjE9IjAiIHYyPSIxIiB2Mz0iNSIvPjx0cmlhbmdsZSB2MT0iMCIgdjI9IjUiIHYzPSI0Ii8+PHRyaWFuZ2xlIHYxPSIxIiB2Mj0iMiIgdjM9IjYiLz48dHJpYW5nbGUgdjE9IjEiIHYyPSI2IiB2Mz0iNSIvPjx0cmlhbmdsZSB2MT0iMiIgdjI9IjMiIHYzPSI3Ii8+PHRyaWFuZ2xlIHYxPSIyIiB2Mj0iNyIgdjM9IjYiLz48dHJpYW5nbGUgdjE9IjMiIHYyPSIwIiB2Mz0iNCIvPjx0cmlhbmdsZSB2MT0iMyIgdjI9IjQiIHYzPSI3Ii8+PC90cmlhbmdsZXM+PC9tZXNoPjwvb2JqZWN0PgogPC9yZXNvdXJjZXM+CiA8YnVpbGQ+CiAgPGl0ZW0gb2JqZWN0aWQ9IjEwIi8+CiAgPGl0ZW0gb2JqZWN0aWQ9IjExIi8+CiAgPGl0ZW0gb2JqZWN0aWQ9IjEyIi8+CiA8L2J1aWxkPgo8L21vZGVsPlBLAQIUABQAAAAAAAAAAACLY45mawoAAGsKAAAQAAAAAAAAAAAAAAAAAAAAAAAzRC8zZG1vZGVsLm1vZGVsUEsFBgAAAAABAAEAPgAAAJkKAAAAAA=='; diff --git a/tools/demo-kit/models.js b/tools/demo-kit/models.js new file mode 100644 index 00000000..4d54b8f2 --- /dev/null +++ b/tools/demo-kit/models.js @@ -0,0 +1,105 @@ +/* + * demo-kit/models — SOURCE-MODEL (Prepare) fixtures for the model-viewer adapters (DD-031), the + * counterpart to fixtures.js (which holds toolpath/Preview G-code). Both are fully synthetic and + * MIT-clean: the STL is generated here; the 3MF is the generated colored fixture (model-3mf-fixture.js). + * Each fixture's `source()` returns a `ModelSourceInput` ({ kind, bytes }) ready for setSource — no + * engine handles, so examples still consume the real published adapter. + */ +import { TOWER_3MF_BASE64 } from './model-3mf-fixture.js'; + +/** Binary STL from a triangle list, with computed face normals. */ +function buildStl(triangles) { + const buf = new ArrayBuffer(84 + triangles.length * 50); + const dv = new DataView(buf); + dv.setUint32(80, triangles.length, true); + let o = 84; + for (const [a, b, c] of triangles) { + const ux = b[0] - a[0], + uy = b[1] - a[1], + uz = b[2] - a[2]; + const vx = c[0] - a[0], + vy = c[1] - a[1], + vz = c[2] - a[2]; + let nx = uy * vz - uz * vy, + ny = uz * vx - ux * vz, + nz = ux * vy - uy * vx; + const len = Math.hypot(nx, ny, nz) || 1; + nx /= len; + ny /= len; + nz /= len; + dv.setFloat32(o, nx, true); + dv.setFloat32(o + 4, ny, true); + dv.setFloat32(o + 8, nz, true); + o += 12; + for (const p of [a, b, c]) { + dv.setFloat32(o, p[0], true); + dv.setFloat32(o + 4, p[1], true); + dv.setFloat32(o + 8, p[2], true); + o += 12; + } + dv.setUint16(o, 0, true); + o += 2; + } + return new Uint8Array(buf); +} + +/** An axis-aligned box as 12 triangles. */ +function box(x0, y0, z0, x1, y1, z1) { + const v = [ + [x0, y0, z0], + [x1, y0, z0], + [x1, y1, z0], + [x0, y1, z0], + [x0, y0, z1], + [x1, y0, z1], + [x1, y1, z1], + [x0, y1, z1] + ]; + const f = [ + [0, 2, 1], + [0, 3, 2], + [4, 5, 6], + [4, 6, 7], + [0, 1, 5], + [0, 5, 4], + [1, 2, 6], + [1, 6, 5], + [2, 3, 7], + [2, 7, 6], + [3, 0, 4], + [3, 4, 7] + ]; + return f.map(([a, b, c]) => [v[a], v[b], v[c]]); +} + +/** The synthetic "tower" STL (a stacked pair of boxes). Carries no colour → materials: unavailable. */ +export function buildDemoStl() { + return buildStl([...box(-16, -16, 0, 16, 16, 10), ...box(-9, -9, 10, 9, 9, 26)]); +} + +/** base64 → Uint8Array (browser btoa/atob path). */ +function base64ToBytes(b64) { + return Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0)); +} + +/** + * Source-model fixtures organized by what each demonstrates. `source()` builds a fresh + * `ModelSourceInput` for `controls.setSource` / the `source` prop. + */ +export const MODEL_FIXTURES = [ + { + id: 'stl-tower', + label: 'Tower (STL)', + blurb: 'A synthetic STL — no colour data, so materials reads the honest "unavailable" tier.', + source: () => ({ kind: 'stl', bytes: buildDemoStl() }) + }, + { + id: 'colored-3mf', + label: 'Colored blocks (3MF)', + blurb: 'A 3MF with real basematerials colours — materials reads "known" and the colours are shown.', + source: () => ({ kind: '3mf', bytes: base64ToBytes(TOWER_3MF_BASE64) }) + } +]; + +/** Flat lookup by id. */ +export const MODEL_FIXTURE_BY_ID = Object.fromEntries(MODEL_FIXTURES.map((m) => [m.id, m])); diff --git a/tools/example-react/index.html b/tools/example-react/index.html index fe86f808..3d472401 100644 --- a/tools/example-react/index.html +++ b/tools/example-react/index.html @@ -69,17 +69,21 @@

@chestnutlabs/gcode-preview-react

- Two example apps built on the published React adapter. Both drive the real package — no raw - renderer or parser imports. + Example apps built on the published React adapter — both halves of the SDK: sliced G-code + (Preview) and source models (Prepare). All drive the real package — no raw renderer imports.

diff --git a/tools/example-react/model.html b/tools/example-react/model.html new file mode 100644 index 00000000..20e7fe30 --- /dev/null +++ b/tools/example-react/model.html @@ -0,0 +1,36 @@ + + + + + + gcode-preview-react — minimal model viewer + + + +
+ + + diff --git a/tools/example-react/src/model-minimal.jsx b/tools/example-react/src/model-minimal.jsx new file mode 100644 index 00000000..11362490 --- /dev/null +++ b/tools/example-react/src/model-minimal.jsx @@ -0,0 +1,54 @@ +/* + * Minimal source-model viewer (DD-031 §4.7, tier 1 — Prepare side). + * + * The smallest real integration of @chestnutlabs/gcode-preview-react/model: give a + * `source` ({ kind, bytes } — an STL or 3MF) and it parses + renders. The counterpart to minimal.jsx, + * which does the same for sliced G-code with . No design system, no demo-kit engine + * handles — just the published adapter. (The fixtures here are synthetic MIT-clean sample models.) + */ +import { StrictMode, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { ModelViewer } from '@chestnutlabs/gcode-preview-react/model'; +import { MODEL_FIXTURES, MODEL_FIXTURE_BY_ID } from '../../demo-kit/index.js'; + +function App() { + const [source, setSource] = useState(null); + const [info, setInfo] = useState(null); + + function load(id) { + const fx = MODEL_FIXTURE_BY_ID[id]; + if (fx) setSource(fx.source()); // { kind, bytes } → ModelViewer parses + renders + } + + return ( + <> +
+ gcode-preview-react · minimal model viewer + + {info && ( + + {info.objectCount} object{info.objectCount === 1 ? '' : 's'} · materials: {info.materials} + + )} +
+
+ setInfo(i)} /> +
+ + ); +} + +createRoot(document.getElementById('app')).render( + + + +); diff --git a/tools/example-react/src/showcase.jsx b/tools/example-react/src/showcase.jsx index 19d224b0..e93a34ce 100644 --- a/tools/example-react/src/showcase.jsx +++ b/tools/example-react/src/showcase.jsx @@ -1,20 +1,25 @@ /* * Full showcase example (DD-031 §4.7, tier 2). * - * Exercises the real declarative surface of @chestnutlabs/gcode-preview-react and mirrors the - * Feature Lab's capability-aware UX in React idiom: color modes gate on the file's capabilities - * with a plain-language reason, feature-role hiding is a declarative prop, and diagnostics/picking - * come through the ref handle's `controls`. Chrome (light/dark) is separate from the renderer's - * neutral viewport. Styling is the shared workspace-internal demo-kit — not a published UI kit and - * not a library default. Still the real package: no raw renderer or parser imports. + * Demonstrates BOTH halves of the SDK in one app, like a slicer's two modes: + * - Preview → sliced G-code / toolpath via (@chestnutlabs/gcode-preview-react) + * - Prepare → source model (STL / 3MF) via (@chestnutlabs/gcode-preview-react/model) + * + * Both are the real published adapters (no raw renderer) and mirror the Feature Lab's capability-aware, + * honest UX in React idiom: toolpath color modes gate on the file's capabilities with a plain-language + * reason; the model side surfaces the material-colour capability tier and object/instance counts. + * Diagnostics/picking come through the ref handle. Styling is the shared workspace-internal demo-kit. */ import { StrictMode, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { GcodePreview } from '@chestnutlabs/gcode-preview-react'; +import { ModelViewer } from '@chestnutlabs/gcode-preview-react/model'; import { FeatureRole } from '@chestnutlabs/toolpath-core'; import { FIXTURE_GROUPS, FIXTURE_BY_ID, + MODEL_FIXTURES, + MODEL_FIXTURE_BY_ID, COLOR_MODES, COLOR_MODE_BY_ID, colorModeReason, @@ -33,12 +38,15 @@ const CAM_VIEWS = [ ]; function App() { - const ref = useRef(null); + const ref = useRef(null); // toolpath handle + const modelRef = useRef(null); // model handle const [chrome, setChrome] = useState('dark'); + const [mode, setMode] = useState('preview'); // 'preview' (toolpath) | 'prepare' (model) + const [view, setView] = useState('iso'); + + // Preview (toolpath) state const [fixtureId, setFixtureId] = useState('skirt-brim'); const [source, setSource] = useState(null); - - // Reactive UI state, mirrored from the component's event callbacks (the controlled-component idiom). const [caps, setCaps] = useState({}); const [layers, setLayers] = useState(0); const [segments, setSegments] = useState(0); @@ -48,20 +56,22 @@ function App() { const [time, setTime] = useState({ ms: null, src: null }); const [stats, setStats] = useState(null); const [picked, setPicked] = useState(null); - - // Declarative control props. const [colorModeId, setColorModeId] = useState('feature'); const [hideAdhesion, setHideAdhesion] = useState(false); const [showTravel, setShowTravel] = useState(false); const [showRetractions, setShowRetractions] = useState(false); - const [view, setView] = useState('iso'); + + // Prepare (model) state + const [modelFixtureId, setModelFixtureId] = useState('colored-3mf'); + const [modelSource, setModelSource] = useState(null); + const [modelInfo, setModelInfo] = useState(null); document.documentElement.dataset.chrome = chrome; - const mode = COLOR_MODE_BY_ID[colorModeId]; + const modeItem = COLOR_MODE_BY_ID[colorModeId]; const modeReason = colorModeReason(colorModeId, caps); - const colorMode = modeReason === '' ? mode.build() : undefined; // don't apply an unavailable mode - const legend = mode.legend ?? (colorModeId === 'feature' ? COLOR_MODE_BY_ID.feature.legend : null); + const colorMode = modeReason === '' ? modeItem.build() : undefined; + const legend = modeItem.legend ?? null; const featureRolesKnown = caps.featureRoles === 'known' || caps.featureRoles === 'inferred'; async function load(id) { @@ -74,18 +84,24 @@ function App() { setPicked(null); } + function loadModel(id) { + const fx = MODEL_FIXTURE_BY_ID[id]; + if (!fx) return; + setModelFixtureId(id); + setModelSource(fx.source()); // { kind, bytes } — no fetch, synthetic MIT-clean fixture + setModelInfo(null); + } + function onReady(s) { - if (typeof window !== 'undefined') window.gcodePreview = ref.current; // handle, for devtools/inspection + if (typeof window !== 'undefined') window.gcodePreview = ref.current; setCaps(s.capabilities); setLayers(s.layers); setSegments(s.segments); setTopLayer(Math.max(0, s.layers - 1)); - // Pull the reactive fields the ready summary doesn't carry from the live state snapshot. const st = ref.current?.state; if (st) { setHasRetractions(st.hasRetractions); setTime({ ms: st.totalTimeMs, src: st.timeEstimateSource }); - // If the chosen mode isn't available for this file, fall back to a mode that always is. if (!st.availableColorModes.includes(colorModeId) && colorModeReason(colorModeId, s.capabilities) !== '') { setColorModeId('single'); } @@ -97,16 +113,18 @@ function App() { } function onCanvasClick(e) { + if (mode !== 'preview') return; const c = e.currentTarget.querySelector('canvas'); if (!c || layers === 0) return; const r = c.getBoundingClientRect(); const ndcX = ((e.clientX - r.left) / r.width) * 2 - 1; const ndcY = -(((e.clientY - r.top) / r.height) * 2 - 1); - // pickSegment returns the IR segment index, or null if the ray missed. setPicked(ref.current?.controls.pickSegment(ndcX, ndcY) ?? null); } const fx = FIXTURE_BY_ID[fixtureId]; + const modelFx = MODEL_FIXTURE_BY_ID[modelFixtureId]; + const prepare = mode === 'prepare'; return ( <> @@ -115,23 +133,42 @@ function App() { G-code Preview · React Showcase - - + + + {!prepare ? ( + + ) : ( + + )} +
@@ -146,25 +183,37 @@ function App() {
- 0 ? [0, topLayer] : null} - hiddenFeatureRoles={hideAdhesion ? ADHESION_ROLES : undefined} - showTravel={showTravel} - showRetractions={showRetractions} - view={view} - frameContent="object" - quality="auto" - onReady={onReady} - onBuildComplete={refreshStats} - onDisclosure={(t) => setDisclosure(t ?? '')} - onParseError={(e) => setDisclosure(`Parse error: ${e.code} — ${e.message}`)} - /> - {legend && ( + {!prepare ? ( + 0 ? [0, topLayer] : null} + hiddenFeatureRoles={hideAdhesion ? ADHESION_ROLES : undefined} + showTravel={showTravel} + showRetractions={showRetractions} + view={view} + frameContent="object" + quality="auto" + onReady={onReady} + onBuildComplete={refreshStats} + onDisclosure={(t) => setDisclosure(t ?? '')} + onParseError={(e) => setDisclosure(`Parse error: ${e.code} — ${e.message}`)} + /> + ) : ( + setModelInfo(info)} + onError={(e) => setDisclosure(`Model error: ${e.code} — ${e.message}`)} + /> + )} + + {!prepare && legend && (
- {mode.label} + {modeItem.label}
{legend.map(([label, rgb]) => ( @@ -187,129 +236,163 @@ function App() { ))}
- {segments > 0 && ( + {!prepare && segments > 0 && (
{formatCount(segments)} segments · {layers} layers {time.ms !== null && ` · ${formatDuration(time.ms)}`}
)} + {prepare && modelInfo && ( +
+ {formatCount(modelInfo.objectCount)} object{modelInfo.objectCount === 1 ? '' : 's'} + {modelInfo.instancedCount > modelInfo.objectCount && ` · ${formatCount(modelInfo.instancedCount)} placements`} +
+ )}
- {['featureRoles', 'objects', 'feedrate', 'colorChanges'].map((k) => { - const tier = confidenceTier(caps[k]); - return ( - - {k} · {tier.label} - - ); - })} + {!prepare + ? ['featureRoles', 'objects', 'feedrate', 'colorChanges'].map((k) => { + const tier = confidenceTier(caps[k]); + return ( + + {k} · {tier.label} + + ); + }) + : modelInfo && ( + + materials · {confidenceTier(modelInfo.materials).label} + + )}
- {disclosure || (fx ? fx.blurb : 'Pick a fixture to begin.')} + {disclosure || (prepare ? 'Prepare: view the source model before slicing.' : fx ? fx.blurb : 'Pick a fixture to begin.')}
); diff --git a/tools/example-react/vite.config.js b/tools/example-react/vite.config.js index da7e2dfd..37da7ce8 100644 --- a/tools/example-react/vite.config.js +++ b/tools/example-react/vite.config.js @@ -22,7 +22,8 @@ export default defineConfig({ input: { index: resolve(import.meta.dirname, 'index.html'), minimal: resolve(import.meta.dirname, 'minimal.html'), - showcase: resolve(import.meta.dirname, 'showcase.html') + showcase: resolve(import.meta.dirname, 'showcase.html'), + model: resolve(import.meta.dirname, 'model.html') } } }, From 9ad74c6107f170507d3491a2bd0f3c84428cd70b Mon Sep 17 00:00:00 2001 From: Nathaniel Chestnut Date: Sat, 29 Aug 2026 11:56:58 -0700 Subject: [PATCH 16/22] feat(demo,examples): Prepare/Preview model viewing in Feature Lab + all showcases (DD-031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrate both halves of the SDK everywhere consumers look — sliced G-code (Preview) and source models (Prepare), like a slicer's two modes: - Feature Lab (tools/demo): a Preview/Prepare header toggle swaps the toolpath viewer for a source-model viewer (lazy createModelPreviewController on #modelView), a Prepare tab + "Source model" panel (objects/placements/materials tier/plates), fixtures swap to the model set, camera presets route to the active viewer. - Vue / Svelte / Web Component showcases: the same Preview/Prepare toggle via each framework's adapter, mirroring the React reference (built by subagents; reviewed + verified). Fixed a Svelte var collision (the color-mode item was named `mode`, clobbering the workflow `mode` → Prepare never activated; renamed to colorModeItem). - Minimal model page per framework (model.html + a tiny file), linked from each landing as a third tier — a discoverable smallest model integration. - demo-kit: models.js already added (synthetic STL + colored 3MF fixtures). Verified headless across all five surfaces: Prepare loads the colored 3MF (3 objects, materials KNOWN) and toggles back to Preview (454 seg / 14 layers) cleanly; minimals load the STL (1 object, materials unavailable). All builds clean, zero console errors. Co-Authored-By: Claude Opus 4.8 --- tools/demo/index.html | 21 +- tools/demo/src/main.js | 119 ++++++++- tools/example-svelte/index.html | 13 +- tools/example-svelte/model.html | 36 +++ tools/example-svelte/src/ModelMinimal.svelte | 43 +++ tools/example-svelte/src/Showcase.svelte | 179 +++++++++---- tools/example-svelte/src/model-minimal.js | 3 + tools/example-svelte/vite.config.js | 1 + tools/example-vue/index.html | 13 +- tools/example-vue/model.html | 39 +++ tools/example-vue/src/ModelMinimal.vue | 38 +++ tools/example-vue/src/Showcase.vue | 244 ++++++++++++------ tools/example-vue/src/model-minimal.js | 4 + tools/example-vue/vite.config.js | 3 +- tools/example-webcomponent/index.html | 12 +- tools/example-webcomponent/model.html | 50 ++++ tools/example-webcomponent/showcase.html | 28 +- .../example-webcomponent/src/model-minimal.js | 33 +++ tools/example-webcomponent/src/showcase.js | 138 +++++++++- tools/example-webcomponent/vite.config.js | 1 + 20 files changed, 869 insertions(+), 149 deletions(-) create mode 100644 tools/example-svelte/model.html create mode 100644 tools/example-svelte/src/ModelMinimal.svelte create mode 100644 tools/example-svelte/src/model-minimal.js create mode 100644 tools/example-vue/model.html create mode 100644 tools/example-vue/src/ModelMinimal.vue create mode 100644 tools/example-vue/src/model-minimal.js create mode 100644 tools/example-webcomponent/model.html create mode 100644 tools/example-webcomponent/src/model-minimal.js diff --git a/tools/demo/index.html b/tools/demo/index.html index 4ea20b57..54ad4c60 100644 --- a/tools/demo/index.html +++ b/tools/demo/index.html @@ -89,13 +89,19 @@ background: var(--gp-viewport); overflow: hidden; } - #view { + #view, + #modelView { width: 100%; height: 100%; display: block; touch-action: none; outline: none; } + /* A CSS display rule beats the UA [hidden] rule — restore hiding for the swapped viewer. */ + #view[hidden], + #modelView[hidden] { + display: none; + } /* floating viewport overlays */ .gp-vp-overlay { @@ -342,6 +348,11 @@
+ +
+ + +
@@ -360,6 +371,8 @@
+ +
@@ -561,6 +575,11 @@

Render diagnostics

Load a file, then read the render stats (backend, GPU, geometry, timings).
+
diff --git a/tools/demo/src/main.js b/tools/demo/src/main.js index bdce157c..79d49c70 100644 --- a/tools/demo/src/main.js +++ b/tools/demo/src/main.js @@ -6,6 +6,9 @@ */ import '../../demo-kit/tokens.css'; import { createPreviewController } from '@chestnutlabs/gcode-preview-core'; +// DD-031: the Prepare-side controller for source-model (STL/3MF) viewing — the counterpart to the +// toolpath controller above. The flagship demonstrates both halves of the SDK. +import { createModelPreviewController } from '@chestnutlabs/gcode-model-renderer'; import { FeatureRole } from '@chestnutlabs/toolpath-core'; import { COLOR_MODES, @@ -15,6 +18,8 @@ import { rgbCss, FIXTURE_GROUPS, FIXTURE_BY_ID, + MODEL_FIXTURES, + MODEL_FIXTURE_BY_ID, count, duration, bytes, @@ -60,6 +65,85 @@ let capabilities = {}; let savedCamera = null; let ready = false; +// ---- Prepare (source-model) mode: the second half of the SDK, lazily created on first use ---- +let mode = 'preview'; // 'preview' (toolpath) | 'prepare' (source model) +let modelController = null; +let modelInfo = null; + +function ensureModel() { + if (modelController !== null) return modelController; + modelController = createModelPreviewController({ background: 'transparent', interactionQuality: 'auto' }); + modelController.bindCanvas(els.modelView); + modelController.onEvent((e) => { + if (e.type === 'ready') { + modelInfo = e.info; + renderModelInfo(); + } else if (e.type === 'error') { + els.status.textContent = `Model error: ${e.code} — ${e.message}`; + } + }); + return modelController; +} + +function renderModelInfo() { + if (modelInfo === null) { + els.modelInfo.textContent = '—'; + return; + } + const t = confidenceTier(modelInfo.materials); + const plates = modelInfo.plates ? `\nplates: ${modelInfo.plates.list.length}` : ''; + els.modelInfo.innerHTML = + `objects: ${count(modelInfo.objectCount)}\n` + + `placements: ${count(modelInfo.instancedCount)}\n` + + `materials: ${t.label}${plates}`; +} + +function populateFixtures(prepare) { + els.fixture.innerHTML = ''; + if (prepare) { + for (const m of MODEL_FIXTURES) { + const o = document.createElement('option'); + o.value = m.id; + o.textContent = m.label; + els.fixture.appendChild(o); + } + els.fixture.value = 'colored-3mf'; + } else { + for (const g of FIXTURE_GROUPS) { + const og = document.createElement('optgroup'); + og.label = g.group; + for (const it of g.items) { + const o = document.createElement('option'); + o.value = it.id; + o.textContent = it.label; + og.appendChild(o); + } + els.fixture.appendChild(og); + } + els.fixture.value = 'skirt-brim'; + } +} + +function setMode(next) { + if (next === mode) return; + mode = next; + const prepare = next === 'prepare'; + for (const b of els.modeSeg.children) b.classList.toggle('gp-on', b.dataset.mode === next); + els.view.hidden = prepare; + els.modelView.hidden = !prepare; + // Toolpath tabs are irrelevant in Prepare; show only the Prepare tab and select it (and vice-versa). + for (const t of els.tabs.querySelectorAll('.gp-tab')) { + if (t.dataset.tab !== 'prepare') t.hidden = prepare; + } + document.querySelector('[data-tab="prepare"]').hidden = !prepare; + selectTab(prepare ? 'prepare' : 'inspect'); + populateFixtures(prepare); + els.legendCard.hidden = true; + els.countPill.hidden = true; + if (prepare) ensureModel(); + loadSelected(); +} + // ============ fixture picker ============ for (const g of FIXTURE_GROUPS) { const og = document.createElement('optgroup'); @@ -136,6 +220,7 @@ function renderLegend() { // ============ parse / load ============ async function loadSelected() { + if (mode === 'prepare') return loadSelectedModel(); const fx = FIXTURE_BY_ID[els.fixture.value]; if (!fx) return; els.status.textContent = `Fetching ${fx.label}…`; @@ -148,6 +233,18 @@ async function loadSelected() { } } +// Prepare: hand the model controller a synthetic MIT-clean source model (no fetch). Its `ready` event +// (wired in ensureModel) populates the Source-model panel with the honest capability tier + counts. +function loadSelectedModel() { + const fx = MODEL_FIXTURE_BY_ID[els.fixture.value]; + if (!fx) return; + modelInfo = null; + renderModelInfo(); + els.status.textContent = `${fx.label} — ${fx.blurb}`; + els.modelBlurb.textContent = fx.blurb; + ensureModel().controls.setSource(fx.source()); +} + async function parseInput(input, label) { setParsing(true); els.stage.hidden = false; @@ -338,11 +435,18 @@ els.capture.addEventListener('click', async () => { }); // ============ wiring: tabs + rail ============ +function selectTab(name) { + for (const t of els.tabs.querySelectorAll('.gp-tab')) t.classList.toggle('gp-on', t.dataset.tab === name); + for (const p of document.querySelectorAll('[data-panel]')) p.hidden = p.dataset.panel !== name; +} els.tabs.addEventListener('click', (ev) => { const btn = ev.target.closest('[data-tab]'); if (!btn) return; - for (const t of els.tabs.querySelectorAll('.gp-tab')) t.classList.toggle('gp-on', t === btn); - for (const p of document.querySelectorAll('[data-panel]')) p.hidden = p.dataset.panel !== btn.dataset.tab; + selectTab(btn.dataset.tab); +}); +els.modeSeg.addEventListener('click', (ev) => { + const b = ev.target.closest('[data-mode]'); + if (b) setMode(b.dataset.mode); }); els.railToggle.addEventListener('click', () => els.main.classList.toggle('gp-rail-collapsed')); @@ -386,10 +490,12 @@ els.projToggle.addEventListener('click', () => { preview.controls.setCameraMode(next); void cur; }); +// Camera presets + frame route to whichever viewer is active (toolpath or source model). +const activeControls = () => (mode === 'prepare' && modelController !== null ? modelController.controls : preview.controls); document .querySelectorAll('[data-view]') - .forEach((b) => b.addEventListener('click', () => preview.controls.setView(b.dataset.view))); -els.frame.addEventListener('click', () => preview.controls.frame()); + .forEach((b) => b.addEventListener('click', () => activeControls().setView(b.dataset.view))); +els.frame.addEventListener('click', () => activeControls().frame()); els.frameSeg.addEventListener('click', (ev) => { const b = ev.target.closest('[data-frame]'); if (!b) return; @@ -515,9 +621,12 @@ new ResizeObserver(() => { const r = preview.raw.renderer(); if (r) r.resize(els.view.clientWidth, els.view.clientHeight); }).observe(els.view); +new ResizeObserver(() => { + if (modelController !== null) modelController.controls.resize(els.modelView.clientWidth, els.modelView.clientHeight); +}).observe(els.modelView); // expose for automated verification (kept minimal — the flagship path is the controller) -window.viewer = { preview }; +window.viewer = { preview, model: () => modelController }; // STL export lives on a header-adjacent action only when useful; keep the util imported (used by tests). void downloadToolpathStl; diff --git a/tools/example-svelte/index.html b/tools/example-svelte/index.html index dc4dcd9a..66102478 100644 --- a/tools/example-svelte/index.html +++ b/tools/example-svelte/index.html @@ -69,17 +69,22 @@

@chestnutlabs/gcode-preview-svelte

- Two example apps built on the published Svelte adapter. Both drive the real package — no raw - renderer or parser imports. + Example apps built on the published Svelte adapter — both halves of the SDK: sliced G-code + (Preview) and source models (Prepare). All drive the real package — no raw renderer or parser + imports.

diff --git a/tools/example-svelte/model.html b/tools/example-svelte/model.html new file mode 100644 index 00000000..8b8e2607 --- /dev/null +++ b/tools/example-svelte/model.html @@ -0,0 +1,36 @@ + + + + + + gcode-preview-svelte — minimal model viewer + + + +
+ + + diff --git a/tools/example-svelte/src/ModelMinimal.svelte b/tools/example-svelte/src/ModelMinimal.svelte new file mode 100644 index 00000000..cbf80691 --- /dev/null +++ b/tools/example-svelte/src/ModelMinimal.svelte @@ -0,0 +1,43 @@ + + + +
+ gcode-preview-svelte · minimal model viewer + + {#if info} + + {info.objectCount} object{info.objectCount === 1 ? '' : 's'} · materials: {info.materials} + + {/if} +
+
+ +
diff --git a/tools/example-svelte/src/Showcase.svelte b/tools/example-svelte/src/Showcase.svelte index b883467b..04470bb5 100644 --- a/tools/example-svelte/src/Showcase.svelte +++ b/tools/example-svelte/src/Showcase.svelte @@ -1,19 +1,27 @@ + + diff --git a/tools/example-vue/src/ModelMinimal.vue b/tools/example-vue/src/ModelMinimal.vue new file mode 100644 index 00000000..778199db --- /dev/null +++ b/tools/example-vue/src/ModelMinimal.vue @@ -0,0 +1,38 @@ + + + + diff --git a/tools/example-vue/src/Showcase.vue b/tools/example-vue/src/Showcase.vue index 2a92a568..cd260ca6 100644 --- a/tools/example-vue/src/Showcase.vue +++ b/tools/example-vue/src/Showcase.vue @@ -1,20 +1,28 @@ + + diff --git a/tools/example-webcomponent/showcase.html b/tools/example-webcomponent/showcase.html index 78259260..0159eb88 100644 --- a/tools/example-webcomponent/showcase.html +++ b/tools/example-webcomponent/showcase.html @@ -37,11 +37,18 @@ min-width: 0; background: var(--gp-viewport); } - .sc-viewport gcode-preview { + .sc-viewport gcode-preview, + .sc-viewport gcode-model-viewer { display: block; width: 100%; height: 100%; } + /* A CSS `display` rule beats the UA `[hidden]` rule, so restore hiding explicitly for the two + viewers we swap between Preview and Prepare. */ + .sc-viewport gcode-preview[hidden], + .sc-viewport gcode-model-viewer[hidden] { + display: none; + } .sc-legend-card { position: absolute; top: var(--gp-space-3); @@ -119,6 +126,12 @@ G-code Preview · Web Component Showcase
+ +
+ + +
+
@@ -133,6 +146,9 @@ + + diff --git a/tools/example-webcomponent/src/model-minimal.js b/tools/example-webcomponent/src/model-minimal.js new file mode 100644 index 00000000..432a2feb --- /dev/null +++ b/tools/example-webcomponent/src/model-minimal.js @@ -0,0 +1,33 @@ +/* + * Minimal source-model viewer (DD-031 §4.7, tier 1 — Prepare side). + * + * The smallest real integration of the Web Component: importing `/model/define` + * auto-registers the element (tag `gcode-model-viewer`, not the reserved `model-viewer`), then set the + * `source` PROPERTY ({ kind, bytes } — an STL or 3MF; objects can't be attributes) and it parses + + * renders. The counterpart to minimal.js, which does the same for sliced G-code with . + * No design system, no demo-kit tokens — just the published adapter. (The fixtures are synthetic + * MIT-clean sample models.) + */ +import '@chestnutlabs/gcode-preview-element/model/define'; +import { MODEL_FIXTURES, MODEL_FIXTURE_BY_ID } from '../../demo-kit/index.js'; + +const view = document.getElementById('view'); +const fixture = document.getElementById('fixture'); +const info = document.getElementById('info'); + +// Populate the picker from the shared model fixtures. +fixture.insertAdjacentHTML( + 'beforeend', + MODEL_FIXTURES.map((m) => ``).join('') +); + +fixture.addEventListener('change', () => { + const fx = MODEL_FIXTURE_BY_ID[fixture.value]; + if (fx) view.source = fx.source(); // property (object) → parse + render +}); + +// `ready`'s detail is a ModelReadyInfo carrying the parse summary. +view.addEventListener('ready', (e) => { + const i = e.detail; + info.textContent = `${i.objectCount} object${i.objectCount === 1 ? '' : 's'} · materials: ${i.materials}`; +}); diff --git a/tools/example-webcomponent/src/showcase.js b/tools/example-webcomponent/src/showcase.js index d5324126..8bf9e372 100644 --- a/tools/example-webcomponent/src/showcase.js +++ b/tools/example-webcomponent/src/showcase.js @@ -10,10 +10,15 @@ * Styling is the shared workspace-internal demo-kit. Still the real package: no raw renderer imports. */ import '@chestnutlabs/gcode-preview-element/define'; +// Auto-registers (the Prepare-side source-model viewer). Note the tag is +// `gcode-model-viewer`, not the reserved `model-viewer`. +import '@chestnutlabs/gcode-preview-element/model/define'; import { FeatureRole } from '@chestnutlabs/toolpath-core'; import { FIXTURE_GROUPS, FIXTURE_BY_ID, + MODEL_FIXTURES, + MODEL_FIXTURE_BY_ID, COLOR_MODES, COLOR_MODE_BY_ID, colorModeReason, @@ -29,16 +34,27 @@ const CAP_KEYS = ['featureRoles', 'objects', 'feedrate', 'colorChanges']; const $ = (id) => document.getElementById(id); const view = $('view'); +const modelView = $('modelView'); let caps = {}; let layers = 0; let segments = 0; let colorModeId = 'feature'; +let mode = 'preview'; // 'preview' (toolpath) | 'prepare' (source model) +let previewCount = { has: false, text: '' }; // preview count-pill state, restored when switching back +let modelInfo = null; // last ModelReadyInfo (Prepare) -// ---- populate the pickers from the shared demo-kit ---- -$('fixture').innerHTML = FIXTURE_GROUPS.map( - (g) => `${g.items.map((i) => ``).join('')}` -).join(''); -$('fixture').value = 'skirt-brim'; +// ---- fixture pickers: one {stats && (
-
backend: {stats.backend} ({stats.capability})
+
+ backend: {stats.backend} ({stats.capability}) +
geometry: {stats.geometryMode}
segments: {formatCount(stats.renderedSegmentCount ?? 0)}
draw calls: {stats.drawCalls ?? '—'}
@@ -362,7 +368,10 @@ function App() {
objects: {formatCount(modelInfo.objectCount)}
placements: {formatCount(modelInfo.instancedCount)}
- materials: {confidenceTier(modelInfo.materials).label} + materials:{' '} + + {confidenceTier(modelInfo.materials).label} +
{modelInfo.plates &&
plates: {modelInfo.plates.list.length}
}
@@ -392,7 +401,10 @@ function App() { )}
- {disclosure || (prepare ? 'Prepare: view the source model before slicing.' : fx ? fx.blurb : 'Pick a fixture to begin.')} + + {disclosure || + (prepare ? 'Prepare: view the source model before slicing.' : fx ? fx.blurb : 'Pick a fixture to begin.')} + ); diff --git a/tools/example-vue/src/Showcase.vue b/tools/example-vue/src/Showcase.vue index cd260ca6..aa43af75 100644 --- a/tools/example-vue/src/Showcase.vue +++ b/tools/example-vue/src/Showcase.vue @@ -115,7 +115,10 @@ function onReady(summary) { if (st) { hasRetractions.value = st.hasRetractions; time.value = { ms: st.totalTimeMs, src: st.timeEstimateSource }; - if (!st.availableColorModes.includes(colorModeId.value) && colorModeReason(colorModeId.value, summary.capabilities) !== '') { + if ( + !st.availableColorModes.includes(colorModeId.value) && + colorModeReason(colorModeId.value, summary.capabilities) !== '' + ) { colorModeId.value = 'single'; } } @@ -167,7 +170,13 @@ function modeDisabled(id) { - @@ -259,7 +268,9 @@ function modeDisabled(id) {

Filter

- +