Matt Van Horn a5378da020
feat: add STL and OBJ export formats (#175)
Extend the export manager to support STL and OBJ formats in addition to
the existing GLB export. Uses Three.js built-in STLExporter and
OBJExporter - no new dependencies.

STL is the standard format for 3D printing. OBJ is widely used in
rendering pipelines and supports basic materials. Both are requested
in the community discussion on #145.

Changes:
- export-manager.tsx: add STLExporter and OBJExporter, accept format param
- settings-panel: three format-specific export buttons (GLB, STL, OBJ)
- use-viewer store: update exportScene type to accept optional format

The format parameter defaults to 'glb', so existing callers (command
palette) continue to work without changes.

Relates to #145

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 00:30:34 -04:00

206 lines
6.1 KiB
TypeScript

'use client'
import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core'
import type { Object3D } from 'three'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type SelectionPath = {
buildingId: BuildingNode['id'] | null
levelId: LevelNode['id'] | null
zoneId: ZoneNode['id'] | null
selectedIds: BaseNode['id'][] // For items/assets (multi-select)
}
type Outliner = {
selectedObjects: Object3D[]
hoveredObjects: Object3D[]
}
type ViewerState = {
selection: SelectionPath
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
cameraMode: 'perspective' | 'orthographic'
setCameraMode: (mode: 'perspective' | 'orthographic') => void
theme: 'light' | 'dark'
setTheme: (theme: 'light' | 'dark') => void
unit: 'metric' | 'imperial'
setUnit: (unit: 'metric' | 'imperial') => void
levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'
setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void
wallMode: 'up' | 'cutaway' | 'down'
setWallMode: (mode: 'up' | 'cutaway' | 'down') => void
showScans: boolean
setShowScans: (show: boolean) => void
showGuides: boolean
setShowGuides: (show: boolean) => void
showGrid: boolean
setShowGrid: (show: boolean) => void
projectId: string | null
setProjectId: (id: string | null) => void
projectPreferences: Record<
string,
{ showScans?: boolean; showGuides?: boolean; showGrid?: boolean }
>
// Smart selection update
setSelection: (updates: Partial<SelectionPath>) => void
resetSelection: () => void
outliner: Outliner // No setter as we will manipulate directly the arrays
// Export functionality
exportScene: ((format?: 'glb' | 'stl' | 'obj') => Promise<void>) | null
setExportScene: (fn: ((format?: 'glb' | 'stl' | 'obj') => Promise<void>) | null) => void
debugColors: boolean
setDebugColors: (enabled: boolean) => void
cameraDragging: boolean
setCameraDragging: (dragging: boolean) => void
}
const useViewer = create<ViewerState>()(
persist(
(set) => ({
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
hoveredId: null,
setHoveredId: (id) => set({ hoveredId: id }),
cameraMode: 'perspective',
setCameraMode: (mode) => set({ cameraMode: mode }),
theme: 'light',
setTheme: (theme) => set({ theme }),
unit: 'metric',
setUnit: (unit) => set({ unit }),
levelMode: 'stacked',
setLevelMode: (mode) => set({ levelMode: mode }),
wallMode: 'up',
setWallMode: (mode) => set({ wallMode: mode }),
showScans: true,
setShowScans: (show) =>
set((state) => {
const projectPreferences = { ...(state.projectPreferences || {}) }
if (state.projectId) {
projectPreferences[state.projectId] = {
...(projectPreferences[state.projectId] || {}),
showScans: show,
}
}
return { showScans: show, projectPreferences }
}),
showGuides: true,
setShowGuides: (show) =>
set((state) => {
const projectPreferences = { ...(state.projectPreferences || {}) }
if (state.projectId) {
projectPreferences[state.projectId] = {
...(projectPreferences[state.projectId] || {}),
showGuides: show,
}
}
return { showGuides: show, projectPreferences }
}),
showGrid: true,
setShowGrid: (show) =>
set((state) => {
const projectPreferences = { ...(state.projectPreferences || {}) }
if (state.projectId) {
projectPreferences[state.projectId] = {
...(projectPreferences[state.projectId] || {}),
showGrid: show,
}
}
return { showGrid: show, projectPreferences }
}),
projectId: null,
setProjectId: (id) =>
set((state) => {
if (!id) return { projectId: id }
const prefs = state.projectPreferences?.[id] || {}
return {
projectId: id,
showScans: prefs.showScans ?? true,
showGuides: prefs.showGuides ?? true,
showGrid: prefs.showGrid ?? true,
}
}),
projectPreferences: {},
setSelection: (updates) =>
set((state) => {
const newSelection = { ...state.selection, ...updates }
// Hierarchy Guard: If we change a high-level parent, reset the children unless explicitly provided
if (updates.buildingId !== undefined) {
if (updates.levelId === undefined) newSelection.levelId = null
if (updates.zoneId === undefined) newSelection.zoneId = null
if (updates.selectedIds === undefined) newSelection.selectedIds = []
}
if (updates.levelId !== undefined) {
if (updates.zoneId === undefined) newSelection.zoneId = null
if (updates.selectedIds === undefined) newSelection.selectedIds = []
}
if (updates.zoneId !== undefined) {
if (updates.selectedIds === undefined) newSelection.selectedIds = []
}
return { selection: newSelection }
}),
resetSelection: () =>
set({
selection: {
buildingId: null,
levelId: null,
zoneId: null,
selectedIds: [],
},
}),
outliner: { selectedObjects: [], hoveredObjects: [] },
exportScene: null,
setExportScene: (fn) => set({ exportScene: fn }),
debugColors: false,
setDebugColors: (enabled) => set({ debugColors: enabled }),
cameraDragging: false,
setCameraDragging: (dragging) => set({ cameraDragging: dragging }),
}),
{
name: 'viewer-preferences',
partialize: (state) => ({
cameraMode: state.cameraMode,
theme: state.theme,
unit: state.unit,
levelMode: state.levelMode,
wallMode: state.wallMode,
projectPreferences: state.projectPreferences,
}),
},
),
)
export default useViewer