Skip to content

feat(viewer): click a 3D part to insert it as an @mention, with exploded view - #261

Open
philippecottier wants to merge 7 commits into
Adam-CAD:masterfrom
philippecottier:feature/click-part-picker
Open

philippecottier wants to merge 7 commits into
Adam-CAD:masterfrom
philippecottier:feature/click-part-picker

Conversation

@philippecottier

@philippecottier philippecottier commented Sep 16, 2026

Copy link
Copy Markdown

What

Viewer features built around the generated model's named parts (from the AMF lazy-union output).

  • Parts data layer — parse the AMF output into named, colored parts.
  • Clickable parts — hovering highlights a part; clicking selects it and inserts it into the prompt as an @part mention, so the next instruction can target a specific part. Selected parts get a stronger accent highlight.
  • Exploded view — a slider spreads the parts apart, eased per-frame so dragging stays smooth.
  • Robust picking — the pickable group is remounted when its geometry is rebuilt, so selection keeps working after the model regenerates.

Notes

  • No new dependencies.
  • tsc -b, eslint, and prettier all pass.

Summary by cubic

Adds clickable 3D parts to the viewer so you can target specific parts in the prompt. Hovering highlights a part, clicking inserts an @part mention, and an exploded-view slider spreads parts apart. No new dependencies.

New Features

  • Parses AMF output into named, colored parts mapped from the source code.
  • Selected parts get an accent highlight; the pickable group remounts on geometry rebuild so picking survives regeneration.
  • Exploded-view slider uses per-frame easing for smooth dragging.

Written for commit 14c33cb. Summary will update on new commits.

Review in cubic

@vercel

vercel Bot commented Sep 16, 2026

Copy link
Copy Markdown

@philippecottier is attempting to deploy a commit to the Adam Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps

greptile-apps Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

The PR is not yet safe to merge because ordinary unannotated OpenSCAD source can assign the wrong names to clickable geometry, and the explicit no-casting repository requirement is also unsatisfied.

Findings

  1. P1 Module declarations become parts
  2. P2 Selection survives group replacement
  3. P2 Material types rely on casts

Summary

This PR adds named-part extraction from AMF output, renders those parts as individually pickable meshes, inserts selected names into the chat composer, and adds an animated exploded-view control.

  • Extends OpenSCAD preview compilation with an AMF companion output.
  • Parses AMF geometry and derives part names and colors from generated OpenSCAD source.
  • Adds hover and selection highlighting plus exploded positioning in the Three.js scene.
  • Connects viewer selection to the chat input through a small event bus.
  • The fallback source scanner currently corrupts part ordering for ordinary unannotated module-based source, and regeneration does not fully reset selection state.

Reviews (1) · Last reviewed commit: "fix(viewer): remonte la primitive Pickab..."

