Repository navigation
feat(viewer): click a 3D part to insert it as an @mention, with exploded view #261
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
12a0d58
46e2216
cab8b1e
0b8fe25
fe96c0f
3f80f42
14c33cb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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,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. | ||||||||
|
|
@@ -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)); | ||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
A successful regeneration disposes the selected mesh and installs a new parts group without clearing There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents
Suggested change
|
||||||||
| }) | ||||||||
| .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; | ||||||||
|
|
@@ -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> | ||||||||
| ) : ( | ||||||||
| <> | ||||||||
|
|
||||||||
| 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, | ||||||||||||||||||||||||||||||||||
|
|
@@ -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; | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+31
to
+34
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These helpers cast an arbitrary Rule Used: Avoid type casting and ensure types are correct from the beginning rather than casting to fix type mismatches. (source) Learned From 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When a regenerated Prompt for AI agents
Suggested change
|
||||||||||||||||||||||||||||||||||
| 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'; | ||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents |
||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| 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} | ||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents
Suggested change
|
||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||
| </group> | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| 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 ( | ||||||||||||||||||||||||||||||||||
| <div className="relative h-full w-full overflow-hidden"> | ||||||||||||||||||||||||||||||||||
| {/* Local Suspense boundary — `<Canvas>` re-throws suspension upward | ||||||||||||||||||||||||||||||||||
|
|
@@ -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} | ||||||||||||||||||||||||||||||||||
|
|
@@ -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', | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: When
scadCodechanges 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