Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/feature-role-visibility.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions docs/VISUAL_FEATURE_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions docs/manual/feature-gallery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions packages/gcode-preview-core/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
segmentsCompletedAtTime,
type Confidence,
type DialectMetadata,
type FeatureRoleValue,
type MachineGeometry,
type MappedProgress,
type ProgressMapper,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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`.
Expand Down
7 changes: 6 additions & 1 deletion packages/gcode-preview-core/src/renderer-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<number> } } }): 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();
});
});
80 changes: 79 additions & 1 deletion packages/gcode-renderer-three/src/scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -358,6 +358,9 @@ export class ToolpathRenderer {
private endLayer = Infinity;
private scrubSegIndex: number | null = null;
private kindVisible: Record<GeometryChunk['kind'], boolean> = { 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<number>();
// Live-progress overlay state (DD-006 §4.5, phase 3): completed cut + ghost + marker/band.
private progress: MappedProgress | null = null;
private presentationMode: ProgressPresentationMode = 'hidden';
Expand Down Expand Up @@ -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++;
}
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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++;
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/toolpath-core/src/ir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions tools/demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,10 @@ <h1>
<input id="retractions" type="checkbox" disabled />
Show retractions
</label>
<label class="inline">
<input id="hideAdhesion" type="checkbox" disabled />
Hide brim / skirt (declutter)
</label>
<label>
Color mode
<select id="colorMode" disabled>
Expand Down
Loading
Loading