From cccd8bcf391522fdde6bf306e36038b675400438 Mon Sep 17 00:00:00 2001 From: mojo Date: Fri, 2 Oct 2026 12:53:54 -0400 Subject: [PATCH 1/2] Add files via upload GeneratePage.tsx: wire the reset control and download transformed meshes from the Export menu. --- src/areas/generate/GeneratePage.tsx | 2400 +++++++++++++++------------ 1 file changed, 1293 insertions(+), 1107 deletions(-) diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx index a6dbe4f3..17577bcf 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -1,1133 +1,1319 @@ -import { useState, useRef, useCallback, useEffect, useMemo } from 'react' -import type { ReactNode } from 'react' -import { useAppStore, DEFAULT_LIGHT_SETTINGS } from '@shared/stores/appStore' -import type { GenerationJob, LightSettings } from '@shared/stores/appStore' -import { useApi } from '@shared/hooks/useApi' -import { ColorPicker } from '@shared/components/ui' -import GenerationHUD from './components/GenerationHUD' -import Viewer3D from './components/Viewer3D' -import WorkflowPanel from './components/WorkflowPanel' -import { getDefaultAssetLibraryService } from './assetLibraryService' -import { resolveAssetLibraryOpenTarget, type ProjectedAssetLibraryEntry } from './assetLibraryProjection' -import { - ASSET_LIBRARY_SORT_OPTIONS, - buildAssetLibraryOpenRequest, - createAssetLibraryOpenJob, - describeAssetLibraryOpenability, - filterAssetLibraryScopeGroups, - getDefaultAssetLibraryCollapsedSectionKeys, - isAssetLibraryEntryOpenable, - resolveOpenPanelAfterLibrarySelection, - toggleAssetLibrarySectionKey, - type AssetLibrarySortMode, - type GenerateOpenPanel, -} from './assetLibraryUi' - -const MIN_WIDTH = 220 -const MAX_WIDTH = 520 -const DEFAULT_WIDTH = 320 - -// --------------------------------------------------------------------------- -// Export dropdown -// --------------------------------------------------------------------------- - -const EXPORT_FORMATS = [ - { fmt: 'glb' as const, desc: 'Binary glTF' }, - { fmt: 'obj' as const, desc: 'Wavefront' }, - { fmt: 'stl' as const, desc: '3D Print' }, - { fmt: 'ply' as const, desc: 'Polygon File' }, -] - -function ExportDropdown({ - onExport, - onClose, -}: { - onExport: (f: 'glb' | 'obj' | 'stl' | 'ply') => void - onClose: () => void -}) { - return ( -
- {EXPORT_FORMATS.map(({ fmt, desc }) => ( - - ))} -
- ) -} - -// --------------------------------------------------------------------------- -// ToolButton — icon-only toolbar button with tooltip + active state -// --------------------------------------------------------------------------- - -function ToolButton({ - label, - active, - onClick, - children, -}: { - label: string - active: boolean - onClick: () => void - children: ReactNode -}) { - return ( - - ) -} - -// --------------------------------------------------------------------------- -// Decimate popover -// --------------------------------------------------------------------------- - -function DecimatePopover({ - currentTriangles, - decimating, - onDecimate, - onClose, -}: { - currentTriangles: number | null - decimating: boolean - onDecimate: (targetFaces: number) => void - onClose: () => void -}) { - const defaultTarget = currentTriangles ? Math.round(currentTriangles * 0.5) : 5000 - const [inputValue, setInputValue] = useState(String(defaultTarget)) - - const parsed = parseInt(inputValue, 10) - const validTarget = !isNaN(parsed) && parsed >= 100 ? parsed : null - const reduction = - currentTriangles && validTarget - ? Math.round((1 - Math.min(validTarget, currentTriangles) / currentTriangles) * 100) - : null - - return ( -
-

Decimate mesh

- - {currentTriangles && ( -

- Current: {currentTriangles.toLocaleString()} tri -

- )} - -
- - setInputValue(e.target.value)} - min={100} - step={500} - className="bg-zinc-800 border border-zinc-700 rounded-lg px-2.5 py-1.5 text-xs text-zinc-200 w-full focus:outline-none focus:border-violet-500 transition-colors" - /> - {reduction !== null && ( -

- Reduction: {reduction}% -

- )} -
- -
- - -
-
- ) -} - -// --------------------------------------------------------------------------- -// Light popover -// --------------------------------------------------------------------------- - -function LightPopover({ - settings, - onChange, - onClose, -}: { - settings: LightSettings - onChange: (s: LightSettings) => void - onClose: () => void -}) { - function lightRow( - label: string, - colorKey: keyof LightSettings, - intensityKey: keyof LightSettings, - max: number, - ) { - const intensity = settings[intensityKey] as number - const color = settings[colorKey] as string - return ( -
-
- onChange({ ...settings, [colorKey]: c })} - /> - {label} - {intensity.toFixed(1)} -
- onChange({ ...settings, [intensityKey]: parseFloat(e.target.value) })} - className="w-full h-1.5 accent-violet-500 cursor-pointer" - /> -
- ) - } - - function plainRow(label: string, intensityKey: keyof LightSettings, max: number) { - const value = (settings[intensityKey] as number) ?? (DEFAULT_LIGHT_SETTINGS[intensityKey] as number) - return ( -
-
- {label} - {value.toFixed(2)} -
- onChange({ ...settings, [intensityKey]: parseFloat(e.target.value) })} - className="w-full h-1.5 accent-violet-500 cursor-pointer" - /> -
- ) - } - - return ( -
-
-

Lighting

- -
- {lightRow('Sun', 'mainColor', 'mainIntensity', 4)} - {lightRow('Fill', 'fillColor', 'fillIntensity', 2)} - {plainRow('Ambient', 'ambientIntensity', 1.5)} - {plainRow('Environment', 'envIntensity', 2)} - -
- ) -} - -// --------------------------------------------------------------------------- -// Smooth popover -// --------------------------------------------------------------------------- - -function SmoothPopover({ - smoothing, - onSmooth, - onClose, -}: { - smoothing: boolean - onSmooth: (iterations: number) => void - onClose: () => void -}) { - const [inputValue, setInputValue] = useState('3') - - const parsed = parseInt(inputValue, 10) - const valid = !isNaN(parsed) && parsed >= 1 && parsed <= 20 - - return ( -
-

Smooth mesh

- -
- - setInputValue(e.target.value)} - min={1} - max={20} - step={1} - className="bg-zinc-800 border border-zinc-700 rounded-lg px-2.5 py-1.5 text-xs text-zinc-200 w-full focus:outline-none focus:border-violet-500 transition-colors" - /> -

More iterations = smoother, but loses detail

-
- -
- - -
-
- ) -} - -// --------------------------------------------------------------------------- -// Workspace library popover -// --------------------------------------------------------------------------- - -function AssetLibraryToggleButton({ - open, - disabled, - onToggle, -}: { - open: boolean - disabled: boolean - onToggle: () => void -}) { - return ( - - ) -} - -function AssetLibraryPopover({ - entries, - selectedEntryId, - loading, - opening, - error, - searchQuery, - sortMode, - collapsedSectionKeys, - onSelectEntry, - onSearchQueryChange, - onSortModeChange, - onToggleSection, - onOpenSelected, - onRefresh, - onClose, -}: { - entries: ProjectedAssetLibraryEntry[] - selectedEntryId: string | null - loading: boolean - opening: boolean - error: string | null - searchQuery: string - sortMode: AssetLibrarySortMode - collapsedSectionKeys: string[] - onSelectEntry: (entryId: string) => void - onSearchQueryChange: (value: string) => void - onSortModeChange: (value: AssetLibrarySortMode) => void - onToggleSection: (sectionKey: string) => void - onOpenSelected: () => void - onRefresh: () => void - onClose: () => void -}) { - const scopeGroups = filterAssetLibraryScopeGroups(entries, searchQuery, sortMode) - const visibleEntryIds = new Set(scopeGroups.flatMap((scopeGroup) => scopeGroup.entryGroups.flatMap((group) => group.entries.map((entry) => entry.id)))) - const selectedEntry = selectedEntryId && visibleEntryIds.has(selectedEntryId) - ? entries.find((entry) => entry.id === selectedEntryId) ?? null - : null - const normalizedSearchQuery = searchQuery.trim() - const openDisabled = !selectedEntry || !isAssetLibraryEntryOpenable(selectedEntry) || loading || opening - const selectedMessage = selectedEntry - ? describeAssetLibraryOpenability(selectedEntry) - : scopeGroups.length === 0 && normalizedSearchQuery - ? `No workspace assets match “${normalizedSearchQuery}”.` - : 'Select an asset to open it in Generate.' - - return ( -
-
-
-

Workspace library

-

Select a workspace asset and open the supported source in Generate.

-
- -
- - - -
-
- - onSearchQueryChange(event.target.value)} - placeholder="Search by name, path, scope, or capability" - className="bg-zinc-800 border border-zinc-700 rounded-lg px-2.5 py-1.5 text-xs text-zinc-200 w-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400" - /> -
-
- - -
-
- - {loading ? ( -

Loading workspace assets…

- ) : scopeGroups.length === 0 && !normalizedSearchQuery ? ( -

No workspace assets are indexed yet.

- ) : scopeGroups.length === 0 ? ( -

{`No workspace assets match “${normalizedSearchQuery}”.`}

- ) : ( -
- {scopeGroups.map((scopeGroup) => { - const scopeExpanded = !collapsedSectionKeys.includes(scopeGroup.sectionKey) - const scopeRegionId = `asset-library-${scopeGroup.sectionKey.replace(/[^a-z0-9-]+/gi, '-')}` - return ( -
- - {scopeExpanded && ( -
- {scopeGroup.entryGroups.map((group) => { - const capabilityExpanded = !collapsedSectionKeys.includes(group.sectionKey) - const capabilityRegionId = `asset-library-${group.sectionKey.replace(/[^a-z0-9-]+/gi, '-')}` - return ( -
- - {capabilityExpanded && ( -
- {group.entries.map((entry) => { - const selected = entry.id === selectedEntryId - return ( - - ) - })} -
- )} -
- ) - })} -
- )} -
- ) - })} -
- )} - -
-

{selectedMessage}

