diff --git a/src/components/TextAreaChat.tsx b/src/components/TextAreaChat.tsx index 23a435d5..072ae200 100644 --- a/src/components/TextAreaChat.tsx +++ b/src/components/TextAreaChat.tsx @@ -6,6 +6,7 @@ import React, { useMemo, useCallback, } from 'react'; +import { onPartMention } from '@/lib/partMention'; import { ArrowUp, ImagePlus, @@ -477,6 +478,19 @@ function TextAreaChat({ const [isFocused, setIsFocused] = useState(false); const textareaRef = useRef(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); diff --git a/src/components/viewer/OpenSCADViewer.tsx b/src/components/viewer/OpenSCADViewer.tsx index 00bba51a..68ebd411 100644 --- a/src/components/viewer/OpenSCADViewer.tsx +++ b/src/components/viewer/OpenSCADViewer.tsx @@ -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'; @@ -61,11 +63,14 @@ export function OpenSCADPreview({ isCompiling, output, offOutput, + amfOutput, isError, error, } = useOpenSCAD(); const [geometry, setGeometry] = useState(null); const [coloredGroup, setColoredGroup] = useState(null); + const [partsGroup, setPartsGroup] = useState(null); + const [selectedPart, setSelectedPart] = useState(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 @@ -73,6 +78,7 @@ export function OpenSCADPreview({ // 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(null); + const mountedPartsGroupRef = useRef(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. @@ -232,6 +238,50 @@ 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)); + if (group.children.length === 0) { + if (!cancelled) clearPartsGroup(); + return; + } + if (mountedPartsGroupRef.current) + disposeColoredGroup(mountedPartsGroupRef.current); + mountedPartsGroupRef.current = group; + 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 () => { @@ -239,6 +289,10 @@ export function OpenSCADPreview({ disposeColoredGroup(mountedGroupRef.current); mountedGroupRef.current = null; } + if (mountedPartsGroupRef.current) { + disposeColoredGroup(mountedPartsGroupRef.current); + mountedPartsGroupRef.current = null; + } if (mountedGeometryRef.current) { mountedGeometryRef.current.dispose(); mountedGeometryRef.current = null; @@ -249,15 +303,25 @@ export function OpenSCADPreview({ return (
- {geometry || coloredGroup ? ( + {geometry || coloredGroup || partsGroup ? (
{ + setSelectedPart(name); + if (name) emitPartMention(name); + }} color={color} isMobile={isMobile} backgroundColor={backgroundColor} /> + {selectedPart && ( +
+ {selectedPart} +
+ )}
) : ( <> diff --git a/src/components/viewer/ThreeScene.tsx b/src/components/viewer/ThreeScene.tsx index 55f18fbd..4985f47c 100644 --- a/src/components/viewer/ThreeScene.tsx +++ b/src/components/viewer/ThreeScene.tsx @@ -1,4 +1,4 @@ -import { Canvas } from '@react-three/fiber'; +import { Canvas, ThreeEvent, useFrame } from '@react-three/fiber'; import { OrbitControls, Stage, @@ -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 { @@ -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; + } + 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(null); + const selected = useRef(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; + } + 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) => { + 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'; + }; + + 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) => { + 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 ( + + + + ); } export function ThreeScene({ @@ -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); @@ -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 (
{/* Local Suspense boundary — `` re-throws suspension upward @@ -80,7 +251,14 @@ export function ThreeScene({ - {coloredGroup && groupCenterOffset ? ( + {partsGroup && partsCenterOffset ? ( + + ) : coloredGroup && groupCenterOffset ? ( + {partsGroup && partsGroup.children.length > 1 && ( +
+ + Éclaté + + setExplode(v[0] ?? 0)} + min={0} + max={1.5} + step={0.05} + aria-label="Vue éclatée" + /> +
+ )} +
(); const [offOutput, setOffOutput] = useState(); + const [amfOutput, setAmfOutput] = useState(); // Per-instance worker. Each useOpenSCAD() call owns its own Web Worker so // listeners only see their own compile/export results — sharing a single // worker across multiple useOpenSCAD() consumers means every listener fires @@ -77,6 +78,7 @@ export function useOpenSCAD() { setIsError(true); setOutput(undefined); setOffOutput(undefined); + setAmfOutput(undefined); } else if (event.data.data?.output) { const blob = new Blob([event.data.data.output], { type: @@ -88,6 +90,11 @@ export function useOpenSCAD() { setOffOutput( offBytes ? new Blob([offBytes], { type: 'text/plain' }) : undefined, ); + + const amfBytes = event.data.data.extraOutputs?.amf; + setAmfOutput( + amfBytes ? new Blob([amfBytes], { type: 'text/plain' }) : undefined, + ); } setIsCompiling(false); } @@ -283,6 +290,7 @@ export function useOpenSCAD() { isCompiling, output, offOutput, + amfOutput, error, isError, }; diff --git a/src/lib/partMention.ts b/src/lib/partMention.ts new file mode 100644 index 00000000..574c3a2f --- /dev/null +++ b/src/lib/partMention.ts @@ -0,0 +1,22 @@ +/** + * Tiny pub/sub so the 3D viewer can push an `@part` mention into the chat + * input. The chat input lives as private local state inside `TextAreaChat`, + * and the viewer sits deep in a separate branch of the tree, so this decouples + * the two without prop drilling or a shared context provider. + */ +type MentionListener = (partName: string) => void; + +const listeners = new Set(); + +/** Subscribe to part-mention events. Returns an unsubscribe function. */ +export function onPartMention(listener: MentionListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** Notify listeners that the user picked a part named `partName`. */ +export function emitPartMention(partName: string): void { + for (const listener of listeners) listener(partName); +} diff --git a/src/utils/amfParser.ts b/src/utils/amfParser.ts new file mode 100644 index 00000000..f3b730f8 --- /dev/null +++ b/src/utils/amfParser.ts @@ -0,0 +1,50 @@ +/** + * Minimal parser for the AMF (Additive Manufacturing File) XML that OpenSCAD's + * manifold backend emits when `--enable=lazy-union` is set: one `` per + * top-level part, in source order. AMF carries geometry only — no part names, + * no colors — so callers map object order back to named modules from the code. + */ + +export type AmfObject = { + id: string; + /** Flat xyz vertex coordinates, length = vertexCount * 3. */ + positions: Float32Array; + /** Triangle vertex indices into `positions`, length = triangleCount * 3. */ + indices: Uint32Array; +}; + +const OBJECT_RE = /]*\bid="([^"]*)"[\s\S]*?<\/object>/g; +const VERTEX_RE = + /\s*([-\d.eE+]+)\s*<\/x>\s*\s*([-\d.eE+]+)\s*<\/y>\s*\s*([-\d.eE+]+)\s*<\/z>/g; +const TRIANGLE_RE = + /\s*(\d+)\s*<\/v1>\s*\s*(\d+)\s*<\/v2>\s*\s*(\d+)\s*<\/v3>/g; + +/** Parse an OpenSCAD-emitted AMF document into one entry per ``. */ +export function parseAmf(text: string): AmfObject[] { + const objects: AmfObject[] = []; + + for (const objectMatch of text.matchAll(OBJECT_RE)) { + const id = objectMatch[1]; + const body = objectMatch[0]; + + const coords: number[] = []; + for (const v of body.matchAll(VERTEX_RE)) { + coords.push(Number(v[1]), Number(v[2]), Number(v[3])); + } + + const indices: number[] = []; + for (const t of body.matchAll(TRIANGLE_RE)) { + indices.push(Number(t[1]), Number(t[2]), Number(t[3])); + } + + if (coords.length === 0 || indices.length === 0) continue; + + objects.push({ + id, + positions: new Float32Array(coords), + indices: new Uint32Array(indices), + }); + } + + return objects; +} diff --git a/src/utils/partsFromAmf.ts b/src/utils/partsFromAmf.ts new file mode 100644 index 00000000..3a955b83 --- /dev/null +++ b/src/utils/partsFromAmf.ts @@ -0,0 +1,153 @@ +import { + BufferAttribute, + BufferGeometry, + Color, + Group, + Mesh, + MeshStandardMaterial, +} from 'three'; + +import { parseAmf } from './amfParser'; + +export type PartInfo = { name: string; color: string | null }; + +const CADAM_NODE_PART_RE = /@cadam-node\b[^\n]*\bkind\s*=\s*"?part"?[^\n]*/g; + +function attr(line: string, key: string): string | undefined { + return new RegExp('\\b' + key + '\\s*=\\s*"?([\\w-]+)"?').exec(line)?.[1]; +} + +/** + * Ordered top-level parts described by the OpenSCAD source. + * + * Prefers `@cadam-node kind=part` annotations (forward-compatible with the + * design-tree PR #176); otherwise falls back to the order of top-level module + * calls, then to `piece_N`. Colors come from each part module's first + * `color(...)` (literal or a `*_color` parameter default). + */ +export function partsFromScad(code: string): PartInfo[] { + const moduleColors = moduleColorMap(code); + + const annotated = [...code.matchAll(CADAM_NODE_PART_RE)].map((m) => { + const moduleName = attr(m[0], 'moduleName'); + return { + name: attr(m[0], 'name') ?? moduleName ?? '', + moduleName, + }; + }); + if (annotated.length > 0) { + return annotated.map((a, i) => ({ + name: a.name || `piece_${i + 1}`, + color: a.moduleName ? (moduleColors.get(a.moduleName) ?? null) : null, + })); + } + + return topLevelModuleCalls(code).map((mod, i) => ({ + name: mod || `piece_${i + 1}`, + color: moduleColors.get(mod) ?? null, + })); +} + +function colorParamDefaults(code: string): Map { + const map = new Map(); + for (const m of code.matchAll(/(\w+_color)\s*=\s*"([^"]+)"/g)) { + map.set(m[1], m[2]); + } + return map; +} + +function extractBlock(code: string, openBraceIndex: number): string { + let depth = 0; + for (let i = openBraceIndex; i < code.length; i++) { + if (code[i] === '{') depth++; + else if (code[i] === '}' && --depth === 0) + return code.slice(openBraceIndex, i + 1); + } + return code.slice(openBraceIndex); +} + +function moduleColorMap(code: string): Map { + const defaults = colorParamDefaults(code); + const map = new Map(); + 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( + extractBlock(code, openBrace), + ); + if (!arg) continue; + const value = arg[1] ?? (arg[2] ? defaults.get(arg[2]) : undefined); + if (value) map.set(m[1], value); + } + return map; +} + +function topLevelModuleCalls(code: string): string[] { + const modules = new Set( + [...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; + while (i < code.length) { + const ch = code[i]; + if (ch === '{') depth++; + else if (ch === '}') depth = Math.max(0, depth - 1); + else if (depth === 0) { + callRe.lastIndex = i; + const m = callRe.exec(code); + if (m && m.index === i && modules.has(m[1])) { + calls.push(m[1]); + i = callRe.lastIndex; + continue; + } + } + i++; + } + return calls; +} + +const FALLBACK_PALETTE = [ + 0x4f8fba, 0xba7a4f, 0x6aba4f, 0xba4f9c, 0x4fba9c, 0xbab04f, +]; + +/** + * Build a THREE.Group with one named, colored mesh per AMF ``. + * `parts[i]` (from {@link partsFromScad}) names object i by source order. + */ +export function buildPartsGroup( + amfText: string, + parts: PartInfo[], + fallbackColor = 0x999999, +): Group { + const group = new Group(); + group.name = 'cadam-parts'; + + parseAmf(amfText).forEach((obj, i) => { + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(obj.positions, 3)); + geometry.setIndex(new BufferAttribute(obj.indices, 1)); + geometry.computeVertexNormals(); + + const info = parts[i]; + const colorValue: string | number = + info?.color ?? + FALLBACK_PALETTE[i % FALLBACK_PALETTE.length] ?? + fallbackColor; + + const mesh = new Mesh( + geometry, + new MeshStandardMaterial({ + color: new Color(colorValue), + roughness: 0.6, + metalness: 0.1, + }), + ); + mesh.name = info?.name ?? `piece_${i + 1}`; + mesh.userData.partName = mesh.name; + mesh.userData.partIndex = i; + group.add(mesh); + }); + + return group; +} diff --git a/src/worker/openSCAD.ts b/src/worker/openSCAD.ts index 48873a67..d3a045eb 100644 --- a/src/worker/openSCAD.ts +++ b/src/worker/openSCAD.ts @@ -242,7 +242,10 @@ class OpenSCADWrapper { data.code, data.fileType, parameters.concat(exportParams), - [{ path: '/out.off', key: 'off' }], + [ + { path: '/out.off', key: 'off' }, + { path: '/out.amf', key: 'amf' }, + ], ); // Check `render.log.stdErr` for "Current top level object is not a 3d object."