diff --git a/.changeset/feature-role-visibility.md b/.changeset/feature-role-visibility.md new file mode 100644 index 00000000..044f4983 --- /dev/null +++ b/.changeset/feature-role-visibility.md @@ -0,0 +1,15 @@ +--- +"@chestnutlabs/gcode-renderer-three": minor +"@chestnutlabs/gcode-preview-core": minor +"@chestnutlabs/toolpath-core": minor +--- + +feat(renderer): `setFeatureRoleVisible(role, visible)` — show/hide a single feature role + +Toggle a `FeatureRole` on or off — e.g. hide `Skirt`/`Brim` to declutter a part preview, or isolate +`Support`. Feature roles live per-segment inside the extrusion geometry (unlike the whole-chunk move-kind +toggle), so hidden segments are collapsed to NaN positions and the GPU discards them; showing the role +again restores the geometry byte-for-byte. Available on `ToolpathRenderer` and via +`GcodePreviewControls.setFeatureRoleVisible` (a no-op on the 2D renderer). Additive and capability-gated: +gate the UI on `capabilities.featureRoles === 'known'`, like the feature colour mode; untouched geometry +(no role hidden) is byte-identical. Also exports `FeatureRoleValue` from `@chestnutlabs/toolpath-core`. diff --git a/docs/VISUAL_FEATURE_COVERAGE.md b/docs/VISUAL_FEATURE_COVERAGE.md index def00030..bbdeb1c9 100644 --- a/docs/VISUAL_FEATURE_COVERAGE.md +++ b/docs/VISUAL_FEATURE_COVERAGE.md @@ -90,6 +90,7 @@ them up rather than rediscovering them. | Retraction / de-retraction markers | ✅ | ✅ | `retraction-markers` | ✅ | gallery | — | covered | | Seam moves | ✅ | partial | — | mention | gallery | — | needs screenshot | | Source-line ↔ segment mapping ("debugger": `pickSegment` + `segmentAtSourceLine`) | ✅ | ❌ | — | ❌ | gallery | recipes | needs demo exposure + needs screenshot | +| Show/hide a feature role (`setFeatureRoleVisible` — e.g. hide brim/skirt) | ✅ | ✅ (toggle) | — | ❌ | gallery | — | covered (demo + tests); screenshot with a brim file pending | ## 2. Coloring & analysis modes diff --git a/docs/manual/feature-gallery.md b/docs/manual/feature-gallery.md index f35ebdfa..304205f7 100644 --- a/docs/manual/feature-gallery.md +++ b/docs/manual/feature-gallery.md @@ -31,8 +31,9 @@ The core job: turn a sliced file into an interactive picture of the actual moves order. Scrubbing is a draw-range trim, so it stays smooth even on large models.* Also here: **segment scrub** (step move-by-move), **time scrub** with an honest print-time estimate -(labeled *slicer estimate* vs *kinematic approximation*), and **source-line ↔ segment mapping** — click -a move to find its byte in the file, and back. See [recipes](recipes.md). +(labeled *slicer estimate* vs *kinematic approximation*), **hide by feature role** +(`setFeatureRoleVisible` — e.g. hide brim/skirt to declutter a part preview), and **source-line ↔ +segment mapping** — click a move to find its byte in the file, and back. See [recipes](recipes.md). ## Coloring & analysis diff --git a/packages/gcode-preview-core/src/controller.ts b/packages/gcode-preview-core/src/controller.ts index e6ec5034..5aeda5b9 100644 --- a/packages/gcode-preview-core/src/controller.ts +++ b/packages/gcode-preview-core/src/controller.ts @@ -43,6 +43,7 @@ import { segmentsCompletedAtTime, type Confidence, type DialectMetadata, + type FeatureRoleValue, type MachineGeometry, type MappedProgress, type ProgressMapper, @@ -181,6 +182,12 @@ export interface GcodePreviewControls { */ setScrubTime(ms: number | null): void; setKindVisible(kind: MoveKindToggle, visible: boolean): void; + /** + * Show/hide a single feature role — e.g. hide `Skirt`/`Brim` to declutter a part preview. Gate the + * UI on `capabilities.featureRoles === 'known'` (like the feature color mode); a no-op on the 2D + * renderer and on files without feature annotations. Import `FeatureRole` from `@chestnutlabs/toolpath-core`. + */ + setFeatureRoleVisible(role: FeatureRoleValue, visible: boolean): void; /** Opt-in retraction/deretraction markers (DD-009 D1, #148). Off by default. */ setShowRetractions(visible: boolean): void; setColorMode(mode: ColorMode): boolean; @@ -545,6 +552,7 @@ export function createPreviewController(options: PreviewControllerOptions = {}): withRenderer((r) => r.setScrubPosition(seg)); }, setKindVisible: (k, v) => withRenderer((r) => r.setKindVisible(k, v)), + setFeatureRoleVisible: (role, v) => withRenderer((r) => r.setFeatureRoleVisible?.(role, v)), setShowRetractions: (v) => withRenderer((r) => r.setShowRetractions(v)), // Availability is IR/renderer-dependent; before the renderer is ready we optimistically queue // and report true (the mode applies once bound). Callers can re-check after `parse-complete`. diff --git a/packages/gcode-preview-core/src/renderer-interface.ts b/packages/gcode-preview-core/src/renderer-interface.ts index 4d6a7555..e30bae9e 100644 --- a/packages/gcode-preview-core/src/renderer-interface.ts +++ b/packages/gcode-preview-core/src/renderer-interface.ts @@ -23,7 +23,7 @@ import type { } from '@chestnutlabs/gcode-renderer-three'; export type { CameraState, CameraView, CaptureOptions } from '@chestnutlabs/gcode-renderer-three'; -import type { MachineGeometry, MappedProgress, ToolpathIR } from '@chestnutlabs/toolpath-core'; +import type { FeatureRoleValue, MachineGeometry, MappedProgress, ToolpathIR } from '@chestnutlabs/toolpath-core'; /** Which renderer implementation backs the preview. `'3d'` is the default (Three.js). */ export type RendererMode = '2d' | '3d'; @@ -63,6 +63,11 @@ export interface PreviewRenderer { setLayerRange(startLayer: number, endLayer: number): void; setScrubPosition(segIndex: number | null): void; setKindVisible(kind: MoveKindToggle, visible: boolean): void; + /** + * Show/hide a single feature role (e.g. hide `Skirt`/`Brim`). 3D only — the flat 2D view treats it + * as a no-op. Optional so a renderer need not implement it; gate the UI on `featureRoles: 'known'`. + */ + setFeatureRoleVisible?(role: FeatureRoleValue, visible: boolean): void; setShowRetractions(visible: boolean): void; setColorMode(mode: ColorMode): boolean; setQuality(quality: QualityMode | 'auto'): void; diff --git a/packages/gcode-renderer-three/src/__tests__/feature-visibility.test.ts b/packages/gcode-renderer-three/src/__tests__/feature-visibility.test.ts new file mode 100644 index 00000000..ac7cc413 --- /dev/null +++ b/packages/gcode-renderer-three/src/__tests__/feature-visibility.test.ts @@ -0,0 +1,142 @@ +// @vitest-environment happy-dom +/** + * setFeatureRoleVisible (DD-009 extension, AnyBridge "hide adhesion" request): show/hide a single + * FeatureRole by collapsing its segments' vertices to NaN (GPU-discarded), reversibly. Unlike the + * whole-chunk move-kind toggle, feature roles live per-segment inside the extrusion geometry. + * + * Guarantees under test: hidden segments' positions go NaN; other segments untouched; showing again + * restores byte-for-byte; never called → geometry byte-identical (no base copy saved); travel geometry + * is unaffected (feature roles are an extrusion channel). + */ +import { describe, expect, it } from 'vitest'; +import { FeatureRole, MoveKind, ToolpathIRBuilder, type ToolpathIR } from '@chestnutlabs/toolpath-core'; +import { ToolpathRenderer, type GLRendererLike } from '../index.js'; + +/** One extrude segment per `roles[i]`, plus `travel` travel segments (feature roles don't apply). */ +function makeIR(roles: number[], travel = 0): ToolpathIR { + const b = new ToolpathIRBuilder({ parserVersion: 'test', units: 'mm', unitsSource: 'known' }); + b.setCapability('featureRoles', 'known'); + let src = 0; + for (let t = 0; t < travel; t++) { + b.addSegment({ + x0: 90 + t, + y0: 90, + z0: 0.2, + x1: 91 + t, + y1: 90, + z1: 0.2, + e: 0, + kind: MoveKind.Travel, + layer: 0, + srcByte: src++ * 10 + }); + } + for (let s = 0; s < roles.length; s++) { + b.addSegment({ + x0: 100 + s, + y0: 100, + z0: 0.2, + x1: 101 + s, + y1: 100, + z1: 0.2, + e: 1, + kind: MoveKind.Extrude, + layer: 0, + srcByte: src++ * 10, + feature: roles[s] + }); + } + return b.finalize(); +} + +function makeRenderer(): { renderer: ToolpathRenderer; run: () => void } { + const canvas = document.createElement('canvas'); + const stub: GLRendererLike = { + render: () => undefined, + setSize: () => undefined, + setPixelRatio: () => undefined, + dispose: () => undefined, + domElement: canvas + }; + const ticks: (() => void)[] = []; + const renderer = new ToolpathRenderer({ + canvas, + buildVolume: { x: 220, y: 220, z: 250 }, + chunksPerTick: 1, + quality: 'lines', // GL_LINES: 6 floats (2 vertices) per segment, in chunk order + createRenderer: () => stub, + scheduleFrame: (cb) => ticks.push(cb) + }); + const run = (): void => { + while (ticks.length > 0) ticks.shift()?.(); + }; + return { renderer, run }; +} + +/** The single extrude chunk mesh (lines quality → one LineSegments over all extrude segments). */ +function extrudeMesh(renderer: ToolpathRenderer) { + return renderer.chunkMeshes.find((m) => (m.userData.chunk as { kind: string } | undefined)?.kind === 'extrude')!; +} +const positions = (mesh: { geometry: { getAttribute: (n: string) => { array: ArrayLike } } }): Float32Array => + mesh.geometry.getAttribute('position').array as Float32Array; + +describe('setFeatureRoleVisible', () => { + it('hides one role (its segments → NaN) and leaves others intact; showing restores byte-for-byte', () => { + const { renderer, run } = makeRenderer(); + renderer.setIR(makeIR([FeatureRole.Skirt, FeatureRole.Perimeter, FeatureRole.Skirt, FeatureRole.Perimeter])); + run(); + const mesh = extrudeMesh(renderer); + const baseline = positions(mesh).slice(); // 4 segments × 6 floats = 24 + + renderer.setFeatureRoleVisible(FeatureRole.Skirt, false); + const hidden = positions(mesh); + // segments 0 and 2 (Skirt) → all 6 floats NaN; 1 and 3 (Perimeter) → unchanged + for (const seg of [0, 2]) for (let f = 0; f < 6; f++) expect(Number.isNaN(hidden[seg * 6 + f])).toBe(true); + for (const seg of [1, 3]) for (let f = 0; f < 6; f++) expect(hidden[seg * 6 + f]).toBe(baseline[seg * 6 + f]); + expect(renderer.getHiddenFeatureRoles()).toEqual([FeatureRole.Skirt]); + + renderer.setFeatureRoleVisible(FeatureRole.Skirt, true); + expect(Array.from(positions(mesh))).toEqual(Array.from(baseline)); // restored exactly + expect(renderer.getHiddenFeatureRoles()).toEqual([]); + }); + + it('hides multiple roles at once and un-hides them independently', () => { + const { renderer, run } = makeRenderer(); + renderer.setIR(makeIR([FeatureRole.Skirt, FeatureRole.Brim, FeatureRole.Perimeter])); + run(); + const mesh = extrudeMesh(renderer); + renderer.setFeatureRoleVisible(FeatureRole.Skirt, false); + renderer.setFeatureRoleVisible(FeatureRole.Brim, false); + const p = positions(mesh); + expect(Number.isNaN(p[0])).toBe(true); // skirt seg 0 + expect(Number.isNaN(p[6])).toBe(true); // brim seg 1 + expect(Number.isNaN(p[12])).toBe(false); // perimeter seg 2 visible + // un-hide brim only: seg 1 returns, seg 0 stays hidden + renderer.setFeatureRoleVisible(FeatureRole.Brim, true); + const p2 = positions(mesh); + expect(Number.isNaN(p2[0])).toBe(true); + expect(Number.isNaN(p2[6])).toBe(false); + }); + + it('never called → geometry is byte-identical (no NaN, no base copy retained)', () => { + const { renderer, run } = makeRenderer(); + renderer.setIR(makeIR([FeatureRole.Skirt, FeatureRole.Perimeter])); + run(); + const mesh = extrudeMesh(renderer); + expect(Array.from(positions(mesh)).some(Number.isNaN)).toBe(false); + expect(mesh.userData.baseFeaturePositions).toBeUndefined(); + }); + + it('does not touch travel geometry (feature roles are extrusion-only)', () => { + const { renderer, run } = makeRenderer(); + renderer.setIR(makeIR([FeatureRole.Skirt], 2)); + run(); + renderer.setFeatureRoleVisible(FeatureRole.Skirt, false); + const travel = renderer.chunkMeshes.find( + (m) => (m.userData.chunk as { kind: string } | undefined)?.kind === 'travel' + ); + expect(travel).toBeDefined(); + expect(Array.from(positions(travel!)).some(Number.isNaN)).toBe(false); + expect(travel!.userData.baseFeaturePositions).toBeUndefined(); + }); +}); diff --git a/packages/gcode-renderer-three/src/scene.ts b/packages/gcode-renderer-three/src/scene.ts index e46402dc..c463b671 100644 --- a/packages/gcode-renderer-three/src/scene.ts +++ b/packages/gcode-renderer-three/src/scene.ts @@ -42,7 +42,7 @@ import { Vector2, Vector3 } from 'three'; -import type { MachineGeometry, MappedProgress, ToolpathIR } from '@chestnutlabs/toolpath-core'; +import type { FeatureRoleValue, MachineGeometry, MappedProgress, ToolpathIR } from '@chestnutlabs/toolpath-core'; import { autoDecimation, buildChunks, type ChunkBuildResult, type GeometryChunk } from './chunks.js'; import { buildChunkColors, type ColorMode } from './colors.js'; import { computeDrawState, computeOverlayDrawStates } from './ranges.js'; @@ -358,6 +358,9 @@ export class ToolpathRenderer { private endLayer = Infinity; private scrubSegIndex: number | null = null; private kindVisible: Record = { extrude: true, travel: true, wipe: true }; + // Per-feature-role visibility (DD-009 extension): hidden roles are collapsed to NaN positions so the + // GPU discards them, reversibly. Empty by default → no work, geometry byte-identical when unused. + private hiddenFeatureRoles = new Set(); // Live-progress overlay state (DD-006 §4.5, phase 3): completed cut + ghost + marker/band. private progress: MappedProgress | null = null; private presentationMode: ProgressPresentationMode = 'hidden'; @@ -780,6 +783,7 @@ export class ToolpathRenderer { mesh.name = `chunk:${c.kind}:${c.layerStart}-${c.layerEnd}`; mesh.userData.chunk = c; this.applyDrawStateToMesh(mesh, c); + this.applyFeatureVisibilityToMesh(mesh, c); this.toolpathGroup.add(mesh); nonExtrude++; } @@ -860,6 +864,7 @@ export class ToolpathRenderer { mesh.userData.drawUnitsPerSegment = response.indicesPerSegment; mesh.userData.vertexSegment = vertexSegment; this.applyDrawStateToMesh(mesh, chunk); + this.applyFeatureVisibilityToMesh(mesh, chunk); return mesh; } @@ -993,6 +998,7 @@ export class ToolpathRenderer { mesh.name = `chunk:${chunk.kind}:${chunk.layerStart}-${chunk.layerEnd}`; mesh.userData.chunk = chunk; this.applyDrawStateToMesh(mesh, chunk); // honor clipping set during an in-flight build + this.applyFeatureVisibilityToMesh(mesh, chunk); // honor a hidden feature role, too this.toolpathGroup.add(mesh); this.builtCount++; built++; @@ -1197,6 +1203,78 @@ export class ToolpathRenderer { this.render(); } + /** + * Show or hide a single `FeatureRole` — e.g. hide `Skirt`/`Brim` to declutter a part preview, + * or isolate `Support`. Feature roles live per-segment inside the extrusion geometry (unlike move + * kinds, which are whole chunks), so hidden segments are collapsed to NaN positions and the GPU + * discards them; the original positions are restored when the role is shown again. + * + * Only meaningful when the file carried feature annotations (`capabilities.featureRoles === 'known'` + * — gate the UI on it, like the feature color mode); on an unannotated file every segment is + * `Unknown` and hiding a specific role is a no-op. Untouched geometry (no role hidden) is + * byte-identical to before this call existed. Interacts cleanly with layer clip and scrub. + */ + setFeatureRoleVisible(role: FeatureRoleValue, visible: boolean): void { + if (this.disposed) return; + if (visible) this.hiddenFeatureRoles.delete(role); + else this.hiddenFeatureRoles.add(role); + this.applyFeatureVisibility(); + this.render(); + } + + /** The feature roles currently hidden via {@link setFeatureRoleVisible} (for state persistence). */ + getHiddenFeatureRoles(): FeatureRoleValue[] { + return [...this.hiddenFeatureRoles] as FeatureRoleValue[]; + } + + private applyFeatureVisibility(): void { + for (const mesh of this.chunkMeshes) { + const chunk = mesh.userData.chunk as GeometryChunk | undefined; + if (chunk) this.applyFeatureVisibilityToMesh(mesh, chunk); + } + } + + /** + * Collapse hidden-feature-role segments in one extrude mesh to NaN (GPU-discarded), restoring from a + * lazily-saved base copy when nothing is hidden. No-op for travel/wipe chunks (feature roles are an + * extrusion channel) and when no role is hidden and none ever was on this mesh — so unused = free. + */ + private applyFeatureVisibilityToMesh(mesh: LineSegments | Mesh, chunk: GeometryChunk): void { + if (this.ir === null || chunk.kind !== 'extrude') return; + const posAttr = (mesh.geometry as BufferGeometry).getAttribute('position') as BufferAttribute; + const arr = posAttr.array as Float32Array; + let base = mesh.userData.baseFeaturePositions as Float32Array | undefined; + if (this.hiddenFeatureRoles.size === 0) { + if (base !== undefined) { + arr.set(base); // restore, then release the base copy + posAttr.needsUpdate = true; + mesh.userData.baseFeaturePositions = undefined; + } + return; + } + if (base === undefined) { + base = arr.slice(); + mesh.userData.baseFeaturePositions = base; + } + const feature = this.ir.segments.feature; + const vertexSegment = mesh.userData.vertexSegment as Uint32Array | undefined; + const vcount = arr.length / 3; + for (let v = 0; v < vcount; v++) { + const inChunk = vertexSegment !== undefined ? vertexSegment[v] : v >> 1; + const globalSeg = chunk.segIndices[inChunk]; + if (this.hiddenFeatureRoles.has(feature[globalSeg])) { + arr[v * 3] = NaN; + arr[v * 3 + 1] = NaN; + arr[v * 3 + 2] = NaN; + } else { + arr[v * 3] = base[v * 3]; + arr[v * 3 + 1] = base[v * 3 + 1]; + arr[v * 3 + 2] = base[v * 3 + 2]; + } + } + posAttr.needsUpdate = true; + } + /** * Capability gate (§4.6): feature coloring needs the IR to actually carry * feature roles — `featureRoles: 'unavailable'` means the UI must disable it, diff --git a/packages/toolpath-core/src/ir.ts b/packages/toolpath-core/src/ir.ts index c42f746c..dd54ae87 100644 --- a/packages/toolpath-core/src/ir.ts +++ b/packages/toolpath-core/src/ir.ts @@ -72,6 +72,8 @@ export const FeatureRole = { Purge: 14 } as const; export type FeatureRoleName = keyof typeof FeatureRole; +/** A {@link FeatureRole} value (the numeric code stored per segment in `segments.feature`). */ +export type FeatureRoleValue = (typeof FeatureRole)[FeatureRoleName]; export interface Warning { code: string; diff --git a/tools/demo/index.html b/tools/demo/index.html index fd3939b2..014e72ad 100644 --- a/tools/demo/index.html +++ b/tools/demo/index.html @@ -217,6 +217,10 @@

Show retractions +