- {error &&

{error}

} -
- - -
- ) -} - -// --------------------------------------------------------------------------- -// GeneratePage -// --------------------------------------------------------------------------- - -export default function GeneratePage(): JSX.Element { - const [unloadStatus, setUnloadStatus] = useState<'idle' | 'done'>('idle') - const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH) - const [openPanel, setOpenPanel] = useState(null) - const [decimating, setDecimating] = useState(false) - const [smoothing, setSmoothing] = useState(false) - const [importing, setImporting] = useState(false) - const [libraryEntries, setLibraryEntries] = useState([]) - const [librarySelectedEntryId, setLibrarySelectedEntryId] = useState(null) - const [libraryLoaded, setLibraryLoaded] = useState(false) - const [libraryLoading, setLibraryLoading] = useState(false) - const [libraryOpening, setLibraryOpening] = useState(false) - const [libraryError, setLibraryError] = useState(null) - const [librarySearchQuery, setLibrarySearchQuery] = useState('') - const [librarySortMode, setLibrarySortMode] = useState('type') - const [libraryCollapsedSectionKeys, setLibraryCollapsedSectionKeys] = useState(() => getDefaultAssetLibraryCollapsedSectionKeys()) +import { useState, useRef, useCallback, useEffect, useMemo } from 'react' +import type { ReactNode } from 'react' +import { useAppStore, DEFAULT_LIGHT_SETTINGS } from '@shared/stores/appStore' +import type { GenerationJob, LightSettings, PointLight } from '@shared/stores/appStore' +import { useApi } from '@shared/hooks/useApi' +import { ColorPicker } from '@shared/components/ui' +import GenerationHUD from './components/GenerationHUD' +import Viewer3D, { type MeshExportFormat, type MeshExportHandler } from './components/Viewer3D' +import WorkflowPanel from './components/WorkflowPanel' +import { getDefaultAssetLibraryService } from './assetLibraryService' +import { buildOrcaSlicerDeepLink, canOpenInOrcaSlicer } from './orcaSlicerLink' +import { resolveAssetLibraryOpenTarget, type ProjectedAssetLibraryEntry } from './assetLibraryProjection' +import { + ASSET_LIBRARY_SORT_OPTIONS, + buildAssetLibraryOpenRequest, + createAssetLibraryOpenJob, + describeAssetLibraryOpenability, + filterAssetLibraryScopeGroups, + getDefaultAssetLibraryCollapsedSectionKeys, + isAssetLibraryEntryOpenable, + resolveOpenPanelAfterLibrarySelection, + toggleAssetLibrarySectionKey, + type AssetLibrarySortMode, + type GenerateOpenPanel, +} from './assetLibraryUi' + +const MIN_WIDTH = 220 +const MAX_WIDTH = 520 +const DEFAULT_WIDTH = 320 + +const MAX_POINT_LIGHTS = 6 + +function createPointLight(): PointLight { + const angle = Math.random() * Math.PI * 2 + return { + id: crypto.randomUUID(), + position: [Math.cos(angle) * 1.5, 0.5, Math.sin(angle) * 1.5], + color: '#ffffff', + intensity: 1, + } +} + +// --------------------------------------------------------------------------- +// Export dropdown +// --------------------------------------------------------------------------- + +const EXPORT_FORMATS = [ + { fmt: 'glb' as const, desc: 'Binary glTF' }, + { fmt: 'obj' as const, desc: 'Wavefront' }, + { fmt: 'stl' as const, desc: '3D Print' }, + { fmt: 'ply' as const, desc: 'Polygon File' }, +] + +function ExportDropdown({ + onExport, + onClose, + onOpenInSlicer, + canOpenInSlicer, +}: { + onExport: (f: 'glb' | 'obj' | 'stl' | 'ply') => void + onClose: () => void + onOpenInSlicer: () => void + canOpenInSlicer: boolean +}) { + return ( +
+ {EXPORT_FORMATS.map(({ fmt, desc }) => ( + + ))} + {canOpenInSlicer && ( + <> +
+ + + )} +
+ ) +} + +// --------------------------------------------------------------------------- +// ToolButton — icon-only toolbar button with tooltip + active state +// --------------------------------------------------------------------------- + +function ToolButton({ + label, + active, + onClick, + children, +}: { + label: string + active: boolean + onClick: () => void + children: ReactNode +}) { + return ( + + ) +} + +// --------------------------------------------------------------------------- +// Decimate popover +// --------------------------------------------------------------------------- + +function DecimatePopover({ + currentTriangles, + decimating, + onDecimate, + onClose, +}: { + currentTriangles: number | null + decimating: boolean + onDecimate: (targetFaces: number) => void + onClose: () => void +}) { + const defaultTarget = currentTriangles ? Math.round(currentTriangles * 0.5) : 5000 + const [inputValue, setInputValue] = useState(String(defaultTarget)) + + const parsed = parseInt(inputValue, 10) + const validTarget = !isNaN(parsed) && parsed >= 100 ? parsed : null + const reduction = + currentTriangles && validTarget + ? Math.round((1 - Math.min(validTarget, currentTriangles) / currentTriangles) * 100) + : null + + return ( +
+

Decimate mesh

+ + {currentTriangles && ( +

+ Current: {currentTriangles.toLocaleString()} tri +

+ )} + +
+ + setInputValue(e.target.value)} + min={100} + step={500} + className="bg-zinc-800 border border-zinc-700 rounded-lg px-2.5 py-1.5 text-xs text-zinc-200 w-full focus:outline-none focus:border-violet-500 transition-colors" + /> + {reduction !== null && ( +

+ Reduction: {reduction}% +

+ )} +
+ +
+ + +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Light popover +// --------------------------------------------------------------------------- + +function LightPopover({ + settings, + onChange, + onClose, + pointLights, + onPointLightsChange, + selectedPointLightId, + onSelectPointLight, +}: { + settings: LightSettings + onChange: (s: LightSettings) => void + onClose: () => void + pointLights: PointLight[] + onPointLightsChange: (lights: PointLight[]) => void + selectedPointLightId: string | null + onSelectPointLight: (id: string | null) => void +}) { + function lightRow( + label: string, + colorKey: keyof LightSettings, + intensityKey: keyof LightSettings, + max: number, + ) { + const intensity = settings[intensityKey] as number + const color = settings[colorKey] as string + return ( +
+
+ onChange({ ...settings, [colorKey]: c })} + /> + {label} + {intensity.toFixed(1)} +
+ onChange({ ...settings, [intensityKey]: parseFloat(e.target.value) })} + className="w-full h-1.5 accent-violet-500 cursor-pointer" + /> +
+ ) + } + + function plainRow(label: string, intensityKey: keyof LightSettings, max: number) { + const value = (settings[intensityKey] as number) ?? (DEFAULT_LIGHT_SETTINGS[intensityKey] as number) + return ( +
+
+ {label} + {value.toFixed(2)} +
+ onChange({ ...settings, [intensityKey]: parseFloat(e.target.value) })} + className="w-full h-1.5 accent-violet-500 cursor-pointer" + /> +
+ ) + } + + return ( +
+
+

Lighting

+ +
+ {lightRow('Sun', 'mainColor', 'mainIntensity', 4)} + {lightRow('Fill', 'fillColor', 'fillIntensity', 2)} + {plainRow('Ambient', 'ambientIntensity', 1.5)} + {plainRow('Environment', 'envIntensity', 2)} + + {/* ── Point lights ── */} +
+
+

Point lights

+ {pointLights.length < MAX_POINT_LIGHTS && ( + + )} +
+ + {pointLights.length === 0 && ( +

No point lights yet.

+ )} + + {pointLights.map((pl) => ( +
onSelectPointLight(pl.id)} + className={`flex flex-col gap-1.5 p-2 rounded-lg bg-zinc-800/40 border cursor-pointer transition-colors ${ + pl.id === selectedPointLightId + ? 'border-violet-500' + : 'border-zinc-700/40 hover:border-zinc-600' + }`} + > +
+ onPointLightsChange(pointLights.map((p) => p.id === pl.id ? { ...p, color: c } : p))} + /> + Point + {pl.intensity.toFixed(1)} + +
+ onPointLightsChange(pointLights.map((p) => p.id === pl.id ? { ...p, intensity: parseFloat(e.target.value) } : p))} + className="w-full h-1.5 accent-violet-500 cursor-pointer" /> +
+ ))} +
+ + +
+ ) +} + +// --------------------------------------------------------------------------- +// Smooth popover +// --------------------------------------------------------------------------- + +function SmoothPopover({ + smoothing, + onSmooth, + onClose, +}: { + smoothing: boolean + onSmooth: (iterations: number) => void + onClose: () => void +}) { + const [inputValue, setInputValue] = useState('3') + + const parsed = parseInt(inputValue, 10) + const valid = !isNaN(parsed) && parsed >= 1 && parsed <= 20 + + return ( +
+

Smooth mesh

+ +
+ + setInputValue(e.target.value)} + min={1} + max={20} + step={1} + className="bg-zinc-800 border border-zinc-700 rounded-lg px-2.5 py-1.5 text-xs text-zinc-200 w-full focus:outline-none focus:border-violet-500 transition-colors" + /> +

More iterations = smoother, but loses detail

+
+ +
+ + +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Workspace library popover +// --------------------------------------------------------------------------- + +function AssetLibraryToggleButton({ + open, + disabled, + onToggle, +}: { + open: boolean + disabled: boolean + onToggle: () => void +}) { + return ( + + ) +} + +function AssetLibraryPopover({ + entries, + selectedEntryId, + loading, + opening, + error, + searchQuery, + sortMode, + collapsedSectionKeys, + onSelectEntry, + onSearchQueryChange, + onSortModeChange, + onToggleSection, + onOpenSelected, + onRefresh, + onClose, +}: { + entries: ProjectedAssetLibraryEntry[] + selectedEntryId: string | null + loading: boolean + opening: boolean + error: string | null + searchQuery: string + sortMode: AssetLibrarySortMode + collapsedSectionKeys: string[] + onSelectEntry: (entryId: string) => void + onSearchQueryChange: (value: string) => void + onSortModeChange: (value: AssetLibrarySortMode) => void + onToggleSection: (sectionKey: string) => void + onOpenSelected: () => void + onRefresh: () => void + onClose: () => void +}) { + const scopeGroups = filterAssetLibraryScopeGroups(entries, searchQuery, sortMode) + const visibleEntryIds = new Set(scopeGroups.flatMap((scopeGroup) => scopeGroup.entryGroups.flatMap((group) => group.entries.map((entry) => entry.id)))) + const selectedEntry = selectedEntryId && visibleEntryIds.has(selectedEntryId) + ? entries.find((entry) => entry.id === selectedEntryId) ?? null + : null + const normalizedSearchQuery = searchQuery.trim() + const openDisabled = !selectedEntry || !isAssetLibraryEntryOpenable(selectedEntry) || loading || opening + const selectedMessage = selectedEntry + ? describeAssetLibraryOpenability(selectedEntry) + : scopeGroups.length === 0 && normalizedSearchQuery + ? `No workspace assets match “${normalizedSearchQuery}”.` + : 'Select an asset to open it in Generate.' + + return ( +
+
+
+