Comment thread src/utils/partsFromAmf.ts
Comment on lines +99 to +100
if (m && m.index === i && modules.has(m[1])) {
calls.push(m[1]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Module declarations become parts

For ordinary unannotated OpenSCAD source, this scanner also treats the name inside each top-level module foo(...) declaration as a module call. If declaration order differs from the order of the actual top-level calls, AMF objects receive the wrong names and colors by index. Clicking a visible part then inserts an @mention for a different part.

Comment on lines +270 to +273
if (mountedPartsGroupRef.current)
disposeColoredGroup(mountedPartsGroupRef.current);
mountedPartsGroupRef.current = group;
setPartsGroup(group);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Selection survives group replacement

A successful regeneration disposes the selected mesh and installs a new parts group without clearing selectedPart. PickableParts also retains its reference to the disposed selection because only the inner primitive is keyed. The rebuilt model therefore has no selected highlight while the viewer continues to show the old part name.

Comment on lines +31 to +34
function emissiveOf(object: THREE.Object3D | null): THREE.Color | null {
const material = (object as THREE.Mesh | null)?.material;
if (material && !Array.isArray(material) && 'emissive' in material) {
return (material as THREE.MeshStandardMaterial).emissive;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Material types rely on casts

These helpers cast an arbitrary Object3D to Mesh and then cast its material to MeshStandardMaterial; the same pattern is repeated in materialOf, the parts traversal, and the selection handler. This violates the repository directive to avoid type casting and make types correct from the beginning. The requirement must be satisfied before merging by narrowing the Three.js objects safely or typing the pickable objects as meshes.

Rule Used: Avoid type casting and ensure types are correct from the beginning rather than casting to fix type mismatches. (source)

Learned From
Adam-CAD/desktop-backend#3

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10 issues found across 8 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/components/viewer/OpenSCADViewer.tsx">

<violation number="1" location="src/components/viewer/OpenSCADViewer.tsx:265">
P2: When `scadCode` changes before the new worker response arrives, this line maps the retained previous AMF geometry to the new source. Invalidate the old AMF on compile start or associate each AMF blob with the source revision that produced it before building the pickable group.</violation>

<violation number="2" location="src/components/viewer/OpenSCADViewer.tsx:273">
P2: When a new non-empty AMF result replaces a selected model, this branch leaves the old part name in the overlay until the user selects another part. Clear `selectedPart` when installing the replacement group.</violation>
</file>

<file name="src/utils/amfParser.ts">

<violation number="1" location="src/utils/amfParser.ts:40">
P2: When AMF contains an empty `<object>` before a non-empty one, this `continue` shifts `buildPartsGroup`'s `parts[i]` mapping, so names, colors, and @mentions target the wrong geometry. Preserve an empty placeholder for each object, or carry the original object index instead of filtering it here.</violation>
</file>

<file name="src/utils/partsFromAmf.ts">

<violation number="1" location="src/utils/partsFromAmf.ts:72">
P2: When a part is colored by wrapping its top-level module call, `moduleColorMap` misses the source color and the viewer replaces it with the fallback palette. Capture the color wrapper while parsing each top-level part call, not only colors inside module definitions.</violation>

<violation number="2" location="src/utils/partsFromAmf.ts:89">
P1: When SCAD defines helper modules before its top-level geometry, this scanner records the declarations as part calls. That shifts AMF objects onto helper names and can make click-to-mention select the wrong part; skip module declaration headers when collecting calls.</violation>

<violation number="3" location="src/utils/partsFromAmf.ts:135">
P3: Passing `fallbackColor` never affects a mesh because every index selects an existing palette entry first. Use the caller-provided fallback when supplied, or remove this unreachable parameter and branch.</violation>
</file>

<file name="src/worker/openSCAD.ts">

<violation number="1" location="src/worker/openSCAD.ts:247">
P2: This adds a third `-o` output (/out.amf) but the comment directly above still documents multi-output support as "two -o flags (/out.stl + /out.off)" that were verified against the 2025.03.25 vendored WASM, with a warning to re-verify if that build changes. Three simultaneous exports are a new, unverified case; if the vendored build rejects the third output or the AMF format, `callMain` returns non-zero and the whole preview throws `OpenSCADError` ("Adam did not exit correctly"), regressing all previews, not just the new parts feature. Update the comment to describe three outputs and confirm the vendored WASM accepts three `-o` flags / AMF export.</violation>
</file>

<file name="src/components/viewer/ThreeScene.tsx">

<violation number="1" location="src/components/viewer/ThreeScene.tsx:121">
P2: When a regenerated `partsGroup` arrives while the slider is settled above zero, the new meshes remain collapsed even though the slider still shows the exploded value. Apply the settled target to the current `spreadDirections`, or reset `appliedExplodeRef` whenever `group` changes.</violation>

<violation number="2" location="src/components/viewer/ThreeScene.tsx:146">
P3: If the viewer is removed while a part is hovered, the global body cursor can remain `pointer` across the rest of the application because cleanup only happens in `handleOut`. Restore the previous cursor in an effect cleanup, or manage the cursor on the viewer element instead of `document.body`.</violation>

<violation number="3" location="src/components/viewer/ThreeScene.tsx:178">
P2: Starting an OrbitControls rotation or right-clicking a part immediately selects it and inserts an `@part` mention because selection runs on `onPointerDown`. Select on a primary `onClick`, or track movement and the button before committing the selection.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/utils/partsFromAmf.ts
[...code.matchAll(/module\s+(\w+)\s*\(/g)].map((m) => m[1]),
);
const calls: string[] = [];
const callRe = /(\w+)\s*\(/g;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When SCAD defines helper modules before its top-level geometry, this scanner records the declarations as part calls. That shifts AMF objects onto helper names and can make click-to-mention select the wrong part; skip module declaration headers when collecting calls.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/utils/partsFromAmf.ts, line 89:

<comment>When SCAD defines helper modules before its top-level geometry, this scanner records the declarations as part calls. That shifts AMF objects onto helper names and can make click-to-mention select the wrong part; skip module declaration headers when collecting calls.</comment>

<file context>
@@ -0,0 +1,153 @@
+    [...code.matchAll(/module\s+(\w+)\s*\(/g)].map((m) => m[1]),
+  );
+  const calls: string[] = [];
+  const callRe = /(\w+)\s*\(/g;
+  let depth = 0;
+  let i = 0;
</file context>

if (mountedPartsGroupRef.current)
disposeColoredGroup(mountedPartsGroupRef.current);
mountedPartsGroupRef.current = group;
setPartsGroup(group);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a new non-empty AMF result replaces a selected model, this branch leaves the old part name in the overlay until the user selects another part. Clear selectedPart when installing the replacement group.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/components/viewer/OpenSCADViewer.tsx, line 273:

<comment>When a new non-empty AMF result replaces a selected model, this branch leaves the old part name in the overlay until the user selects another part. Clear `selectedPart` when installing the replacement group.</comment>

<file context>
@@ -232,13 +238,61 @@ export function OpenSCADPreview({
+        if (mountedPartsGroupRef.current)
+          disposeColoredGroup(mountedPartsGroupRef.current);
+        mountedPartsGroupRef.current = group;
+        setPartsGroup(group);
+      })
+      .catch((err) => {
</file context>
Suggested change
setPartsGroup(group);
setSelectedPart(null);
setPartsGroup(group);

.text()
.then((text) => {
if (cancelled) return;
const group = buildPartsGroup(text, partsFromScad(scadCode));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When scadCode changes before the new worker response arrives, this line maps the retained previous AMF geometry to the new source. Invalidate the old AMF on compile start or associate each AMF blob with the source revision that produced it before building the pickable group.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/components/viewer/OpenSCADViewer.tsx, line 265:

<comment>When `scadCode` changes before the new worker response arrives, this line maps the retained previous AMF geometry to the new source. Invalidate the old AMF on compile start or associate each AMF blob with the source revision that produced it before building the pickable group.</comment>

<file context>
@@ -232,13 +238,61 @@ export function OpenSCADPreview({
+      .text()
+      .then((text) => {
+        if (cancelled) return;
+        const group = buildPartsGroup(text, partsFromScad(scadCode));
+        if (group.children.length === 0) {
+          if (!cancelled) clearPartsGroup();
</file context>

Comment thread src/utils/amfParser.ts
indices.push(Number(t[1]), Number(t[2]), Number(t[3]));
}

if (coords.length === 0 || indices.length === 0) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When AMF contains an empty <object> before a non-empty one, this continue shifts buildPartsGroup's parts[i] mapping, so names, colors, and @mentions target the wrong geometry. Preserve an empty placeholder for each object, or carry the original object index instead of filtering it here.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/utils/amfParser.ts, line 40:

<comment>When AMF contains an empty `<object>` before a non-empty one, this `continue` shifts `buildPartsGroup`'s `parts[i]` mapping, so names, colors, and @mentions target the wrong geometry. Preserve an empty placeholder for each object, or carry the original object index instead of filtering it here.</comment>

<file context>
@@ -0,0 +1,50 @@
+      indices.push(Number(t[1]), Number(t[2]), Number(t[3]));
+    }
+
+    if (coords.length === 0 || indices.length === 0) continue;
+
+    objects.push({
</file context>

Comment thread src/utils/partsFromAmf.ts
function moduleColorMap(code: string): Map<string, string> {
const defaults = colorParamDefaults(code);
const map = new Map<string, string>();
for (const m of code.matchAll(/module\s+(\w+)\s*\([^)]*\)\s*\{/g)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a part is colored by wrapping its top-level module call, moduleColorMap misses the source color and the viewer replaces it with the fallback palette. Capture the color wrapper while parsing each top-level part call, not only colors inside module definitions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/utils/partsFromAmf.ts, line 72:

<comment>When a part is colored by wrapping its top-level module call, `moduleColorMap` misses the source color and the viewer replaces it with the fallback palette. Capture the color wrapper while parsing each top-level part call, not only colors inside module definitions.</comment>

<file context>
@@ -0,0 +1,153 @@
+function moduleColorMap(code: string): Map<string, string> {
+  const defaults = colorParamDefaults(code);
+  const map = new Map<string, string>();
+  for (const m of code.matchAll(/module\s+(\w+)\s*\([^)]*\)\s*\{/g)) {
+    const openBrace = (m.index ?? 0) + m[0].length - 1;
+    const arg = /color\s*\(\s*(?:"([^"]+)"|(\w+))/.exec(
</file context>

Comment thread src/worker/openSCAD.ts
[{ path: '/out.off', key: 'off' }],
[
{ path: '/out.off', key: 'off' },
{ path: '/out.amf', key: 'amf' },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This adds a third -o output (/out.amf) but the comment directly above still documents multi-output support as "two -o flags (/out.stl + /out.off)" that were verified against the 2025.03.25 vendored WASM, with a warning to re-verify if that build changes. Three simultaneous exports are a new, unverified case; if the vendored build rejects the third output or the AMF format, callMain returns non-zero and the whole preview throws OpenSCADError ("Adam did not exit correctly"), regressing all previews, not just the new parts feature. Update the comment to describe three outputs and confirm the vendored WASM accepts three -o flags / AMF export.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/worker/openSCAD.ts, line 247:

<comment>This adds a third `-o` output (/out.amf) but the comment directly above still documents multi-output support as "two -o flags (/out.stl + /out.off)" that were verified against the 2025.03.25 vendored WASM, with a warning to re-verify if that build changes. Three simultaneous exports are a new, unverified case; if the vendored build rejects the third output or the AMF format, `callMain` returns non-zero and the whole preview throws `OpenSCADError` ("Adam did not exit correctly"), regressing all previews, not just the new parts feature. Update the comment to describe three outputs and confirm the vendored WASM accepts three `-o` flags / AMF export.</comment>

<file context>
@@ -242,7 +242,10 @@ class OpenSCADWrapper {
-      [{ path: '/out.off', key: 'off' }],
+      [
+        { path: '/out.off', key: 'off' },
+        { path: '/out.amf', key: 'amf' },
+      ],
     );
</file context>

position={offset.toArray()}
onPointerMove={handleMove}
onPointerOut={handleOut}
onPointerDown={handleDown}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Starting an OrbitControls rotation or right-clicking a part immediately selects it and inserts an @part mention because selection runs on onPointerDown. Select on a primary onClick, or track movement and the button before committing the selection.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/components/viewer/ThreeScene.tsx, line 178:

<comment>Starting an OrbitControls rotation or right-clicking a part immediately selects it and inserts an `@part` mention because selection runs on `onPointerDown`. Select on a primary `onClick`, or track movement and the button before committing the selection.</comment>

<file context>
@@ -18,6 +19,166 @@ interface ThreeSceneProps {
+        position={offset.toArray()}
+        onPointerMove={handleMove}
+        onPointerOut={handleOut}
+        onPointerDown={handleDown}
+      />
+    </group>
</file context>
Suggested change
onPointerDown={handleDown}
onClick={(event) =>
handleDown(event as unknown as ThreeEvent<PointerEvent>)}

Comment on lines +121 to +129
if (Math.abs(diff) < 0.0005) {
if (current !== explode) {
appliedExplodeRef.current = explode;
for (const { mesh, dir } of spreadDirections) {
mesh.position.copy(dir).multiplyScalar(explode);
}
}
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a regenerated partsGroup arrives while the slider is settled above zero, the new meshes remain collapsed even though the slider still shows the exploded value. Apply the settled target to the current spreadDirections, or reset appliedExplodeRef whenever group changes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/components/viewer/ThreeScene.tsx, line 121:

<comment>When a regenerated `partsGroup` arrives while the slider is settled above zero, the new meshes remain collapsed even though the slider still shows the exploded value. Apply the settled target to the current `spreadDirections`, or reset `appliedExplodeRef` whenever `group` changes.</comment>

<file context>
@@ -18,6 +19,166 @@ interface ThreeSceneProps {
+  useFrame((_, delta) => {
+    const current = appliedExplodeRef.current;
+    const diff = explode - current;
+    if (Math.abs(diff) < 0.0005) {
+      if (current !== explode) {
+        appliedExplodeRef.current = explode;
</file context>
Suggested change
if (Math.abs(diff) < 0.0005) {
if (current !== explode) {
appliedExplodeRef.current = explode;
for (const { mesh, dir } of spreadDirections) {
mesh.position.copy(dir).multiplyScalar(explode);
}
}
return;
}
if (Math.abs(diff) < 0.0005) {
appliedExplodeRef.current = explode;
for (const { mesh, dir } of spreadDirections) {
mesh.position.copy(dir).multiplyScalar(explode);
}
return;
}

Comment thread src/utils/partsFromAmf.ts
const info = parts[i];
const colorValue: string | number =
info?.color ??
FALLBACK_PALETTE[i % FALLBACK_PALETTE.length] ??

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Passing fallbackColor never affects a mesh because every index selects an existing palette entry first. Use the caller-provided fallback when supplied, or remove this unreachable parameter and branch.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/utils/partsFromAmf.ts, line 135:

<comment>Passing `fallbackColor` never affects a mesh because every index selects an existing palette entry first. Use the caller-provided fallback when supplied, or remove this unreachable parameter and branch.</comment>

<file context>
@@ -0,0 +1,153 @@
+    const info = parts[i];
+    const colorValue: string | number =
+      info?.color ??
+      FALLBACK_PALETTE[i % FALLBACK_PALETTE.length] ??
+      fallbackColor;
+
</file context>

}
hovered.current = mesh;
if (mesh !== selected.current) paint(mesh, HOVER_EMISSIVE);
document.body.style.cursor = 'pointer';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: If the viewer is removed while a part is hovered, the global body cursor can remain pointer across the rest of the application because cleanup only happens in handleOut. Restore the previous cursor in an effect cleanup, or manage the cursor on the viewer element instead of document.body.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/components/viewer/ThreeScene.tsx, line 146:

<comment>If the viewer is removed while a part is hovered, the global body cursor can remain `pointer` across the rest of the application because cleanup only happens in `handleOut`. Restore the previous cursor in an effect cleanup, or manage the cursor on the viewer element instead of `document.body`.</comment>

<file context>
@@ -18,6 +19,166 @@ interface ThreeSceneProps {
+    }
+    hovered.current = mesh;
+    if (mesh !== selected.current) paint(mesh, HOVER_EMISSIVE);
+    document.body.style.cursor = 'pointer';
+  };
+
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant