Skip to content
14 changes: 14 additions & 0 deletions src/components/TextAreaChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import React, {
useMemo,
useCallback,
} from 'react';
import { onPartMention } from '@/lib/partMention';
import {
ArrowUp,
ImagePlus,
Expand Down Expand Up @@ -477,6 +478,19 @@ function TextAreaChat({
const [isFocused, setIsFocused] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [input, setInput] = useState('');

// When the user clicks a part in the 3D viewer, append an @mention to the
// prompt. The viewer emits via the partMention bus, decoupled from this
// component's private input state.
useEffect(() => {
return onPartMention((name) => {
setInput((prev) => {
const needsSpace = prev.length > 0 && !prev.endsWith(' ');
return `${prev}${needsSpace ? ' ' : ''}@${name} `;
});
textareaRef.current?.focus();
});
}, []);
const [isDragging, setIsDragging] = useState(false);
const [isDragHover, setIsDragHover] = useState(false);
const [isGeneratingPrompt, setIsGeneratingPrompt] = useState(false);
Expand Down
66 changes: 65 additions & 1 deletion src/components/viewer/OpenSCADViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
buildColoredGroupFromOff,
disposeColoredGroup,
} from '@/utils/coloredOffMesh';
import { buildPartsGroup, partsFromScad } from '@/utils/partsFromAmf';
import { emitPartMention } from '@/lib/partMention';
import { Button } from '@/components/ui/button';
import OpenSCADError from '@/lib/OpenSCADError';
import { cn } from '@/lib/utils';
Expand Down Expand Up @@ -61,18 +63,22 @@ export function OpenSCADPreview({
isCompiling,
output,
offOutput,
amfOutput,
isError,
error,
} = useOpenSCAD();
const [geometry, setGeometry] = useState<BufferGeometry | null>(null);
const [coloredGroup, setColoredGroup] = useState<Group | null>(null);
const [partsGroup, setPartsGroup] = useState<Group | null>(null);
const [selectedPart, setSelectedPart] = useState<string | null>(null);
// Use context directly to avoid throwing if provider is not mounted (e.g. VisualCard)
const meshFilesCtx = useContext(MeshFilesContext);
// Track which files we've written to avoid re-writing unchanged blobs
const writtenFilesRef = useRef<Map<string, Blob>>(new Map());
// Hold on to the last colored group so its meshes' GPU resources can be
// released when a new compile replaces it (or the component unmounts).
const mountedGroupRef = useRef<Group | null>(null);
const mountedPartsGroupRef = useRef<Group | null>(null);
// Same story for the STL-path BufferGeometry — every compile produces a
// fresh one, and even when OFF wins the render the STL still parses, so
// the previous geometry's VRAM must be released on replacement.
Expand Down Expand Up @@ -232,13 +238,61 @@ export function OpenSCADPreview({
};
}, [offOutput]);

// Build the per-part group from the AMF companion + the source code. AMF
// keeps lazy-union top-level parts as separate objects; partsFromScad maps
// their source order to names and colors. This is what click-to-select uses.
useEffect(() => {
let cancelled = false;

const clearPartsGroup = () => {
if (mountedPartsGroupRef.current) {
disposeColoredGroup(mountedPartsGroupRef.current);
mountedPartsGroupRef.current = null;
}
setPartsGroup(null);
setSelectedPart(null);
};

if (!(amfOutput instanceof Blob) || !scadCode) {
clearPartsGroup();
return;
}

amfOutput
.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>

if (group.children.length === 0) {
if (!cancelled) clearPartsGroup();
return;
}
if (mountedPartsGroupRef.current)
disposeColoredGroup(mountedPartsGroupRef.current);
mountedPartsGroupRef.current = group;
setPartsGroup(group);
Comment on lines +270 to +273

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.

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);

})
.catch((err) => {
console.error('[OpenSCAD] Failed to parse AMF parts:', err);
if (!cancelled) clearPartsGroup();
});

return () => {
cancelled = true;
};
}, [amfOutput, scadCode]);