Workspace library

+

Select a workspace asset and open the supported source in Generate.

+
+ +
+ + + +
+
+ + onSearchQueryChange(event.target.value)} + placeholder="Search by name, path, scope, or capability" + className="bg-zinc-800 border border-zinc-700 rounded-lg px-2.5 py-1.5 text-xs text-zinc-200 w-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400" + /> +
+
+ + +
+
+ + {loading ? ( +

Loading workspace assets…

+ ) : scopeGroups.length === 0 && !normalizedSearchQuery ? ( +

No workspace assets are indexed yet.

+ ) : scopeGroups.length === 0 ? ( +

{`No workspace assets match “${normalizedSearchQuery}”.`}

+ ) : ( +
+ {scopeGroups.map((scopeGroup) => { + const scopeExpanded = !collapsedSectionKeys.includes(scopeGroup.sectionKey) + const scopeRegionId = `asset-library-${scopeGroup.sectionKey.replace(/[^a-z0-9-]+/gi, '-')}` + return ( +
+ + {scopeExpanded && ( +
+ {scopeGroup.entryGroups.map((group) => { + const capabilityExpanded = !collapsedSectionKeys.includes(group.sectionKey) + const capabilityRegionId = `asset-library-${group.sectionKey.replace(/[^a-z0-9-]+/gi, '-')}` + return ( +
+ + {capabilityExpanded && ( +
+ {group.entries.map((entry) => { + const selected = entry.id === selectedEntryId + return ( + + ) + })} +
+ )} +
+ ) + })} +
+ )} +
+ ) + })} +
+ )} + +
+

{selectedMessage}

