Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion api/routers/optimize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)")
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -469,4 +503,4 @@ def export_mesh(path: str, format: str):
content=data,
media_type=mime,
headers={"Content-Disposition": f'attachment; filename="{stem}.{format}"'},
)
)
24 changes: 21 additions & 3 deletions src/areas/generate/GeneratePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,8 @@ export default function GeneratePage(): JSX.Element {
const [libraryCollapsedSectionKeys, setLibraryCollapsedSectionKeys] = useState<string[]>(() => 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)
Expand All @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -1082,10 +1100,10 @@ export default function GeneratePage(): JSX.Element {

{/* Viewer area */}
<div className="flex-1 relative overflow-hidden">
<Viewer3D lightSettings={lightSettings} />
<Viewer3D lightSettings={lightSettings} gizmoMode={gizmoMode} gizmoUndoRef={gizmoUndoRef} />
<GenerationHUD />
</div>
</div>
</>
)
}
}
Loading