diff --git a/api/routers/optimize.py b/api/routers/optimize.py index 282e723..6081c70 100644 --- a/api/routers/optimize.py +++ b/api/routers/optimize.py @@ -12,6 +12,7 @@ _pymeshlab = None _PYMESHLAB_AVAILABLE = False +import numpy as np import trimesh import trimesh.visual from fastapi import APIRouter, HTTPException, UploadFile, File @@ -35,6 +36,11 @@ class SmoothRequest(BaseModel): iterations: int +class TransformRequest(BaseModel): + path: str # format: "{collection}/{filename}" + matrix: list[list[float]] # row-major 4x4 world transform + + def _require_pymeshlab(): if not _PYMESHLAB_AVAILABLE: raise HTTPException(503, "pymeshlab is unavailable on this system (DLL blocked by Windows Application Control policy)") @@ -194,6 +200,34 @@ def smooth_mesh(body: SmoothRequest): return {"url": f"/workspace/{rel}"} +@router.post("/transform") +def transform_mesh(body: TransformRequest): + # Bake an interactive-gizmo transform into the GLB at scene level so it + # persists to export. Pure trimesh — no pymeshlab needed. + input_path = _resolve_input_path(body.path) + + matrix = np.asarray(body.matrix, dtype=float) + if matrix.shape != (4, 4): + raise HTTPException(400, "matrix must be a 4x4 array") + if not np.all(np.isfinite(matrix)): + raise HTTPException(400, "matrix contains non-finite values") + + # Keep the loaded result as-is (Scene when textured/multi-geometry) so + # apply_transform preserves materials and UVs. + loaded = trimesh.load(str(input_path)) + loaded.apply_transform(matrix) + + stem = input_path.stem + output_name = f"{stem}_xf_{uuid.uuid4().hex[:8]}.glb" + output_dir = input_path.parent if str(input_path).startswith(str(WORKSPACE_DIR.resolve())) else WORKSPACE_DIR / "Workflows" + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / output_name + loaded.export(str(output_path)) + + rel = output_path.relative_to(WORKSPACE_DIR).as_posix() + return {"url": f"/workspace/{rel}"} + + def _smooth(input_path: str, iterations: int, tmp_dir: str) -> trimesh.Trimesh: loaded = trimesh.load(input_path) if isinstance(loaded, trimesh.Scene): @@ -469,4 +503,4 @@ def export_mesh(path: str, format: str): content=data, media_type=mime, headers={"Content-Disposition": f'attachment; filename="{stem}.{format}"'}, - ) + ) \ No newline at end of file diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx index 00bf08c..ce57502 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -554,6 +554,8 @@ export default function GeneratePage(): JSX.Element { const [libraryCollapsedSectionKeys, setLibraryCollapsedSectionKeys] = useState(() => getDefaultAssetLibraryCollapsedSectionKeys()) const [gizmoMode, setGizmoMode] = useState<'translate' | 'rotate' | 'scale' | null>(null) const dragging = useRef(false) + // Populated by Viewer3D — undoes the latest live gizmo transform, if any. + const gizmoUndoRef = useRef<(() => boolean) | null>(null) const lightSettings = useAppStore((s) => s.lightSettings) const setLightSettings = useAppStore((s) => s.setLightSettings) @@ -578,7 +580,7 @@ export default function GeneratePage(): JSX.Element { useEffect(() => { const handler = (e: KeyboardEvent) => { if (!e.ctrlKey && !e.metaKey) return - if (e.key === 'z') { e.preventDefault(); undoMesh() } + if (e.key === 'z') { e.preventDefault(); if (gizmoUndoRef.current?.()) return; undoMesh() } if (e.key === 'y') { e.preventDefault(); redoMesh() } } window.addEventListener('keydown', handler) @@ -593,6 +595,22 @@ export default function GeneratePage(): JSX.Element { if (!meshSelected) setGizmoMode(null) }, [meshSelected]) + // Gizmo hotkeys: W move, R rotate, S scale, Esc exits. Ignored while typing. + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const el = document.activeElement as HTMLElement | null + if (el && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el.isContentEditable)) return + if (e.key === 'Escape') { setGizmoMode((m) => (m ? null : m)); return } + if (!hasModel || !meshSelected) return + const k = e.key.toLowerCase() + if (k === 'w') setGizmoMode('translate') + else if (k === 'r') setGizmoMode('rotate') + else if (k === 's') setGizmoMode('scale') + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [hasModel, meshSelected]) + useEffect(() => { if (openPanel !== 'library' || libraryLoaded || libraryLoading) return void loadLibraryEntries() @@ -1082,10 +1100,10 @@ export default function GeneratePage(): JSX.Element { {/* Viewer area */}
- +
) -} +} \ No newline at end of file diff --git a/src/areas/generate/components/Viewer3D.tsx b/src/areas/generate/components/Viewer3D.tsx index 4224fe0..7a9b4b9 100644 --- a/src/areas/generate/components/Viewer3D.tsx +++ b/src/areas/generate/components/Viewer3D.tsx @@ -1,6 +1,7 @@ -import { Component, Suspense, useEffect, useMemo, useRef, useState } from 'react' -import type { ReactNode, ErrorInfo } from 'react' -import { Canvas, useLoader, useThree } from '@react-three/fiber' +import { Component, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { ReactNode, ErrorInfo, MutableRefObject } from 'react' +import { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber' +import type { ThreeEvent } from '@react-three/fiber' import { Environment, GizmoHelper, Lightformer, OrbitControls, useGizmoContext, useGLTF } from '@react-three/drei' import { EffectComposer, Outline, Select, Selection } from '@react-three/postprocessing' import * as THREE from 'three' @@ -18,6 +19,8 @@ import { ViewerToolbar, type ViewMode } from './ViewerToolbar' import type { LightSettings } from '@shared/stores/appStore' import { DEFAULT_LIGHT_SETTINGS } from '@shared/stores/appStore' +export type GizmoMode = 'translate' | 'rotate' | 'scale' + const SELECTION_OUTLINE_VISIBLE_COLOR = 0x8b5cf6 const SELECTION_OUTLINE_HIDDEN_COLOR = 0x5b21b6 const SELECTION_OUTLINE_EDGE_STRENGTH = 2.5 @@ -137,11 +140,12 @@ interface MeshModelProps { selected: boolean onStats: (stats: { vertices: number; triangles: number }) => void onSelect: () => void + onObject: (obj: THREE.Object3D | null) => void } -function MeshModel({ url, jobId, viewMode, selected, onStats, onSelect }: MeshModelProps): JSX.Element { +function MeshModel({ url, jobId, viewMode, selected, onStats, onSelect, onObject }: MeshModelProps): JSX.Element { const extension = url.split('?')[0]?.split('.').pop()?.toLowerCase() - const common = { url, jobId, viewMode, selected, onStats, onSelect } + const common = { url, jobId, viewMode, selected, onStats, onSelect, onObject } return extension === 'obj' ? : } @@ -161,6 +165,7 @@ function SceneMeshModel({ selected, onStats, onSelect, + onObject, scene, loaderType, }: MeshModelProps & { @@ -170,6 +175,12 @@ function SceneMeshModel({ const captured = useRef(false) const edgeHelpers = useRef([]) + // Expose the scene object so Viewer3D can attach the transform gizmo to it. + useEffect(() => { + onObject(scene) + return () => onObject(null) + }, [scene, onObject]) + // Free GPU resources and loader cache when this model is replaced or unmounted useEffect(() => { return () => { @@ -208,11 +219,14 @@ function SceneMeshModel({ } }, [scene]) - // Centre the mesh on the grid + // Centre the mesh on the grid. Runs only on first load / model change — never + // on plain re-renders, so a live gizmo transform is not silently overwritten. useEffect(() => { - // Reset before computing — useGLTF caches the scene with its already-modified position, - // which would skew the setFromObject (world space) on a second mount. + // Clear any cached transform before measuring (useGLTF may reuse a scene + // that still carries an earlier gizmo pose). scene.position.set(0, 0, 0) + scene.rotation.set(0, 0, 0) + scene.scale.set(1, 1, 1) const box = new THREE.Box3().setFromObject(scene) const center = new THREE.Vector3() box.getCenter(center) @@ -361,6 +375,413 @@ function GizmoBubbles() { ) } +// --------------------------------------------------------------------------- +// Transform gizmos — custom move / rotate / scale handles (shared style) +// --------------------------------------------------------------------------- + +type GizmoAxis = 'x' | 'y' | 'z' +type TranslateHandleId = GizmoAxis | 'xy' | 'yz' | 'xz' +type ScaleHandleId = GizmoAxis | 'xyz' + +const AXIS_COLORS: Record = { + x: '#f87171', + y: '#4ade80', + z: '#60a5fa', +} + +const AXIS_DIR: Record = { + x: [1, 0, 0], + y: [0, 1, 0], + z: [0, 0, 1], +} + +// Orient a +Y cylinder/cone/box onto each axis. +const AXIS_ROTATION: Record = { + x: [0, 0, -Math.PI / 2], + y: [0, 0, 0], + z: [Math.PI / 2, 0, 0], +} + +// Orient a default-XY torus so its ring spins around each axis. +const RING_ROTATION: Record = { + x: [0, Math.PI / 2, 0], + y: [Math.PI / 2, 0, 0], + z: [0, 0, 0], +} + +// Two-axis plane handles, coloured by their locked (normal) axis. +const PLANE_HANDLES: { + id: 'xy' | 'yz' | 'xz' + normal: [number, number, number] + color: string + position: [number, number, number] + rotation: [number, number, number] +}[] = [ + { id: 'xy', normal: [0, 0, 1], color: AXIS_COLORS.z, position: [0.26, 0.26, 0], rotation: [0, 0, 0] }, + { id: 'yz', normal: [1, 0, 0], color: AXIS_COLORS.x, position: [0, 0.26, 0.26], rotation: [0, -Math.PI / 2, 0] }, + { id: 'xz', normal: [0, 1, 0], color: AXIS_COLORS.y, position: [0.26, 0, 0.26], rotation: [Math.PI / 2, 0, 0] }, +] + +const GIZMO_SCREEN_SIZE = 0.12 + +function lightenColor(hex: string, amount = 0.5): string { + return '#' + new THREE.Color(hex).lerp(new THREE.Color('#ffffff'), amount).getHexString() +} + +function intersectPlane(ray: THREE.Ray, origin: THREE.Vector3, normal: THREE.Vector3): THREE.Vector3 | null { + const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, origin) + const hit = new THREE.Vector3() + return ray.intersectPlane(plane, hit) ? hit : null +} + +// Shared plumbing: follow the object, keep a constant on-screen size, and run +// the pointer-drag lifecycle (window listeners + OrbitControls locking). +function useGizmoBase(object: THREE.Object3D) { + const camera = useThree((s) => s.camera) + const gl = useThree((s) => s.gl) + const raycaster = useThree((s) => s.raycaster) + const controls = useThree((s) => s.controls) as { enabled: boolean } | null + + const groupRef = useRef(null) + const ndc = useRef(new THREE.Vector2()) + const moveRef = useRef<((ev: PointerEvent) => void) | null>(null) + const endRef = useRef<(() => void) | null>(null) + + useFrame(() => { + const g = groupRef.current + if (!g) return + object.getWorldPosition(g.position) + g.scale.setScalar(Math.max(camera.position.distanceTo(g.position) * GIZMO_SCREEN_SIZE, 0.001)) + }) + + const pointerRay = useCallback((ev: PointerEvent): THREE.Ray => { + const rect = gl.domElement.getBoundingClientRect() + ndc.current.set( + ((ev.clientX - rect.left) / rect.width) * 2 - 1, + -((ev.clientY - rect.top) / rect.height) * 2 + 1, + ) + raycaster.setFromCamera(ndc.current, camera) + return raycaster.ray + }, [camera, gl, raycaster]) + + const stop = useCallback(() => { + if (!moveRef.current) return + window.removeEventListener('pointermove', moveRef.current) + window.removeEventListener('pointerup', stop) + moveRef.current = null + endRef.current?.() + endRef.current = null + if (controls) controls.enabled = true + gl.domElement.style.cursor = '' + }, [controls, gl]) + + const start = useCallback((onMove: (ev: PointerEvent) => void, onEnd?: () => void) => { + moveRef.current = onMove + endRef.current = onEnd ?? null + if (controls) controls.enabled = false + gl.domElement.style.cursor = 'grabbing' + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', stop) + }, [controls, gl, stop]) + + useEffect(() => stop, [stop]) // release the drag if unmounted mid-interaction + + return { camera, groupRef, pointerRay, start } +} + +function hoverHandlers( + id: T, + setHovered: (value: T | null) => void, + onDown: (e: ThreeEvent) => void, +) { + return { + onPointerOver: (e: ThreeEvent) => { e.stopPropagation(); setHovered(id) }, + onPointerOut: () => setHovered(null), + onPointerDown: onDown, + } +} + +function GizmoArrow({ color, active }: { color: string; active: boolean }): JSX.Element { + const tint = active ? lightenColor(color) : color + return ( + + {/* Invisible, fat hit target spanning the whole arm */} + + + + + {/* Shaft */} + + + + + {/* Arrowhead */} + + + + + + ) +} + +function GizmoScaleArm({ color, active }: { color: string; active: boolean }): JSX.Element { + const tint = active ? lightenColor(color) : color + return ( + + {/* Invisible, fat hit target — starts above the centre cube so a + centre click hits the uniform-scale handle, not an axis */} + + + + + {/* Shaft */} + + + + + {/* Cube head */} + + + + + + ) +} + +function GizmoRing({ color, active }: { color: string; active: boolean }): JSX.Element { + const tint = active ? lightenColor(color) : color + return ( + + {/* Invisible, fat hit target */} + + + + + + + + + + ) +} + +function GizmoPlane({ color, active }: { color: string; active: boolean }): JSX.Element { + return ( + + + + + ) +} + +function TranslateGizmo({ object, onDragStart, onDragEnd }: { object: THREE.Object3D; onDragStart?: () => void; onDragEnd?: () => void }): JSX.Element { + const { camera, groupRef, pointerRay, start } = useGizmoBase(object) + const [hovered, setHovered] = useState(null) + const [activeId, setActiveId] = useState(null) + const drag = useRef<{ + axisDir: THREE.Vector3 | null + planeNormal: THREE.Vector3 + origin: THREE.Vector3 + startHit: THREE.Vector3 + startPos: THREE.Vector3 + } | null>(null) + + const beginDrag = useCallback((id: TranslateHandleId, e: ThreeEvent) => { + e.stopPropagation() + const origin = new THREE.Vector3() + object.getWorldPosition(origin) + const startPos = object.position.clone() + + let axisDir: THREE.Vector3 | null = null + let planeNormal: THREE.Vector3 + if (id === 'x' || id === 'y' || id === 'z') { + axisDir = new THREE.Vector3(...AXIS_DIR[id]) + // Drag plane: contains the axis and faces the camera as much as possible. + const view = new THREE.Vector3().subVectors(camera.position, origin) + planeNormal = view.sub(axisDir.clone().multiplyScalar(view.dot(axisDir))) + if (planeNormal.lengthSq() < 1e-6) planeNormal.set(axisDir.y ? 1 : 0, axisDir.y ? 0 : 1, 0) + planeNormal.normalize() + } else { + planeNormal = new THREE.Vector3(...PLANE_HANDLES.find((p) => p.id === id)!.normal) + } + + const startHit = intersectPlane(e.ray, origin, planeNormal) + if (!startHit) return + drag.current = { axisDir, planeNormal, origin, startHit, startPos } + setActiveId(id) + onDragStart?.() + start((ev) => { + const d = drag.current + if (!d) return + const hit = intersectPlane(pointerRay(ev), d.origin, d.planeNormal) + if (!hit) return + const delta = new THREE.Vector3().subVectors(hit, d.startHit) + if (d.axisDir) { + object.position.copy(d.startPos).addScaledVector(d.axisDir, delta.dot(d.axisDir)) + } else { + object.position.copy(d.startPos).add(delta) + } + }, () => { drag.current = null; setActiveId(null); onDragEnd?.() }) + }, [object, camera, pointerRay, start, onDragStart, onDragEnd]) + + return ( + + {/* Central origin handle (decorative — never blocks picking) */} + null} renderOrder={999}> + + + + + {(['x', 'y', 'z'] as GizmoAxis[]).map((axis) => ( + (axis, setHovered, (e) => beginDrag(axis, e))}> + + + ))} + + {PLANE_HANDLES.map((plane) => ( + (plane.id, setHovered, (e) => beginDrag(plane.id, e))}> + + + ))} + + ) +} + +function RotateGizmo({ object, onDragStart, onDragEnd }: { object: THREE.Object3D; onDragStart?: () => void; onDragEnd?: () => void }): JSX.Element { + const { groupRef, pointerRay, start } = useGizmoBase(object) + const [hovered, setHovered] = useState(null) + const [activeId, setActiveId] = useState(null) + const drag = useRef<{ + axisDir: THREE.Vector3 + origin: THREE.Vector3 + startVec: THREE.Vector3 + startQuat: THREE.Quaternion + } | null>(null) + + const beginDrag = useCallback((axis: GizmoAxis, e: ThreeEvent) => { + e.stopPropagation() + const origin = new THREE.Vector3() + object.getWorldPosition(origin) + const axisDir = new THREE.Vector3(...AXIS_DIR[axis]).normalize() + // Rotation happens in the plane perpendicular to the axis (the ring's plane). + const startHit = intersectPlane(e.ray, origin, axisDir) + if (!startHit) return + const startVec = new THREE.Vector3().subVectors(startHit, origin) + if (startVec.lengthSq() < 1e-9) return + drag.current = { axisDir, origin, startVec, startQuat: object.quaternion.clone() } + setActiveId(axis) + onDragStart?.() + start((ev) => { + const d = drag.current + if (!d) return + const hit = intersectPlane(pointerRay(ev), d.origin, d.axisDir) + if (!hit) return + const cur = new THREE.Vector3().subVectors(hit, d.origin) + // Signed angle between the start and current vectors, around the axis. + const cross = new THREE.Vector3().crossVectors(d.startVec, cur) + const angle = Math.atan2(cross.dot(d.axisDir), d.startVec.dot(cur)) + const q = new THREE.Quaternion().setFromAxisAngle(d.axisDir, angle) + object.quaternion.copy(d.startQuat).premultiply(q) + }, () => { drag.current = null; setActiveId(null); onDragEnd?.() }) + }, [object, pointerRay, start, onDragStart, onDragEnd]) + + return ( + + {(['x', 'y', 'z'] as GizmoAxis[]).map((axis) => ( + (axis, setHovered, (e) => beginDrag(axis, e))}> + + + ))} + + ) +} + +function ScaleGizmo({ object, onDragStart, onDragEnd }: { object: THREE.Object3D; onDragStart?: () => void; onDragEnd?: () => void }): JSX.Element { + const { camera, groupRef, pointerRay, start } = useGizmoBase(object) + const [hovered, setHovered] = useState(null) + const [activeId, setActiveId] = useState(null) + const drag = useRef<{ + axisDir: THREE.Vector3 | null + planeNormal: THREE.Vector3 + origin: THREE.Vector3 + startProj: number + startScale: THREE.Vector3 + armLength: number + } | null>(null) + + const beginDrag = useCallback((id: ScaleHandleId, e: ThreeEvent) => { + e.stopPropagation() + const origin = new THREE.Vector3() + object.getWorldPosition(origin) + // World length of one local unit — maps drag distance to a sensible factor. + const armLength = Math.max(groupRef.current?.scale.x ?? 1, 1e-4) + + let axisDir: THREE.Vector3 | null = null + let planeNormal: THREE.Vector3 + if (id === 'xyz') { + planeNormal = new THREE.Vector3().subVectors(camera.position, origin).normalize() + } else { + axisDir = new THREE.Vector3(...AXIS_DIR[id]) + const view = new THREE.Vector3().subVectors(camera.position, origin) + planeNormal = view.sub(axisDir.clone().multiplyScalar(view.dot(axisDir))) + if (planeNormal.lengthSq() < 1e-6) planeNormal.set(axisDir.y ? 1 : 0, axisDir.y ? 0 : 1, 0) + planeNormal.normalize() + } + + const startHit = intersectPlane(e.ray, origin, planeNormal) + if (!startHit) return + const startRel = new THREE.Vector3().subVectors(startHit, origin) + const startProj = axisDir ? startRel.dot(axisDir) : startRel.length() + drag.current = { axisDir, planeNormal, origin, startProj, startScale: object.scale.clone(), armLength } + setActiveId(id) + onDragStart?.() + start((ev) => { + const d = drag.current + if (!d) return + const hit = intersectPlane(pointerRay(ev), d.origin, d.planeNormal) + if (!hit) return + const rel = new THREE.Vector3().subVectors(hit, d.origin) + const proj = d.axisDir ? rel.dot(d.axisDir) : rel.length() + const factor = Math.max(0.01, 1 + (proj - d.startProj) / d.armLength) + if (d.axisDir) { + const s = d.startScale.clone() + if (d.axisDir.x) s.x = Math.max(0.01, d.startScale.x * factor) + if (d.axisDir.y) s.y = Math.max(0.01, d.startScale.y * factor) + if (d.axisDir.z) s.z = Math.max(0.01, d.startScale.z * factor) + object.scale.copy(s) + } else { + object.scale.copy(d.startScale).multiplyScalar(factor) + } + }, () => { drag.current = null; setActiveId(null); onDragEnd?.() }) + }, [object, camera, pointerRay, start, groupRef, onDragStart, onDragEnd]) + + const uniformActive = hovered === 'xyz' || activeId === 'xyz' + + return ( + + {/* Central cube — uniform scale */} + ('xyz', setHovered, (e) => beginDrag('xyz', e))} renderOrder={999}> + + + + + {(['x', 'y', 'z'] as GizmoAxis[]).map((axis) => ( + (axis, setHovered, (e) => beginDrag(axis, e))}> + + + ))} + + ) +} + // --------------------------------------------------------------------------- // EmptyState // --------------------------------------------------------------------------- @@ -380,7 +801,9 @@ function EmptyState(): JSX.Element { // Viewer3D // --------------------------------------------------------------------------- -export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS }: { lightSettings?: LightSettings }): JSX.Element { +type TransformSnapshot = { p: THREE.Vector3; q: THREE.Quaternion; s: THREE.Vector3 } + +export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS, gizmoMode = null, gizmoUndoRef }: { lightSettings?: LightSettings; gizmoMode?: GizmoMode | null; gizmoUndoRef?: MutableRefObject<(() => boolean) | null> }): JSX.Element { const { currentJob } = useGeneration() const apiUrl = useAppStore((s) => s.apiUrl) @@ -395,6 +818,13 @@ export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS }: { l const canvasRef = useRef(null) const splatRef = useRef(null) + const [meshObject, setMeshObject] = useState(null) + + // Local gizmo-transform history (live TRS), undoable with Ctrl+Z. A snapshot + // is taken when a drag starts and committed on release only if it changed. + const transformHistory = useRef([]) + const pendingTransform = useRef(null) + const outputUrl = currentJob?.outputUrl ?? '' const modelUrl = currentJob?.status === 'done' && currentJob.outputUrl @@ -446,6 +876,52 @@ export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS }: { l link.click() } + // Snapshot the pre-drag pose when a gizmo manipulation starts. + const handleGizmoDragStart = useCallback(() => { + if (meshObject) { + pendingTransform.current = { + p: meshObject.position.clone(), + q: meshObject.quaternion.clone(), + s: meshObject.scale.clone(), + } + } + }, [meshObject]) + + // Commit the snapshot on release, but only if the pose actually changed. + const handleGizmoDragEnd = useCallback(() => { + const before = pendingTransform.current + pendingTransform.current = null + if (!before || !meshObject) return + const changed = !meshObject.position.equals(before.p) + || !meshObject.quaternion.equals(before.q) + || !meshObject.scale.equals(before.s) + if (changed) transformHistory.current.push(before) + }, [meshObject]) + + // Revert the most recent gizmo manipulation. Returns false when there is + // nothing to undo, so the caller can fall back to the mesh-history undo. + const undoTransform = useCallback((): boolean => { + const prev = transformHistory.current.pop() + if (!prev || !meshObject) return false + meshObject.position.copy(prev.p) + meshObject.quaternion.copy(prev.q) + meshObject.scale.copy(prev.s) + return true + }, [meshObject]) + + // Expose transform-undo so the page's Ctrl+Z undoes gizmo edits first. + useEffect(() => { + if (!gizmoUndoRef) return + gizmoUndoRef.current = undoTransform + return () => { if (gizmoUndoRef.current === undoTransform) gizmoUndoRef.current = null } + }, [gizmoUndoRef, undoTransform]) + + // Drop the transform history when the model changes. + useEffect(() => { + transformHistory.current = [] + pendingTransform.current = null + }, [modelUrl]) + return ( }> @@ -485,6 +961,7 @@ export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS }: { l {modelUrl && currentJob ? ( @@ -506,11 +983,22 @@ export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS }: { l selected={selected} onStats={setStoreMeshStats} onSelect={() => setSelected(true)} + onObject={setMeshObject} /> ) : null} + {selected && meshObject && gizmoMode === 'translate' && ( + + )} + {selected && meshObject && gizmoMode === 'rotate' && ( + + )} + {selected && meshObject && gizmoMode === 'scale' && ( + + )} + ) -} +} \ No newline at end of file diff --git a/src/shared/hooks/useApi.ts b/src/shared/hooks/useApi.ts index 81f0c42..c98ac65 100644 --- a/src/shared/hooks/useApi.ts +++ b/src/shared/hooks/useApi.ts @@ -116,5 +116,18 @@ export function useApi() { return { url: data.url } } - return { generateFromImage, pollJobStatus, cancelJob, getModelStatus, downloadModel, optimizeMesh, smoothMesh, importMesh } -} + // Bakes a world-space 4x4 transform into the GLB geometry. + // `matrix` is row-major (4 rows of 4), matching the backend's reshape. + async function transformMesh( + path: string, + matrix: number[][], + ): Promise<{ url: string }> { + const { data } = await client.post<{ url: string }>('/optimize/transform', { + path, + matrix, + }) + return { url: data.url } + } + + return { generateFromImage, pollJobStatus, cancelJob, getModelStatus, downloadModel, optimizeMesh, smoothMesh, importMesh, transformMesh } +} \ No newline at end of file