+ {error &&

{error}

} +
+ + +
+ ) +} + +// --------------------------------------------------------------------------- +// GeneratePage +// --------------------------------------------------------------------------- + +export default function GeneratePage(): JSX.Element { + const [unloadStatus, setUnloadStatus] = useState<'idle' | 'done'>('idle') + const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH) + const [openPanel, setOpenPanel] = useState(null) + const [decimating, setDecimating] = useState(false) + const [smoothing, setSmoothing] = useState(false) + const [importing, setImporting] = useState(false) + const [libraryEntries, setLibraryEntries] = useState([]) + const [librarySelectedEntryId, setLibrarySelectedEntryId] = useState(null) + const [libraryLoaded, setLibraryLoaded] = useState(false) + const [libraryLoading, setLibraryLoading] = useState(false) + const [libraryOpening, setLibraryOpening] = useState(false) + const [libraryError, setLibraryError] = useState(null) + const [librarySearchQuery, setLibrarySearchQuery] = useState('') + const [librarySortMode, setLibrarySortMode] = useState('type') + const [libraryCollapsedSectionKeys, setLibraryCollapsedSectionKeys] = useState(() => getDefaultAssetLibraryCollapsedSectionKeys()) const [gizmoMode, setGizmoMode] = useState<'translate' | 'rotate' | 'scale' | null>(null) const dragging = useRef(false) + const [selectedPointLightId, setSelectedPointLightId] = useState(null) // 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) - const isGenerating = useAppStore((s) => - s.currentJob?.status === 'uploading' || s.currentJob?.status === 'generating' - ) - const currentJob = useAppStore((s) => s.currentJob) - const apiUrl = useAppStore((s) => s.apiUrl) - const showError = useAppStore((s) => s.showError) - const updateCurrentJob = useAppStore((s) => s.updateCurrentJob) - const setCurrentJob = useAppStore((s) => s.setCurrentJob) - const meshStats = useAppStore((s) => s.meshStats) - const meshSelected = useAppStore((s) => s.meshSelected) - const pushMeshUrl = useAppStore((s) => s.pushMeshUrl) - const undoMesh = useAppStore((s) => s.undoMesh) - const redoMesh = useAppStore((s) => s.redoMesh) - const canUndo = useAppStore((s) => s.historyIndex > 0) - const canRedo = useAppStore((s) => s.historyIndex < s.meshHistory.length - 1) - const { optimizeMesh, smoothMesh, importMesh } = useApi() - const assetLibraryService = useMemo(() => getDefaultAssetLibraryService(), []) - - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (!e.ctrlKey && !e.metaKey) return - if (e.key === 'z') { e.preventDefault(); if (gizmoUndoRef.current?.()) return; undoMesh() } - if (e.key === 'y') { e.preventDefault(); redoMesh() } - } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - }, [undoMesh, redoMesh]) - - const hasModel = currentJob?.status === 'done' && !!currentJob.outputUrl - - // Drop the active transform tool when the mesh is deselected, so it doesn't - // silently re-activate on the next selection. - useEffect(() => { - 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() - // eslint-disable-next-line react-hooks/exhaustive-deps -- lazy-load guarded by loaded/loading flags - }, [openPanel, libraryLoaded, libraryLoading]) - - async function handleUnloadAll() { - await window.electron.model.unloadAll() - setUnloadStatus('done') - setTimeout(() => setUnloadStatus('idle'), 2000) - } - - function handleExport(format: 'glb' | 'obj' | 'stl' | 'ply') { + const gizmoResetRef = useRef<(() => boolean) | null>(null) + const meshExportRef = useRef(null) + + const lightSettings = useAppStore((s) => s.lightSettings) + const setLightSettings = useAppStore((s) => s.setLightSettings) + const pointLights = useAppStore((s) => s.pointLights) + const setPointLights = useAppStore((s) => s.setPointLights) + const isGenerating = useAppStore((s) => + s.currentJob?.status === 'uploading' || s.currentJob?.status === 'generating' + ) + const currentJob = useAppStore((s) => s.currentJob) + const apiUrl = useAppStore((s) => s.apiUrl) + const showError = useAppStore((s) => s.showError) + const updateCurrentJob = useAppStore((s) => s.updateCurrentJob) + const setCurrentJob = useAppStore((s) => s.setCurrentJob) + const meshStats = useAppStore((s) => s.meshStats) + const meshSelected = useAppStore((s) => s.meshSelected) + const pushMeshUrl = useAppStore((s) => s.pushMeshUrl) + const undoMesh = useAppStore((s) => s.undoMesh) + const redoMesh = useAppStore((s) => s.redoMesh) + const canUndo = useAppStore((s) => s.historyIndex > 0) + const canRedo = useAppStore((s) => s.historyIndex < s.meshHistory.length - 1) + const { optimizeMesh, smoothMesh, importMesh } = useApi() + const assetLibraryService = useMemo(() => getDefaultAssetLibraryService(), []) + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (!e.ctrlKey && !e.metaKey) return + if (e.key === 'z') { e.preventDefault(); if (gizmoUndoRef.current?.()) return; undoMesh() } + if (e.key === 'y') { e.preventDefault(); redoMesh() } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [undoMesh, redoMesh]) + + const hasModel = currentJob?.status === 'done' && !!currentJob.outputUrl + const showOpenInSlicer = hasModel && canOpenInOrcaSlicer(currentJob?.outputUrl) + + // Selecting a point light (from the 3D marker or the light panel list) — + // also drops the active gizmo tool so it doesn't silently carry over from + // whatever was selected before. Switching selection directly (mesh → + // point light, or point light → point light) never passes through a + // fully-deselected state, so an effect keyed on the selection alone can't + // catch this; clearing it here, at the one place all of those paths go + // through, does. + const handleSelectPointLight = useCallback((id: string | null) => { + setSelectedPointLightId(id) + setGizmoMode(null) + }, []) + + // Drop the active transform tool when nothing is selected, so it doesn't + // silently re-activate on the next selection. + useEffect(() => { + if (!meshSelected && !selectedPointLightId) setGizmoMode(null) + }, [meshSelected, selectedPointLightId]) + + // 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 (!meshSelected && !selectedPointLightId) 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, selectedPointLightId]) + + useEffect(() => { + if (openPanel !== 'library' || libraryLoaded || libraryLoading) return + void loadLibraryEntries() + // eslint-disable-next-line react-hooks/exhaustive-deps -- lazy-load guarded by loaded/loading flags + }, [openPanel, libraryLoaded, libraryLoading]) + + async function handleUnloadAll() { + await window.electron.model.unloadAll() + setUnloadStatus('done') + setTimeout(() => setUnloadStatus('idle'), 2000) + } + + async function handleExport(format: MeshExportFormat) { if (!currentJob?.outputUrl) return const stem = `modly-${Date.now()}` const link = document.createElement('a') - if (format === 'glb') { - link.href = `${apiUrl}${currentJob.outputUrl}` - } else { - const path = encodeURIComponent(currentJob.outputUrl.replace('/workspace/', '')) - link.href = `${apiUrl}/optimize/export?path=${path}&format=${format}` - } - link.download = `${stem}.${format}` - link.click() - } - - function getOptimizePath(url: string): string { - if (url.startsWith('/workspace/')) { - return url.slice('/workspace/'.length) - } - if (url.startsWith('/optimize/serve-file?path=')) { - return decodeURIComponent(url.split('path=')[1] ?? '') - } - return url - } - - async function handleImportMesh() { - const filePath = await window.electron.fs.selectMeshFile() - if (!filePath) return - setOpenPanel(null) - setImporting(true) - try { - const { url } = await importMesh(filePath) - const job: GenerationJob = { - id: `import-${Date.now()}`, - imageFile: '', - status: 'done', - progress: 100, - outputUrl: url, - originalOutputUrl: url, - createdAt: Date.now(), - } - setCurrentJob(job) - pushMeshUrl(url) - } finally { - setImporting(false) - } - } - async function loadLibraryEntries() { - setLibraryLoading(true) - setLibraryError(null) try { - const result = await assetLibraryService.list() - if (!result.success) { - setLibraryLoaded(false) - setLibraryEntries([]) - setLibrarySelectedEntryId(null) - setLibraryError(result.error.message) + const transformedMesh = await meshExportRef.current?.(format) + if (transformedMesh) { + const objectUrl = URL.createObjectURL(transformedMesh) + link.href = objectUrl + link.download = `${stem}.${format}` + link.click() + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000) return } - setLibraryEntries(result.entries) - setLibrarySelectedEntryId((current) => current && result.entries.some((entry) => entry.id === current) - ? current - : result.entries.find(isAssetLibraryEntryOpenable)?.id ?? result.entries[0]?.id ?? null) - setLibraryLoaded(true) - } catch (err) { - setLibraryLoaded(false) - setLibraryEntries([]) - setLibrarySelectedEntryId(null) - setLibraryError(err instanceof Error ? err.message : String(err)) - } finally { - setLibraryLoading(false) - } - } - async function handleOpenSelectedLibraryEntry() { - const selectedEntry = libraryEntries.find((entry) => entry.id === librarySelectedEntryId) ?? null - if (!selectedEntry) { - setLibraryError('Select an asset before opening it in Generate.') - return - } - if (!isAssetLibraryEntryOpenable(selectedEntry)) { - setLibraryError(describeAssetLibraryOpenability(selectedEntry)) - return - } - - setLibraryOpening(true) - setLibraryError(null) - try { - const result = await assetLibraryService.open(buildAssetLibraryOpenRequest(selectedEntry)) - if (!result.success) { - setLibraryError(result.error.message) - return + // Gaussian splats are not mesh objects and continue through the existing + // source-file export path. + if (!/\.(ply|splat)(?:$|[?#])/i.test(currentJob.outputUrl)) { + throw new Error('The mesh viewer is still loading. Try exporting again once it appears.') } - const target = resolveAssetLibraryOpenTarget(result.entry) - const selection = createAssetLibraryOpenJob(result.entry, target) - if (!selection) { - setLibraryError(describeAssetLibraryOpenability(result.entry)) - return + if (format === 'glb') { + link.href = `${apiUrl}${currentJob.outputUrl}` + } else { + const path = encodeURIComponent(currentJob.outputUrl.replace('/workspace/', '')) + link.href = `${apiUrl}/optimize/export?path=${path}&format=${format}` } - setLibraryEntries((currentEntries) => currentEntries.map((entry) => entry.id === result.entry.id ? result.entry : entry)) - setLibrarySelectedEntryId(result.entry.id) - setCurrentJob(selection.job) - pushMeshUrl(selection.historyUrl) - setOpenPanel((currentPanel) => resolveOpenPanelAfterLibrarySelection(currentPanel)) - } catch (err) { - setLibraryError(err instanceof Error ? err.message : String(err)) - } finally { - setLibraryOpening(false) - } - } - - async function handleSmooth(iterations: number) { - if (!currentJob?.outputUrl) return - setSmoothing(true) - try { - const path = getOptimizePath(currentJob.outputUrl) - const { url } = await smoothMesh(path, iterations) - updateCurrentJob({ outputUrl: url }) - pushMeshUrl(url) - setOpenPanel(null) - } catch (err) { - showError(err instanceof Error ? err.message : String(err)) - } finally { - setSmoothing(false) - } - } - - async function handleDecimate(targetFaces: number) { - if (!currentJob?.outputUrl) return - setDecimating(true) - try { - const path = getOptimizePath(currentJob.outputUrl) - const { url } = await optimizeMesh(path, targetFaces) - updateCurrentJob({ outputUrl: url }) - pushMeshUrl(url) - setOpenPanel(null) + link.download = `${stem}.${format}` + link.click() } catch (err) { showError(err instanceof Error ? err.message : String(err)) - } finally { - setDecimating(false) } } - - const onMouseDown = useCallback((e: React.MouseEvent) => { - e.preventDefault() - dragging.current = true - - const onMouseMove = (ev: MouseEvent) => { - if (!dragging.current) return - setPanelWidth((w) => Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, w + ev.movementX))) - } - const onMouseUp = () => { - dragging.current = false - window.removeEventListener('mousemove', onMouseMove) - window.removeEventListener('mouseup', onMouseUp) - } - window.addEventListener('mousemove', onMouseMove) - window.addEventListener('mouseup', onMouseUp) - }, []) - - return ( - <> -
- -
- - {/* Resize handle */} -
- -
- {/* Header bar */} -
- - {/* Free memory */} - - -
- - {/* Undo / Redo */} - - - -
- - {/* Import */} -
- - {openPanel === 'import' && ( -
- -
- )} -
- -
- { - setLibraryError(null) - setOpenPanel((panel) => (panel === 'library' ? null : 'library')) - }} - /> - {openPanel === 'library' && ( - { - setLibraryError(null) - setLibrarySelectedEntryId(entryId) - }} - onSearchQueryChange={setLibrarySearchQuery} - onSortModeChange={setLibrarySortMode} - onToggleSection={(sectionKey) => setLibraryCollapsedSectionKeys((current) => toggleAssetLibrarySectionKey(current, sectionKey))} - onOpenSelected={() => { void handleOpenSelectedLibraryEntry() }} - onRefresh={() => { void loadLibraryEntries() }} - onClose={() => setOpenPanel(null)} - /> - )} -
- - {hasModel && ( - <> -
- - {/* Export */} -
- - {openPanel === 'export' && ( - void} - onClose={() => setOpenPanel(null)} - /> - )} -
- - {/* Smooth */} -
- - {openPanel === 'smooth' && ( - setOpenPanel(null)} - /> - )} -
- - {/* Decimate */} -
- - {openPanel === 'decimate' && ( - setOpenPanel(null)} - /> - )} -
- - - )} - - {/* Light — always visible, pushed to the right */} -
- - {openPanel === 'light' && ( - setOpenPanel(null)} - /> - )} -
-
- - {/* Tools bar — always visible; transform tools appear once a mesh is selected */} -
- {hasModel && meshSelected && ( - <> - setGizmoMode((m) => (m === 'translate' ? null : 'translate'))} - > - - - - - - - - - - setGizmoMode((m) => (m === 'rotate' ? null : 'rotate'))} - > - - - - - + + async function handleOpenInOrcaSlicer() { + if (!currentJob?.outputUrl) return + try { + const link = buildOrcaSlicerDeepLink(apiUrl, currentJob.outputUrl) + const result = await window.electron.slicer.open(link) + if (!result.success) { + // Deliberately not "make sure it is installed": the main process cannot + // tell a missing OrcaSlicer from a working one (see slicer:open). + showError(result.error ?? 'Could not open OrcaSlicer.') + } + } catch (err) { + showError(err instanceof Error ? err.message : 'Could not open OrcaSlicer.') + } + } + + function getOptimizePath(url: string): string { + if (url.startsWith('/workspace/')) { + return url.slice('/workspace/'.length) + } + if (url.startsWith('/optimize/serve-file?path=')) { + return decodeURIComponent(url.split('path=')[1] ?? '') + } + return url + } + + async function handleImportMesh() { + const filePath = await window.electron.fs.selectMeshFile() + if (!filePath) return + setOpenPanel(null) + setImporting(true) + try { + const { url } = await importMesh(filePath) + const job: GenerationJob = { + id: `import-${Date.now()}`, + imageFile: '', + status: 'done', + progress: 100, + outputUrl: url, + originalOutputUrl: url, + createdAt: Date.now(), + } + setCurrentJob(job) + pushMeshUrl(url) + } finally { + setImporting(false) + } + } + + async function loadLibraryEntries() { + setLibraryLoading(true) + setLibraryError(null) + try { + const result = await assetLibraryService.list() + if (!result.success) { + setLibraryLoaded(false) + setLibraryEntries([]) + setLibrarySelectedEntryId(null) + setLibraryError(result.error.message) + return + } + setLibraryEntries(result.entries) + setLibrarySelectedEntryId((current) => current && result.entries.some((entry) => entry.id === current) + ? current + : result.entries.find(isAssetLibraryEntryOpenable)?.id ?? result.entries[0]?.id ?? null) + setLibraryLoaded(true) + } catch (err) { + setLibraryLoaded(false) + setLibraryEntries([]) + setLibrarySelectedEntryId(null) + setLibraryError(err instanceof Error ? err.message : String(err)) + } finally { + setLibraryLoading(false) + } + } + + async function handleOpenSelectedLibraryEntry() { + const selectedEntry = libraryEntries.find((entry) => entry.id === librarySelectedEntryId) ?? null + if (!selectedEntry) { + setLibraryError('Select an asset before opening it in Generate.') + return + } + if (!isAssetLibraryEntryOpenable(selectedEntry)) { + setLibraryError(describeAssetLibraryOpenability(selectedEntry)) + return + } + + setLibraryOpening(true) + setLibraryError(null) + try { + const result = await assetLibraryService.open(buildAssetLibraryOpenRequest(selectedEntry)) + if (!result.success) { + setLibraryError(result.error.message) + return + } + const target = resolveAssetLibraryOpenTarget(result.entry) + const selection = createAssetLibraryOpenJob(result.entry, target) + if (!selection) { + setLibraryError(describeAssetLibraryOpenability(result.entry)) + return + } + setLibraryEntries((currentEntries) => currentEntries.map((entry) => entry.id === result.entry.id ? result.entry : entry)) + setLibrarySelectedEntryId(result.entry.id) + setCurrentJob(selection.job) + pushMeshUrl(selection.historyUrl) + setOpenPanel((currentPanel) => resolveOpenPanelAfterLibrarySelection(currentPanel)) + } catch (err) { + setLibraryError(err instanceof Error ? err.message : String(err)) + } finally { + setLibraryOpening(false) + } + } + + async function handleSmooth(iterations: number) { + if (!currentJob?.outputUrl) return + setSmoothing(true) + try { + const path = getOptimizePath(currentJob.outputUrl) + const { url } = await smoothMesh(path, iterations) + updateCurrentJob({ outputUrl: url }) + pushMeshUrl(url) + setOpenPanel(null) + } catch (err) { + showError(err instanceof Error ? err.message : String(err)) + } finally { + setSmoothing(false) + } + } + + async function handleDecimate(targetFaces: number) { + if (!currentJob?.outputUrl) return + setDecimating(true) + try { + const path = getOptimizePath(currentJob.outputUrl) + const { url } = await optimizeMesh(path, targetFaces) + updateCurrentJob({ outputUrl: url }) + pushMeshUrl(url) + setOpenPanel(null) + } catch (err) { + showError(err instanceof Error ? err.message : String(err)) + } finally { + setDecimating(false) + } + } + + const onMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault() + dragging.current = true + + const onMouseMove = (ev: MouseEvent) => { + if (!dragging.current) return + setPanelWidth((w) => Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, w + ev.movementX))) + } + const onMouseUp = () => { + dragging.current = false + window.removeEventListener('mousemove', onMouseMove) + window.removeEventListener('mouseup', onMouseUp) + } + window.addEventListener('mousemove', onMouseMove) + window.addEventListener('mouseup', onMouseUp) + }, []) + + return ( + <> +
+ +
+ + {/* Resize handle */} +
+ +
+ {/* Header bar */} +
+ + {/* Free memory */} + + +
+ + {/* Undo / Redo */} + + + +
+ + {/* Import */} +
+ + {openPanel === 'import' && ( +
+ +
+ )} +
+ +
+ { + setLibraryError(null) + setOpenPanel((panel) => (panel === 'library' ? null : 'library')) + }} + /> + {openPanel === 'library' && ( + { + setLibraryError(null) + setLibrarySelectedEntryId(entryId) + }} + onSearchQueryChange={setLibrarySearchQuery} + onSortModeChange={setLibrarySortMode} + onToggleSection={(sectionKey) => setLibraryCollapsedSectionKeys((current) => toggleAssetLibrarySectionKey(current, sectionKey))} + onOpenSelected={() => { void handleOpenSelectedLibraryEntry() }} + onRefresh={() => { void loadLibraryEntries() }} + onClose={() => setOpenPanel(null)} + /> + )} +
+ + {hasModel && ( + <> +
+ + {/* Export */} +
+ + {openPanel === 'export' && ( + setOpenPanel(null)} + onOpenInSlicer={() => { void handleOpenInOrcaSlicer() }} + canOpenInSlicer={showOpenInSlicer} + /> + )} +
+ + {/* Smooth */} +
+ + {openPanel === 'smooth' && ( + setOpenPanel(null)} + /> + )} +
+ + {/* Decimate */} +
+ + {openPanel === 'decimate' && ( + setOpenPanel(null)} + /> + )} +
+ + + )} + + {/* Light — always visible, pushed to the right */} +
+ + {openPanel === 'light' && ( + setOpenPanel(null)} + pointLights={pointLights} + onPointLightsChange={setPointLights} + selectedPointLightId={selectedPointLightId} + onSelectPointLight={handleSelectPointLight} + /> + )} +
+
+ + {/* Tools bar — always visible; transform tools appear once a mesh is selected */} +
+ {(meshSelected || selectedPointLightId) && ( + <> + setGizmoMode((m) => (m === 'translate' ? null : 'translate'))} + > + + + + + + + + + + setGizmoMode((m) => (m === 'rotate' ? null : 'rotate'))} + > + + + + + setGizmoMode((m) => (m === 'scale' ? null : 'scale'))} - > - - - - + onClick={() => setGizmoMode((m) => (m === 'scale' ? null : 'scale'))} + > + + + + + {meshSelected && ( + <> +
- - {/* Viewer area */} -
- - -
-
- - ) -} \ No newline at end of file +
+ + {/* Viewer area */} +
+ + +
+
+ + ) +} From 866429676c52eaccfc5f9c235b369395ff695ccf Mon Sep 17 00:00:00 2001 From: mojo Date: Fri, 2 Oct 2026 12:54:46 -0400 Subject: [PATCH 2/2] Add files via upload Viewer3D.tsx: keep mesh selection through gizmo release, reset transforms, and export the live mesh as GLB, OBJ, STL, or PLY. --- src/areas/generate/components/Viewer3D.tsx | 2285 +++++++++++--------- 1 file changed, 1284 insertions(+), 1001 deletions(-) diff --git a/src/areas/generate/components/Viewer3D.tsx b/src/areas/generate/components/Viewer3D.tsx index 8cff822f..70ec6495 100644 --- a/src/areas/generate/components/Viewer3D.tsx +++ b/src/areas/generate/components/Viewer3D.tsx @@ -1,898 +1,1123 @@ -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 { 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' import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js' -import { computeBoundsTree, disposeBoundsTree, acceleratedRaycast } from 'three-mesh-bvh' - -// Patch THREE pour utiliser BVH sur tous les meshes — réduit le raycast O(N) → O(log N) -THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree as any -THREE.BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree as any -THREE.Mesh.prototype.raycast = acceleratedRaycast -import SplatViewer, { type SplatViewerHandle } from './SplatViewer' -import { useGeneration } from '@shared/hooks/useGeneration' -import { useAppStore } from '@shared/stores/appStore' -import { ViewerToolbar, type ViewMode } from './ViewerToolbar' -import type { LightSettings } from '@shared/stores/appStore' -import { DEFAULT_LIGHT_SETTINGS } from '@shared/stores/appStore' - +import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js' +import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js' +import { PLYExporter } from 'three/examples/jsm/exporters/PLYExporter.js' +import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js' +import { computeBoundsTree, disposeBoundsTree, acceleratedRaycast } from 'three-mesh-bvh' + +// Patch THREE pour utiliser BVH sur tous les meshes — réduit le raycast O(N) → O(log N) +THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree as any +THREE.BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree as any +THREE.Mesh.prototype.raycast = acceleratedRaycast +import SplatViewer, { type SplatViewerHandle } from './SplatViewer' +import { useGeneration } from '@shared/hooks/useGeneration' +import { useAppStore } from '@shared/stores/appStore' +import { ViewerToolbar, type ViewMode } from './ViewerToolbar' +import type { LightSettings, PointLight } 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 -const SELECTION_OUTLINE_BLUR = false -const SELECTION_OUTLINE_MULTISAMPLING = 0 -const SELECTION_OUTLINE_RESOLUTION_SCALE = 0.5 - -// --------------------------------------------------------------------------- -// Procedural textures -// --------------------------------------------------------------------------- - -function createMatcapTexture(): THREE.CanvasTexture { - const size = 128 - const canvas = document.createElement('canvas') - canvas.width = canvas.height = size - const ctx = canvas.getContext('2d')! - const grad = ctx.createRadialGradient(size * 0.35, size * 0.3, 0, size / 2, size / 2, size / 2) - grad.addColorStop(0, '#ffffff') - grad.addColorStop(0.45, '#aaaaaa') - grad.addColorStop(1, '#222222') - ctx.fillStyle = grad - ctx.fillRect(0, 0, size, size) - return new THREE.CanvasTexture(canvas) -} - -function createCheckerTexture(): THREE.CanvasTexture { - const size = 256 - const tileCount = 8 - const tileSize = size / tileCount - const canvas = document.createElement('canvas') - canvas.width = canvas.height = size - const ctx = canvas.getContext('2d')! - for (let row = 0; row < tileCount; row++) { - for (let col = 0; col < tileCount; col++) { - ctx.fillStyle = (row + col) % 2 === 0 ? '#e0e0e0' : '#888888' - ctx.fillRect(col * tileSize, row * tileSize, tileSize, tileSize) - } +export type MeshExportFormat = 'glb' | 'obj' | 'stl' | 'ply' +export type MeshExportHandler = (format: MeshExportFormat) => Promise + +function cloneForExport(source: THREE.Object3D): THREE.Object3D { + const hadOriginalMaterial = Object.prototype.hasOwnProperty.call(source.userData, 'originalMaterial') + const originalMaterial = source.userData.originalMaterial as THREE.Material | THREE.Material[] | undefined + const savedUserData = source.userData + + // originalMaterial is a live Three.js object held in userData for display-mode + // switching. Leave it out of the clone's serializable userData, then restore it + // on the source object before returning. + if (hadOriginalMaterial) { + source.userData = { ...savedUserData } + delete source.userData.originalMaterial } - const tex = new THREE.CanvasTexture(canvas) - tex.wrapS = tex.wrapT = THREE.RepeatWrapping - return tex -} - -// --------------------------------------------------------------------------- -// CanvasCapture — exposes gl.domElement ref outside Canvas -// --------------------------------------------------------------------------- - -function CanvasCapture({ - domRef, -}: { - domRef: React.MutableRefObject -}): null { - const { gl } = useThree() - useEffect(() => { - domRef.current = gl.domElement - // eslint-disable-next-line react-hooks/exhaustive-deps -- domRef is a stable ref - }, [gl]) - return null -} - -// --------------------------------------------------------------------------- -// ModelErrorBoundary — catches useGLTF load failures (e.g. 404) -// --------------------------------------------------------------------------- - -interface ErrorBoundaryProps { - children: ReactNode - fallback: ReactNode - resetKey?: string | null -} - -interface ErrorBoundaryState { - hasError: boolean -} - -class ModelErrorBoundary extends Component { - state: ErrorBoundaryState = { hasError: false } + const clone = (() => { + try { + return source.clone(false) + } finally { + source.userData = savedUserData + } + })() - static getDerivedStateFromError(): ErrorBoundaryState { - return { hasError: true } + if (source instanceof THREE.Mesh && clone instanceof THREE.Mesh) { + clone.geometry = source.geometry + clone.material = originalMaterial ?? source.material } - componentDidCatch(error: Error, info: ErrorInfo): void { - console.warn('[Viewer3D] Failed to load model:', error.message, info.componentStack) + for (const child of source.children) { + clone.add(cloneForExport(child)) } + return clone +} - componentDidUpdate(prevProps: ErrorBoundaryProps): void { - if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) { - this.setState({ hasError: false }) - } - } +async function exportMeshObject(object: THREE.Object3D, format: MeshExportFormat): Promise { + object.updateMatrixWorld(true) - render(): ReactNode { - return this.state.hasError ? this.props.fallback : this.props.children + switch (format) { + case 'glb': { + const data = await new GLTFExporter().parseAsync(object, { binary: true }) + if (!(data instanceof ArrayBuffer)) throw new Error('GLB export did not return binary data') + return new Blob([data], { type: 'model/gltf-binary' }) + } + case 'obj': { + const data = new OBJExporter().parse(object) + return new Blob([data], { type: 'text/plain' }) + } + case 'stl': { + const data = new STLExporter().parse(object, { binary: true }) + const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice() + return new Blob([bytes], { type: 'application/octet-stream' }) + } + case 'ply': { + const data = new PLYExporter().parse(object, () => {}, { binary: true }) + if (!data) throw new Error('PLY export did not return binary data') + return new Blob([data], { type: 'application/octet-stream' }) + } } } - -function ModelLoadError(): JSX.Element { - return ( -
- - - - - -

Model file not found

-
- ) -} - -// --------------------------------------------------------------------------- -// MeshModel -// --------------------------------------------------------------------------- - + +const SELECTION_OUTLINE_VISIBLE_COLOR = 0x8b5cf6 +const SELECTION_OUTLINE_HIDDEN_COLOR = 0x5b21b6 +const SELECTION_OUTLINE_EDGE_STRENGTH = 2.5 +const SELECTION_OUTLINE_BLUR = false +const SELECTION_OUTLINE_MULTISAMPLING = 0 +const SELECTION_OUTLINE_RESOLUTION_SCALE = 0.5 + +// --------------------------------------------------------------------------- +// Procedural textures +// --------------------------------------------------------------------------- + +function createMatcapTexture(): THREE.CanvasTexture { + const size = 128 + const canvas = document.createElement('canvas') + canvas.width = canvas.height = size + const ctx = canvas.getContext('2d')! + const grad = ctx.createRadialGradient(size * 0.35, size * 0.3, 0, size / 2, size / 2, size / 2) + grad.addColorStop(0, '#ffffff') + grad.addColorStop(0.45, '#aaaaaa') + grad.addColorStop(1, '#222222') + ctx.fillStyle = grad + ctx.fillRect(0, 0, size, size) + return new THREE.CanvasTexture(canvas) +} + +function createCheckerTexture(): THREE.CanvasTexture { + const size = 256 + const tileCount = 8 + const tileSize = size / tileCount + const canvas = document.createElement('canvas') + canvas.width = canvas.height = size + const ctx = canvas.getContext('2d')! + for (let row = 0; row < tileCount; row++) { + for (let col = 0; col < tileCount; col++) { + ctx.fillStyle = (row + col) % 2 === 0 ? '#e0e0e0' : '#888888' + ctx.fillRect(col * tileSize, row * tileSize, tileSize, tileSize) + } + } + const tex = new THREE.CanvasTexture(canvas) + tex.wrapS = tex.wrapT = THREE.RepeatWrapping + return tex +} + +const SELECTION_OUTLINE_COLOR = '#8b5cf6' + +function makeLightBulbTexture(color: string, isSelected: boolean): THREE.CanvasTexture { + const size = 64 + const canvas = document.createElement('canvas') + canvas.width = canvas.height = size + const ctx = canvas.getContext('2d')! + + const cx = size / 2 + const cy = size / 2 + + // Draws the bulb glyph (rays + circle + base). `pad` grows every part by a + // few pixels — used to lay down an oversized violet silhouette behind the + // normal-sized icon, so the outline hugs the actual glyph shape instead of + // being a plain circle around it. + const drawGlyph = (fillColor: string, pad: number) => { + ctx.strokeStyle = fillColor + ctx.lineWidth = 2 + pad * 2 + ctx.lineCap = 'round' + for (let i = 0; i < 6; i++) { + const a = (i / 6) * Math.PI * 2 - Math.PI / 2 + const r1 = 20 - pad + const r2 = 27 + pad + ctx.beginPath() + ctx.moveTo(cx + Math.cos(a) * r1, cy + Math.sin(a) * r1) + ctx.lineTo(cx + Math.cos(a) * r2, cy + Math.sin(a) * r2) + ctx.stroke() + } + + ctx.beginPath() + ctx.arc(cx, cy - 1, 12 + pad, 0, Math.PI * 2) + ctx.fillStyle = fillColor + ctx.fill() + if (pad === 0) { + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 1.5 + ctx.stroke() + } + + ctx.fillStyle = fillColor + ctx.fillRect(cx - 4 - pad, cy + 11 - pad, 8 + pad * 2, 8 + pad * 2) + if (pad === 0) { + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 1 + ctx.strokeRect(cx - 4, cy + 11, 8, 8) + } + } + + if (isSelected) drawGlyph(SELECTION_OUTLINE_COLOR, 2.5) + drawGlyph(color, 0) + + return new THREE.CanvasTexture(canvas) +} + +// --------------------------------------------------------------------------- +// CanvasCapture — exposes gl.domElement ref outside Canvas +// --------------------------------------------------------------------------- + +function CanvasCapture({ + domRef, +}: { + domRef: React.MutableRefObject +}): null { + const { gl } = useThree() + useEffect(() => { + domRef.current = gl.domElement + // eslint-disable-next-line react-hooks/exhaustive-deps -- domRef is a stable ref + }, [gl]) + return null +} + +// --------------------------------------------------------------------------- +// ModelErrorBoundary — catches useGLTF load failures (e.g. 404) +// --------------------------------------------------------------------------- + +interface ErrorBoundaryProps { + children: ReactNode + fallback: ReactNode + resetKey?: string | null +} + +interface ErrorBoundaryState { + hasError: boolean +} + +class ModelErrorBoundary extends Component { + state: ErrorBoundaryState = { hasError: false } + + static getDerivedStateFromError(): ErrorBoundaryState { + return { hasError: true } + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.warn('[Viewer3D] Failed to load model:', error.message, info.componentStack) + } + + componentDidUpdate(prevProps: ErrorBoundaryProps): void { + if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) { + this.setState({ hasError: false }) + } + } + + render(): ReactNode { + return this.state.hasError ? this.props.fallback : this.props.children + } +} + +function ModelLoadError(): JSX.Element { + return ( +
+ + + + + +

Model file not found

+
+ ) +} + +// --------------------------------------------------------------------------- +// MeshModel +// --------------------------------------------------------------------------- + interface MeshModelProps { url: string jobId: string viewMode: ViewMode - selected: boolean - onStats: (stats: { vertices: number; triangles: number }) => void - onSelect: () => void + 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, onObject }: MeshModelProps): JSX.Element { - const extension = url.split('?')[0]?.split('.').pop()?.toLowerCase() - const common = { url, jobId, viewMode, selected, onStats, onSelect, onObject } - return extension === 'obj' ? : -} - -function GltfMeshModel(props: MeshModelProps): JSX.Element { - const { scene } = useGLTF(props.url) - return -} - -function ObjMeshModel(props: MeshModelProps): JSX.Element { - const scene = useLoader(OBJLoader, props.url) - return +function centerSceneOnGrid(scene: THREE.Object3D): void { + scene.position.set(0, 0, 0) + scene.rotation.set(0, 0, 0) + scene.scale.set(1, 1, 1) + scene.updateMatrixWorld(true) + + const box = new THREE.Box3().setFromObject(scene) + const center = new THREE.Vector3() + box.getCenter(center) + scene.position.set(-center.x, -box.min.y, -center.z) + scene.updateMatrixWorld(true) } -function SceneMeshModel({ - url, - viewMode, - selected, - onStats, - onSelect, - onObject, - scene, - loaderType, -}: MeshModelProps & { - scene: THREE.Group | THREE.Scene - loaderType: 'gltf' | 'obj' -}): JSX.Element { - 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 () => { - if (loaderType === 'obj') { - useLoader.clear(OBJLoader, url) - } else { - useGLTF.clear(url) - } - scene.traverse((child) => { - if (child instanceof THREE.Mesh) { - child.geometry.dispose() - const materials = Array.isArray(child.material) ? child.material : [child.material] - materials.forEach((m: THREE.Material) => m.dispose()) - } - }) - } - }, [loaderType, scene, url]) - - // Compute BVH on all geometries for fast raycasting (O(log N) vs O(N)). - // Also force DoubleSide on every material so faces with inverted normals - // (a known artifact of the flexible-dual-grid mesh decoder) are still visible. - useEffect(() => { - scene.traverse((child) => { - if (child instanceof THREE.Mesh) { - (child.geometry as any).computeBoundsTree() - const mats = Array.isArray(child.material) ? child.material : [child.material] - mats.forEach((m: THREE.Material) => { m.side = THREE.DoubleSide }) - } - }) - return () => { - scene.traverse((child) => { - if (child instanceof THREE.Mesh) { - (child.geometry as any).disposeBoundsTree?.() - } - }) - } - }, [scene]) - +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, onObject } + return extension === 'obj' ? : +} + +function GltfMeshModel(props: MeshModelProps): JSX.Element { + const { scene } = useGLTF(props.url) + return +} + +function ObjMeshModel(props: MeshModelProps): JSX.Element { + const scene = useLoader(OBJLoader, props.url) + return +} + +function SceneMeshModel({ + url, + viewMode, + selected, + onStats, + onSelect, + onObject, + scene, + loaderType, +}: MeshModelProps & { + scene: THREE.Group | THREE.Scene + loaderType: 'gltf' | 'obj' +}): JSX.Element { + 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 () => { + if (loaderType === 'obj') { + useLoader.clear(OBJLoader, url) + } else { + useGLTF.clear(url) + } + scene.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.geometry.dispose() + const materials = Array.isArray(child.material) ? child.material : [child.material] + materials.forEach((m: THREE.Material) => m.dispose()) + } + }) + } + }, [loaderType, scene, url]) + + // Compute BVH on all geometries for fast raycasting (O(log N) vs O(N)). + // Also force DoubleSide on every material so faces with inverted normals + // (a known artifact of the flexible-dual-grid mesh decoder) are still visible. + useEffect(() => { + scene.traverse((child) => { + if (child instanceof THREE.Mesh) { + (child.geometry as any).computeBoundsTree() + const mats = Array.isArray(child.material) ? child.material : [child.material] + mats.forEach((m: THREE.Material) => { m.side = THREE.DoubleSide }) + } + }) + return () => { + scene.traverse((child) => { + if (child instanceof THREE.Mesh) { + (child.geometry as any).disposeBoundsTree?.() + } + }) + } + }, [scene]) + // 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(() => { // 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) - scene.position.set(-center.x, -box.min.y, -center.z) - - // Compute stats - let vertices = 0 - let triangles = 0 - scene.traverse((child) => { - if (child instanceof THREE.Mesh) { - vertices += child.geometry.attributes.position?.count ?? 0 - triangles += child.geometry.index - ? child.geometry.index.count / 3 - : (child.geometry.attributes.position?.count ?? 0) / 3 - } - }) - const roundedTriangles = Math.round(triangles) - onStats({ vertices: Math.round(vertices), triangles: roundedTriangles }) - // eslint-disable-next-line react-hooks/exhaustive-deps -- recompute on scene change only; onStats is a stable callback - }, [scene]) - - // Thumbnail capture (kept for future use) - useEffect(() => { - captured.current = false - }, [url]) - - // Material swapping based on viewMode - useEffect(() => { - // Remove any edge helpers from previous wireframe pass - edgeHelpers.current.forEach((lines) => lines.parent?.remove(lines)) - edgeHelpers.current = [] - - scene.traverse((child) => { - if (!(child instanceof THREE.Mesh)) return - - // Save original material on first visit - if (!child.userData.originalMaterial) { - child.userData.originalMaterial = child.material - } - - let next: THREE.Material - switch (viewMode) { - case 'wireframe': { - next = new THREE.MeshBasicMaterial({ color: 0x4ade80, wireframe: true }) - break - } - case 'normals': - // Ensure vertex normals exist — AI-generated meshes often skip this - child.geometry.computeVertexNormals() - next = new THREE.MeshNormalMaterial({ side: THREE.DoubleSide }) - break - case 'matcap': - next = new THREE.MeshMatcapMaterial({ matcap: createMatcapTexture() }) - break - case 'uv': - next = new THREE.MeshBasicMaterial({ map: createCheckerTexture() }) - break - default: - next = child.userData.originalMaterial as THREE.Material - } - - child.material = next - }) - }, [scene, viewMode]) - - return ( - - ) - -} - -// --------------------------------------------------------------------------- -// Orientation gizmo — coloured bubbles only (X/Y/Z) -// --------------------------------------------------------------------------- - -function makeAxisLabelTexture(letter: string, bg: string): THREE.CanvasTexture { - const canvas = document.createElement('canvas') - canvas.width = canvas.height = 64 - const ctx = canvas.getContext('2d')! - ctx.beginPath() - ctx.arc(32, 32, 16, 0, 2 * Math.PI) - ctx.closePath() - ctx.fillStyle = bg - ctx.fill() - ctx.font = '18px Arial, sans-serif' - ctx.textAlign = 'center' - ctx.fillStyle = '#ffffff' - ctx.fillText(letter, 32, 41) - return new THREE.CanvasTexture(canvas) -} - -const GIZMO_AXES: { - letter: string - color: string - pos: [number, number, number] - lineRotation: [number, number, number] -}[] = [ - { letter: 'X', color: '#f87171', pos: [1, 0, 0], lineRotation: [0, 0, 0] }, - { letter: 'Y', color: '#4ade80', pos: [0, 1, 0], lineRotation: [0, 0, Math.PI / 2] }, - { letter: 'Z', color: '#60a5fa', pos: [0, 0, 1], lineRotation: [0, -Math.PI / 2, 0] }, -] - -function AxisLine({ color, rotation }: { color: string; rotation: [number, number, number] }) { - return ( - - - - - - - ) -} - -function AxisBubble({ letter, color, pos }: { letter: string; color: string; pos: [number, number, number] }) { - const { tweenCamera } = useGizmoContext() - const texture = useMemo(() => makeAxisLabelTexture(letter, color), [letter, color]) - const [hovered, setHovered] = useState(false) - - return ( - { tweenCamera(e.object.position); e.stopPropagation() }} - onPointerOver={(e) => { e.stopPropagation(); setHovered(true) }} - onPointerOut={() => setHovered(false)} - > - - - ) -} - -function GizmoBubbles() { - return ( - - {GIZMO_AXES.map((axis) => ( - - ))} - {GIZMO_AXES.map((axis) => ( - - ))} - - ) -} - -// --------------------------------------------------------------------------- -// 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, -) { + centerSceneOnGrid(scene) + + // Compute stats + let vertices = 0 + let triangles = 0 + scene.traverse((child) => { + if (child instanceof THREE.Mesh) { + vertices += child.geometry.attributes.position?.count ?? 0 + triangles += child.geometry.index + ? child.geometry.index.count / 3 + : (child.geometry.attributes.position?.count ?? 0) / 3 + } + }) + const roundedTriangles = Math.round(triangles) + onStats({ vertices: Math.round(vertices), triangles: roundedTriangles }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- recompute on scene change only; onStats is a stable callback + }, [scene]) + + // Thumbnail capture (kept for future use) + useEffect(() => { + captured.current = false + }, [url]) + + // Material swapping based on viewMode + useEffect(() => { + // Remove any edge helpers from previous wireframe pass + edgeHelpers.current.forEach((lines) => lines.parent?.remove(lines)) + edgeHelpers.current = [] + + scene.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return + + // Save original material on first visit + if (!child.userData.originalMaterial) { + child.userData.originalMaterial = child.material + } + + let next: THREE.Material + switch (viewMode) { + case 'wireframe': { + next = new THREE.MeshBasicMaterial({ color: 0x4ade80, wireframe: true }) + break + } + case 'normals': + // Ensure vertex normals exist — AI-generated meshes often skip this + child.geometry.computeVertexNormals() + next = new THREE.MeshNormalMaterial({ side: THREE.DoubleSide }) + break + case 'matcap': + next = new THREE.MeshMatcapMaterial({ matcap: createMatcapTexture() }) + break + case 'uv': + next = new THREE.MeshBasicMaterial({ map: createCheckerTexture() }) + break + default: + next = child.userData.originalMaterial as THREE.Material + } + + child.material = next + }) + }, [scene, viewMode]) + + return ( + + ) + +} + +// --------------------------------------------------------------------------- +// Orientation gizmo — coloured bubbles only (X/Y/Z) +// --------------------------------------------------------------------------- + +function makeAxisLabelTexture(letter: string, bg: string): THREE.CanvasTexture { + const canvas = document.createElement('canvas') + canvas.width = canvas.height = 64 + const ctx = canvas.getContext('2d')! + ctx.beginPath() + ctx.arc(32, 32, 16, 0, 2 * Math.PI) + ctx.closePath() + ctx.fillStyle = bg + ctx.fill() + ctx.font = '18px Arial, sans-serif' + ctx.textAlign = 'center' + ctx.fillStyle = '#ffffff' + ctx.fillText(letter, 32, 41) + return new THREE.CanvasTexture(canvas) +} + +const GIZMO_AXES: { + letter: string + color: string + pos: [number, number, number] + lineRotation: [number, number, number] +}[] = [ + { letter: 'X', color: '#f87171', pos: [1, 0, 0], lineRotation: [0, 0, 0] }, + { letter: 'Y', color: '#4ade80', pos: [0, 1, 0], lineRotation: [0, 0, Math.PI / 2] }, + { letter: 'Z', color: '#60a5fa', pos: [0, 0, 1], lineRotation: [0, -Math.PI / 2, 0] }, +] + +function AxisLine({ color, rotation }: { color: string; rotation: [number, number, number] }) { + return ( + + + + + + + ) +} + +function AxisBubble({ letter, color, pos }: { letter: string; color: string; pos: [number, number, number] }) { + const { tweenCamera } = useGizmoContext() + const texture = useMemo(() => makeAxisLabelTexture(letter, color), [letter, color]) + const [hovered, setHovered] = useState(false) + + return ( + { tweenCamera(e.object.position); e.stopPropagation() }} + onPointerOver={(e) => { e.stopPropagation(); setHovered(true) }} + onPointerOut={() => setHovered(false)} + > + + + ) +} + +function GizmoBubbles() { + return ( + + {GIZMO_AXES.map((axis) => ( + + ))} + {GIZMO_AXES.map((axis) => ( + + ))} + + ) +} + +// --------------------------------------------------------------------------- +// 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 -// --------------------------------------------------------------------------- - -function EmptyState(): JSX.Element { - return ( -
- - - -