// Release the last mounted group's and geometry's GPU resources on unmount.
useEffect(() => {
return () => {
if (mountedGroupRef.current) {
disposeColoredGroup(mountedGroupRef.current);
mountedGroupRef.current = null;
}
if (mountedPartsGroupRef.current) {
disposeColoredGroup(mountedPartsGroupRef.current);
mountedPartsGroupRef.current = null;
}
if (mountedGeometryRef.current) {
mountedGeometryRef.current.dispose();
mountedGeometryRef.current = null;
Expand All @@ -249,15 +303,25 @@ export function OpenSCADPreview({
return (
<div className="relative h-full w-full bg-adam-neutral-700/50 transition-all duration-300 ease-in-out">
<div className="h-full w-full">
{geometry || coloredGroup ? (
{geometry || coloredGroup || partsGroup ? (
<div className="h-full w-full">
<ThreeScene
geometry={geometry}
coloredGroup={coloredGroup}
partsGroup={partsGroup}
onSelectPart={(name) => {
setSelectedPart(name);
if (name) emitPartMention(name);
}}
color={color}
isMobile={isMobile}
backgroundColor={backgroundColor}
/>
{selectedPart && (
<div className="pointer-events-none absolute left-2 top-2 rounded bg-adam-neutral-800/80 px-2 py-1 text-xs text-adam-text-primary">
{selectedPart}
</div>
)}
</div>
) : (
<>
Expand Down
200 changes: 197 additions & 3 deletions src/components/viewer/ThreeScene.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Canvas } from '@react-three/fiber';
import { Canvas, ThreeEvent, useFrame } from '@react-three/fiber';
import {
OrbitControls,
Stage,
Expand All @@ -7,9 +7,10 @@ import {
PerspectiveCamera,
} from '@react-three/drei';
import * as THREE from 'three';
import { Suspense, useMemo, useState } from 'react';
import { Suspense, useMemo, useRef, useState } from 'react';
import { OrthographicPerspectiveToggle } from '@/components/viewer/OrthographicPerspectiveToggle';
import { ViewGizmo } from '@/components/viewer/ViewGizmo';
import { Slider } from '@/components/ui/slider';
import { cn } from '@/lib/utils';

interface ThreeSceneProps {
Expand All @@ -18,6 +19,166 @@ interface ThreeSceneProps {
isMobile?: boolean;
backgroundColor?: string;
coloredGroup?: THREE.Group | null;
partsGroup?: THREE.Group | null;
onSelectPart?: (name: string | null) => void;
}

const HOVER_EMISSIVE = 0x3a4a5c;
const SELECT_COLOR = 0x00a6ff;
const SELECT_EMISSIVE = 0x0a4a8f;
const NO_EMISSIVE = 0x000000;

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;
Comment on lines +31 to +34

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!

}
return null;
}

function paint(object: THREE.Object3D | null, hex: number): void {
emissiveOf(object)?.setHex(hex);
}

function materialOf(
object: THREE.Object3D | null,
): THREE.MeshStandardMaterial | null {
const material = (object as THREE.Mesh | null)?.material;
if (material && !Array.isArray(material) && 'emissive' in material) {
return material as THREE.MeshStandardMaterial;
}
return null;
}

// A selected part is recolored to a clear accent — an emissive tint alone reads
// weakly on light models. The part's own color is stored so it can be restored.
function selectHighlight(object: THREE.Object3D | null): void {
const material = materialOf(object);
if (!material || !object) return;
if (object.userData.baseColorHex === undefined) {
object.userData.baseColorHex = material.color.getHex();
}
material.color.setHex(SELECT_COLOR);
material.emissive.setHex(SELECT_EMISSIVE);
}

function clearHighlight(object: THREE.Object3D | null): void {
const material = materialOf(object);
if (!material || !object) return;
const base = object.userData.baseColorHex;
if (typeof base === 'number') material.color.setHex(base);
material.emissive.setHex(NO_EMISSIVE);
}

/**
* Render the per-part AMF group and let the user hover/click a part. Hover
* tints the hit part; clicking selects it and reports its name upward. The
* group is a raw THREE object (not R3F-managed), so highlighting mutates each
* mesh's emissive directly — cheap and reversible.
*/
function PickableParts({
group,
offset,
explode,
onSelectPart,
}: {
group: THREE.Group;
offset: THREE.Vector3;
explode: number;
onSelectPart?: (name: string | null) => void;
}) {
const hovered = useRef<THREE.Object3D | null>(null);
const selected = useRef<THREE.Object3D | null>(null);

// Exploded view: push each part outward along the vector from the parts'
// collective centre to the part's own centre (group-local frame).
const spreadDirections = useMemo(() => {
const combined = new THREE.Box3();
const centres: { mesh: THREE.Object3D; centre: THREE.Vector3 }[] = [];
for (const child of group.children) {
const mesh = child as THREE.Mesh;
if (!mesh.geometry) continue;
mesh.geometry.computeBoundingBox();
const box = mesh.geometry.boundingBox;
if (!box) continue;
combined.union(box);
centres.push({ mesh, centre: box.getCenter(new THREE.Vector3()) });
}
const origin = combined.getCenter(new THREE.Vector3());
return centres.map(({ mesh, centre }) => ({
mesh,
dir: centre.clone().sub(origin),
}));
}, [group]);

// Exploded view animation: ease the applied offset toward the slider
// target every frame (frame-rate independent) so dragging looks smooth
// instead of stepping through the discrete slider values.
const appliedExplodeRef = useRef(0);
useFrame((_, delta) => {
const current = appliedExplodeRef.current;
const diff = explode - current;
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;
}
Comment on lines +121 to +129

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;
}

const next = current + diff * (1 - Math.exp(-delta * 12));
appliedExplodeRef.current = next;
for (const { mesh, dir } of spreadDirections) {
mesh.position.copy(dir).multiplyScalar(next);
}
});

const handleMove = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation();
const mesh = event.object;
if (hovered.current === mesh) return;
if (hovered.current && hovered.current !== selected.current) {
paint(hovered.current, NO_EMISSIVE);
}
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>

};

const handleOut = () => {
if (hovered.current && hovered.current !== selected.current) {
paint(hovered.current, NO_EMISSIVE);
}
hovered.current = null;
document.body.style.cursor = 'auto';
};

const handleDown = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation();
const mesh = event.object;
if (selected.current && selected.current !== mesh) {
clearHighlight(selected.current);
}
selected.current = mesh;
selectHighlight(mesh);
const name =
(mesh.userData.partName as string | undefined) ?? mesh.name ?? null;
onSelectPart?.(name);
};

return (
<group rotation={[-Math.PI / 2, 0, 0]}>
<primitive
key={group.uuid}
object={group}
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>)}

/>
</group>
);
}

export function ThreeScene({
Expand All @@ -26,8 +187,11 @@ export function ThreeScene({
isMobile = false,
backgroundColor = '#3B3B3B',
coloredGroup,
partsGroup,
onSelectPart,
}: ThreeSceneProps) {
const [isOrthographic, setIsOrthographic] = useState(true);
const [explode, setExplode] = useState(0);

// Store the initial isMobile value to prevent position changes during resize
const [initialIsMobile] = useState(isMobile);
Expand All @@ -42,6 +206,13 @@ export function ThreeScene({
return box.getCenter(new THREE.Vector3()).negate();
}, [coloredGroup]);

const partsCenterOffset = useMemo(() => {
if (!partsGroup) return null;
const box = new THREE.Box3().setFromObject(partsGroup);
if (box.isEmpty()) return new THREE.Vector3();
return box.getCenter(new THREE.Vector3()).negate();
}, [partsGroup]);

return (
<div className="relative h-full w-full overflow-hidden">
{/* Local Suspense boundary — `<Canvas>` re-throws suspension upward
Expand Down Expand Up @@ -80,7 +251,14 @@ export function ThreeScene({
<directionalLight position={[-5, 5, -5]} intensity={0.2} />
<directionalLight position={[0, 5, 0]} intensity={0.2} />
<directionalLight position={[-5, -5, -5]} intensity={0.6} />
{coloredGroup && groupCenterOffset ? (
{partsGroup && partsCenterOffset ? (
<PickableParts
group={partsGroup}
offset={partsCenterOffset}
explode={explode}
onSelectPart={onSelectPart}
/>
) : coloredGroup && groupCenterOffset ? (
<group rotation={[-Math.PI / 2, 0, 0]}>
<primitive
object={coloredGroup}
Expand Down Expand Up @@ -123,6 +301,22 @@ export function ThreeScene({
</Canvas>
</Suspense>

{partsGroup && partsGroup.children.length > 1 && (
<div className="absolute bottom-2 left-2 flex w-44 items-center gap-2 rounded-md bg-adam-neutral-800/80 px-3 py-2">
<span className="whitespace-nowrap text-xs text-adam-text-primary/80">
Éclaté
</span>
<Slider
value={[explode]}
onValueChange={(v) => setExplode(v[0] ?? 0)}
min={0}
max={1.5}
step={0.05}
aria-label="Vue éclatée"
/>
</div>
)}

<div
className={cn(
'absolute flex flex-col items-center',
Expand Down
Loading