3D model will appear here

-
- ) -} - -// --------------------------------------------------------------------------- -// Viewer3D -// --------------------------------------------------------------------------- - -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) - - const setStoreMeshStats = useAppStore((s) => s.setMeshStats) - const meshStats = useAppStore((s) => s.meshStats) - const setCurrentJob = useAppStore((s) => s.setCurrentJob) - - const [viewMode, setViewMode] = useState('solid') - const [autoRotate, setAutoRotate] = useState(false) - const selected = useAppStore((s) => s.meshSelected) - const setSelected = useAppStore((s) => s.setMeshSelected) - 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 + +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))}> + + + ))} + + ) +} + +// --------------------------------------------------------------------------- +// PointLightMarker — renders a point light + billboard icon + optional gizmo +// --------------------------------------------------------------------------- + +function PointLightMarker({ + light, + isSelected, + gizmoMode, + onSelect, + onPositionChange, +}: { + light: PointLight + isSelected: boolean + gizmoMode: GizmoMode | null + onSelect: () => void + onPositionChange: (pos: [number, number, number]) => void +}) { + const [group, setGroup] = useState(null) + const iconTexture = useMemo(() => makeLightBulbTexture(light.color, isSelected), [light.color, isSelected]) + + return ( + <> + + + { e.stopPropagation(); onSelect() }} + > + + + + {group && isSelected && gizmoMode === 'translate' && ( + onPositionChange([group.position.x, group.position.y, group.position.z])} + /> + )} + {group && isSelected && gizmoMode === 'rotate' && ( + onPositionChange([group.position.x, group.position.y, group.position.z])} + /> + )} + {group && isSelected && gizmoMode === 'scale' && ( + onPositionChange([group.position.x, group.position.y, group.position.z])} + /> + )} + + ) +} + +// --------------------------------------------------------------------------- +// EmptyState +// --------------------------------------------------------------------------- + +function EmptyState(): JSX.Element { + return ( +
+ + + +

3D model will appear here

+
+ ) +} + +// --------------------------------------------------------------------------- +// Viewer3D +// --------------------------------------------------------------------------- + +type TransformSnapshot = { p: THREE.Vector3; q: THREE.Quaternion; s: THREE.Vector3 } + +export default function Viewer3D({ + lightSettings = DEFAULT_LIGHT_SETTINGS, + gizmoMode = null, + gizmoUndoRef, + gizmoResetRef, + meshExportRef, + pointLights = [], + selectedPointLightId = null, + onSelectPointLight, + onPointLightsChange, +}: { + lightSettings?: LightSettings + gizmoMode?: GizmoMode | null + gizmoUndoRef?: MutableRefObject<(() => boolean) | null> + gizmoResetRef?: MutableRefObject<(() => boolean) | null> + meshExportRef?: MutableRefObject + pointLights?: PointLight[] + selectedPointLightId?: string | null + onSelectPointLight?: (id: string | null) => void + onPointLightsChange?: (lights: PointLight[]) => void +}): JSX.Element { + const { currentJob } = useGeneration() + const apiUrl = useAppStore((s) => s.apiUrl) + + const setStoreMeshStats = useAppStore((s) => s.setMeshStats) + const meshStats = useAppStore((s) => s.meshStats) + const setCurrentJob = useAppStore((s) => s.setCurrentJob) + + const [viewMode, setViewMode] = useState('solid') + const [autoRotate, setAutoRotate] = useState(false) + const selected = useAppStore((s) => s.meshSelected) + const setSelected = useAppStore((s) => s.setMeshSelected) + 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 gizmoDragActive = useRef(false) + const ignoreGizmoReleaseMiss = useRef(false) - const outputUrl = currentJob?.outputUrl ?? '' - const modelUrl = - currentJob?.status === 'done' && currentJob.outputUrl - ? `${apiUrl}${currentJob.outputUrl}` - : null - - // A .ply/.splat reaching the viewer is always a Gaussian splat here: mesh - // plys are converted to GLB on import and workflow mesh outputs are .glb. - const isSplat = /\.(ply|splat)$/i.test(outputUrl) - - // The splat viewer needs binary .splat — route raw workspace .ply through the - // conversion endpoint; import URLs already point at a .splat via serve-file. - const splatUrl = outputUrl.startsWith('/workspace/') - ? `${apiUrl}/optimize/ply-to-splat?path=${encodeURIComponent(outputUrl.slice('/workspace/'.length))}` - : modelUrl - - // Reset view state when model changes + // A gizmo drag can end with a click event that the canvas interprets as a + // miss. Clear that one-release guard when the user starts a new gesture. useEffect(() => { + const clearReleaseMiss = () => { ignoreGizmoReleaseMiss.current = false } + window.addEventListener('pointerdown', clearReleaseMiss, true) + return () => window.removeEventListener('pointerdown', clearReleaseMiss, true) + }, []) + + const outputUrl = currentJob?.outputUrl ?? '' + const modelUrl = + currentJob?.status === 'done' && currentJob.outputUrl + ? `${apiUrl}${currentJob.outputUrl}` + : null + + // A .ply/.splat reaching the viewer is always a Gaussian splat here: mesh + // plys are converted to GLB on import and workflow mesh outputs are .glb. + const isSplat = /\.(ply|splat)$/i.test(outputUrl) + + // The splat viewer needs binary .splat — route raw workspace .ply through the + // conversion endpoint; import URLs already point at a .splat via serve-file. + const splatUrl = outputUrl.startsWith('/workspace/') + ? `${apiUrl}/optimize/ply-to-splat?path=${encodeURIComponent(outputUrl.slice('/workspace/'.length))}` + : modelUrl + + // Reset view state when model changes + useEffect(() => { + setSelected(false) + setViewMode('solid') + setStoreMeshStats(null) + // eslint-disable-next-line react-hooks/exhaustive-deps -- reset only when the model changes; setters are stable + }, [modelUrl]) + + // Clear the shared selection when the viewer unmounts — the store would + // otherwise keep it set and flash a stale selection on the next mount. + useEffect(() => () => setSelected(false), [setSelected]) + + // Delete key removes the model from the scene + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key !== 'Delete') return + if (document.activeElement instanceof HTMLInputElement) return + if (!selected) return + setCurrentJob(null) + setSelected(false) + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + // eslint-disable-next-line react-hooks/exhaustive-deps -- setSelected is a stable store setter + }, [selected, setCurrentJob]) + + // Deselect both mesh and point light when clicking empty space + const handlePointerMissed = useCallback(() => { + if (gizmoDragActive.current || ignoreGizmoReleaseMiss.current) return setSelected(false) - setViewMode('solid') - setStoreMeshStats(null) - // eslint-disable-next-line react-hooks/exhaustive-deps -- reset only when the model changes; setters are stable - }, [modelUrl]) - - // Clear the shared selection when the viewer unmounts — the store would - // otherwise keep it set and flash a stale selection on the next mount. - useEffect(() => () => setSelected(false), [setSelected]) - - // Delete key removes the model from the scene - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.key !== 'Delete') return - if (document.activeElement instanceof HTMLInputElement) return - if (!selected) return - setCurrentJob(null) - setSelected(false) - } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - // eslint-disable-next-line react-hooks/exhaustive-deps -- setSelected is a stable store setter - }, [selected, setCurrentJob]) - - const handleScreenshot = () => { - const dataUrl = isSplat - ? splatRef.current?.screenshot() ?? null - : canvasRef.current?.toDataURL('image/png') ?? null - if (!dataUrl) return - const link = document.createElement('a') - link.download = `modly-${Date.now()}.png` - link.href = dataUrl - link.click() - } - + onSelectPointLight?.(null) + }, [setSelected, onSelectPointLight]) + + // Select mesh and deselect any selected point light + const handleMeshSelect = useCallback(() => { + if (gizmoDragActive.current || ignoreGizmoReleaseMiss.current) return + setSelected(true) + onSelectPointLight?.(null) + }, [setSelected, onSelectPointLight]) + + const handleScreenshot = () => { + const dataUrl = isSplat + ? splatRef.current?.screenshot() ?? null + : canvasRef.current?.toDataURL('image/png') ?? null + if (!dataUrl) return + const link = document.createElement('a') + link.download = `modly-${Date.now()}.png` + link.href = dataUrl + link.click() + } + // Snapshot the pre-drag pose when a gizmo manipulation starts. const handleGizmoDragStart = useCallback(() => { + gizmoDragActive.current = true + ignoreGizmoReleaseMiss.current = false 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. + q: meshObject.quaternion.clone(), + s: meshObject.scale.clone(), + } + } + }, [meshObject]) + + // Commit the snapshot on release, but only if the pose actually changed. const handleGizmoDragEnd = useCallback(() => { + gizmoDragActive.current = false + ignoreGizmoReleaseMiss.current = true const before = pendingTransform.current pendingTransform.current = null if (!before || !meshObject) return @@ -901,18 +1126,53 @@ export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS, gizmo || !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. + + // 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) + meshObject.position.copy(prev.p) + meshObject.quaternion.copy(prev.q) + meshObject.scale.copy(prev.s) return true }, [meshObject]) + const resetTransform = useCallback((): boolean => { + if (!meshObject) return false + const before = { + p: meshObject.position.clone(), + q: meshObject.quaternion.clone(), + s: meshObject.scale.clone(), + } + + centerSceneOnGrid(meshObject) + const changed = !meshObject.position.equals(before.p) + || !meshObject.quaternion.equals(before.q) + || !meshObject.scale.equals(before.s) + if (changed) transformHistory.current.push(before) + return changed + }, [meshObject]) + + const exportCurrentMesh = useCallback(async (format) => { + if (isSplat) return null + if (!meshObject) throw new Error('The mesh is still loading. Try exporting again once it appears.') + meshObject.updateWorldMatrix(true, false) + const exportObject = cloneForExport(meshObject) + // The clone is detached from the viewer's parent hierarchy, so preserve its + // full world transform (including any parent transform) at the export root. + exportObject.matrix.copy(meshObject.matrixWorld) + exportObject.matrixAutoUpdate = false + exportObject.matrixWorld.copy(meshObject.matrixWorld) + return exportMeshObject(exportObject, format) + }, [meshObject, isSplat]) + + useEffect(() => { + if (!meshExportRef) return + meshExportRef.current = exportCurrentMesh + return () => { if (meshExportRef.current === exportCurrentMesh) meshExportRef.current = null } + }, [meshExportRef, exportCurrentMesh]) + // Expose transform-undo so the page's Ctrl+Z undoes gizmo edits first. useEffect(() => { if (!gizmoUndoRef) return @@ -920,150 +1180,173 @@ export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS, gizmo 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]) - - // Memoise the post-processing stack so its children stay referentially stable. - // @react-three/postprocessing rebuilds (recompiles) all EffectPasses whenever the - // children identity changes; without this, every Viewer3D re-render - // (e.g. dragging a Lighting slider) recompiles the outline shader. The Outline still - // tracks selection through the context, so nothing here needs to depend - // on render state. - const postProcessing = useMemo(() => ( - - - - ), []) - - - return ( - }> -
- {!modelUrl && } - - {/* Splat path → fully isolated viewer (mkkellogg, outside R3F) */} - {modelUrl && isSplat && splatUrl ? ( - - ) : null} - - {/* Mesh path → original Canvas, unchanged */} - {!isSplat && ( - setSelected(false)} - camera={{ position: [0, 1.5, 4], fov: 45 }} - dpr={[1, 2]} - gl={{ - antialias: true, - preserveDrawingBuffer: true, - outputColorSpace: THREE.SRGBColorSpace, - }} - > - - - - - - - - - + if (!gizmoResetRef) return + gizmoResetRef.current = resetTransform + return () => { if (gizmoResetRef.current === resetTransform) gizmoResetRef.current = null } + }, [gizmoResetRef, resetTransform]) + + // Drop the transform history when the model changes. + useEffect(() => { + transformHistory.current = [] + pendingTransform.current = null + }, [modelUrl]) + + // Memoise the post-processing stack so its children stay referentially stable. + // @react-three/postprocessing rebuilds (recompiles) all EffectPasses whenever the + // children identity changes; without this, every Viewer3D re-render + // (e.g. dragging a Lighting slider) recompiles the outline shader. The Outline still + // tracks selection through the context, so nothing here needs to depend + // on render state. + const postProcessing = useMemo(() => ( + + + + ), []) + + + return ( + }> +
+ {!modelUrl && } + + {/* Splat path → fully isolated viewer (mkkellogg, outside R3F) */} + {modelUrl && isSplat && splatUrl ? ( + + ) : null} + + {/* Mesh path → original Canvas, unchanged */} + {!isSplat && ( + + + + + + + + + + - - {modelUrl && currentJob ? ( - - {postProcessing} - - - - setSelected(true)} - onObject={setMeshObject} - /> - - - ) : null} - - {selected && meshObject && gizmoMode === 'translate' && ( - - )} - {selected && meshObject && gizmoMode === 'rotate' && ( - - )} - {selected && meshObject && gizmoMode === 'scale' && ( - - )} - - - - - - - - )} - - {/* Left toolbar — visible only when a model is loaded */} - {modelUrl && ( - setAutoRotate((v) => !v)} - onScreenshot={handleScreenshot} - showViewModes={!isSplat} - /> - )} - - {/* Bottom-left stats overlay */} - {meshStats && ( -
-

- {meshStats.triangles.toLocaleString()} tri • {meshStats.vertices.toLocaleString()} verts -

-
- )} - - {/* Bottom-right hint */} - {modelUrl && ( -
-

- {selected - ? <>Click mesh to select • Delete to remove - : 'Drag to rotate \u2022 Scroll to zoom' - } -

-
- )} -
-
- ) -} \ No newline at end of file + + {modelUrl && currentJob ? ( + + {postProcessing} + + + + + + + ) : null} + + {/* Point light markers */} + {onPointLightsChange && pointLights.map((pl) => ( + { + setSelected(false) + onSelectPointLight?.(pl.id) + }} + onPositionChange={(pos) => { + onPointLightsChange!(pointLights.map((p) => p.id === pl.id ? { ...p, position: pos } : p)) + }} + /> + ))} + + {selected && meshObject && gizmoMode === 'translate' && ( + + )} + {selected && meshObject && gizmoMode === 'rotate' && ( + + )} + {selected && meshObject && gizmoMode === 'scale' && ( + + )} + + + + + + +
+ )} + + {/* Left toolbar — visible only when a model is loaded */} + {modelUrl && ( + setAutoRotate((v) => !v)} + onScreenshot={handleScreenshot} + showViewModes={!isSplat} + /> + )} + + {/* Bottom-left stats overlay */} + {meshStats && ( +
+

+ {meshStats.triangles.toLocaleString()} tri • {meshStats.vertices.toLocaleString()} verts +

+
+ )} + + {/* Bottom-right hint */} + {modelUrl && ( +
+

+ {selected + ? <>Click mesh to select • Delete to remove + : 'Drag to rotate \u2022 Scroll to zoom' + } +

+
+ )} +
+
+ ) +}