Support template scans and room preset workflows (#390)
* Allow template forks to preserve scans * Add room preset save trigger * Show room preset action for selected zones * Use standard inspector for selected zones * Support room preset editor workflows * Preview room clear-underneath items
This commit is contained in:
@@ -220,6 +220,14 @@ type AIChatEvents = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RoomPresetCreateEvent {
|
||||||
|
zoneId: ZoneNode['id']
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoomPresetEvents = {
|
||||||
|
'room-preset:create': RoomPresetCreateEvent
|
||||||
|
}
|
||||||
|
|
||||||
type EditorEvents = GridEvents &
|
type EditorEvents = GridEvents &
|
||||||
NodeEvents<'wall', WallEvent> &
|
NodeEvents<'wall', WallEvent> &
|
||||||
NodeEvents<'fence', FenceEvent> &
|
NodeEvents<'fence', FenceEvent> &
|
||||||
@@ -260,6 +268,7 @@ type EditorEvents = GridEvents &
|
|||||||
WindowAnimationEvents &
|
WindowAnimationEvents &
|
||||||
ThumbnailEvents &
|
ThumbnailEvents &
|
||||||
SnapshotEvents &
|
SnapshotEvents &
|
||||||
AIChatEvents
|
AIChatEvents &
|
||||||
|
RoomPresetEvents
|
||||||
|
|
||||||
export const emitter = mitt<EditorEvents>()
|
export const emitter = mitt<EditorEvents>()
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type {
|
|||||||
RidgeVentEvent,
|
RidgeVentEvent,
|
||||||
RoofEvent,
|
RoofEvent,
|
||||||
RoofSegmentEvent,
|
RoofSegmentEvent,
|
||||||
|
RoomPresetCreateEvent,
|
||||||
ScanEvent,
|
ScanEvent,
|
||||||
ShelfEvent,
|
ShelfEvent,
|
||||||
SiteEvent,
|
SiteEvent,
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import type { CollectionId } from '../schema/collections'
|
||||||
|
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||||
|
import { forkSceneGraph, type SceneGraph } from './clone-scene-graph'
|
||||||
|
|
||||||
|
function makeNode(id: string, type: string, extra: Record<string, unknown> = {}): AnyNode {
|
||||||
|
return {
|
||||||
|
object: 'node',
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
parentId: null,
|
||||||
|
visible: true,
|
||||||
|
metadata: {},
|
||||||
|
...extra,
|
||||||
|
} as unknown as AnyNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSceneGraph(): SceneGraph {
|
||||||
|
const site = makeNode('site_1', 'site', { children: ['level_1'] })
|
||||||
|
const level = makeNode('level_1', 'level', {
|
||||||
|
parentId: 'site_1',
|
||||||
|
children: ['wall_1', 'scan_1', 'guide_1'],
|
||||||
|
})
|
||||||
|
const wall = makeNode('wall_1', 'wall', { parentId: 'level_1' })
|
||||||
|
const scan = makeNode('scan_1', 'scan', { parentId: 'level_1', url: 'scan.glb' })
|
||||||
|
const guide = makeNode('guide_1', 'guide', { parentId: 'level_1', url: 'guide.png' })
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: {
|
||||||
|
['site_1' as AnyNodeId]: site,
|
||||||
|
['level_1' as AnyNodeId]: level,
|
||||||
|
['wall_1' as AnyNodeId]: wall,
|
||||||
|
['scan_1' as AnyNodeId]: scan,
|
||||||
|
['guide_1' as AnyNodeId]: guide,
|
||||||
|
},
|
||||||
|
rootNodeIds: ['site_1' as AnyNodeId],
|
||||||
|
collections: {
|
||||||
|
['collection_1' as CollectionId]: {
|
||||||
|
id: 'collection_1' as CollectionId,
|
||||||
|
name: 'References',
|
||||||
|
nodeIds: ['scan_1', 'guide_1'] as AnyNodeId[],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('forkSceneGraph', () => {
|
||||||
|
test('strips scan and guide nodes by default', () => {
|
||||||
|
const forked = forkSceneGraph(makeSceneGraph())
|
||||||
|
const nodes = Object.values(forked.nodes)
|
||||||
|
|
||||||
|
expect(nodes.some((node) => node.type === 'scan')).toBe(false)
|
||||||
|
expect(nodes.some((node) => node.type === 'guide')).toBe(false)
|
||||||
|
expect(nodes.some((node) => node.type === 'wall')).toBe(true)
|
||||||
|
expect(forked.collections).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserves scan and guide nodes when requested', () => {
|
||||||
|
const forked = forkSceneGraph(makeSceneGraph(), { preserveScans: true })
|
||||||
|
const nodes = Object.values(forked.nodes)
|
||||||
|
|
||||||
|
expect(nodes.some((node) => node.type === 'scan')).toBe(true)
|
||||||
|
expect(nodes.some((node) => node.type === 'guide')).toBe(true)
|
||||||
|
expect(nodes.map((node) => node.id)).not.toContain('scan_1')
|
||||||
|
expect(nodes.map((node) => node.id)).not.toContain('guide_1')
|
||||||
|
expect(
|
||||||
|
Object.values(forked.collections ?? {}).flatMap((collection) => collection.nodeIds),
|
||||||
|
).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -226,12 +226,22 @@ export function cloneLevelSubtree(
|
|||||||
return { clonedNodes, newLevelId, idMap }
|
return { clonedNodes, newLevelId, idMap }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ForkSceneGraphOptions = {
|
||||||
|
preserveScans?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forks a scene graph for use as a new project: clones with new IDs and strips
|
* Forks a scene graph for use as a new project: clones with new IDs and, by
|
||||||
* scan and guide nodes (and their references) since those contain user-uploaded
|
* default, strips scan and guide nodes since they contain user-uploaded imagery.
|
||||||
* imagery that shouldn't carry over to a forked project.
|
|
||||||
*/
|
*/
|
||||||
export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
export function forkSceneGraph(
|
||||||
|
sceneGraph: SceneGraph,
|
||||||
|
options: ForkSceneGraphOptions = {},
|
||||||
|
): SceneGraph {
|
||||||
|
if (options.preserveScans) {
|
||||||
|
return cloneSceneGraph(sceneGraph)
|
||||||
|
}
|
||||||
|
|
||||||
const { nodes, rootNodeIds, collections } = sceneGraph
|
const { nodes, rootNodeIds, collections } = sceneGraph
|
||||||
|
|
||||||
// First, identify scan and guide node IDs to exclude (user-uploaded imagery)
|
// First, identify scan and guide node IDs to exclude (user-uploaded imagery)
|
||||||
|
|||||||
@@ -948,10 +948,21 @@ export const CustomCameraControls = () => {
|
|||||||
const sub = new Box3().setFromObject(obj)
|
const sub = new Box3().setFromObject(obj)
|
||||||
if (!sub.isEmpty()) tempBox.union(sub)
|
if (!sub.isEmpty()) tempBox.union(sub)
|
||||||
}
|
}
|
||||||
|
if (captureMode.framingBounds) {
|
||||||
|
const { center, max, min, size } = captureMode.framingBounds
|
||||||
|
const fallbackHeight = Math.max(Math.max(size[0], size[1]) * 0.35, 2.5)
|
||||||
|
const minY = tempBox.isEmpty() ? 0 : tempBox.min.y
|
||||||
|
const maxY = tempBox.isEmpty() ? fallbackHeight : tempBox.max.y
|
||||||
|
tempBox.min.set(min[0], minY, min[1])
|
||||||
|
tempBox.max.set(max[0], Math.max(maxY, minY + 0.1), max[1])
|
||||||
|
tempCenter.set(center[0], (tempBox.min.y + tempBox.max.y) / 2, center[1])
|
||||||
|
tempSize.set(size[0], tempBox.max.y - tempBox.min.y, size[1])
|
||||||
|
} else {
|
||||||
if (tempBox.isEmpty()) return
|
if (tempBox.isEmpty()) return
|
||||||
|
|
||||||
tempBox.getCenter(tempCenter)
|
tempBox.getCenter(tempCenter)
|
||||||
tempBox.getSize(tempSize)
|
tempBox.getSize(tempSize)
|
||||||
|
}
|
||||||
|
|
||||||
// Distance heuristic: fit the subject inside the 75%-of-shorter-
|
// Distance heuristic: fit the subject inside the 75%-of-shorter-
|
||||||
// side square crop with comfortable padding. Multiplier 2.4 leaves
|
// side square crop with comfortable padding. Multiplier 2.4 leaves
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ function MobilePanelLayer({
|
|||||||
export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.ReactNode }) {
|
export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.ReactNode }) {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
|
const selectedZoneId = useViewer((s) => s.selection.zoneId)
|
||||||
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
||||||
const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
|
const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
|
||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
@@ -215,5 +217,16 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac
|
|||||||
return <PaintPanel />
|
return <PaintPanel />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedZoneId && selectedIds.length === 0) {
|
||||||
|
return (
|
||||||
|
<ParametricInspector
|
||||||
|
footer={inspectorFooter}
|
||||||
|
key={selectedZoneId}
|
||||||
|
nodeId={selectedZoneId as AnyNodeId}
|
||||||
|
onClose={() => setSelection({ zoneId: null })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return panelForType(selectedNodeType, inspectorFooter)
|
return panelForType(selectedNodeType, inspectorFooter)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import {
|
|||||||
type ParamAction,
|
type ParamAction,
|
||||||
type ParamField,
|
type ParamField,
|
||||||
useScene,
|
useScene,
|
||||||
|
type ZoneNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Icon } from '@iconify/react'
|
import { Icon } from '@iconify/react'
|
||||||
import { Move, Trash2 } from 'lucide-react'
|
import { Move, Trash2 } from 'lucide-react'
|
||||||
import { type ComponentType, lazy, Suspense, useCallback } from 'react'
|
import { type ComponentType, lazy, Suspense, useCallback } from 'react'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
|
import { collectZoneContentIds } from '../../../lib/zone-content'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
@@ -38,8 +40,15 @@ import { InspectorFooterContext, PanelWrapper } from './panel-wrapper'
|
|||||||
* `parametrics.customPanel?` escape hatch for kinds whose parametric editor
|
* `parametrics.customPanel?` escape hatch for kinds whose parametric editor
|
||||||
* can't be auto-generated (topology editors etc.).
|
* can't be auto-generated (topology editors etc.).
|
||||||
*/
|
*/
|
||||||
export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {}) {
|
export function ParametricInspector({
|
||||||
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
|
footer,
|
||||||
|
nodeId,
|
||||||
|
onClose,
|
||||||
|
}: { footer?: React.ReactNode; nodeId?: AnyNodeId; onClose?: () => void } = {}) {
|
||||||
|
const selectedIdFromSelection = useViewer((s) => s.selection.selectedIds[0]) as
|
||||||
|
| AnyNodeId
|
||||||
|
| undefined
|
||||||
|
const selectedId = nodeId ?? selectedIdFromSelection
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
// Subscribe only to the *type* — a string primitive that doesn't change
|
// Subscribe only to the *type* — a string primitive that doesn't change
|
||||||
// when slider values change. Without this, every updateNode tick during
|
// when slider values change. Without this, every updateNode tick during
|
||||||
@@ -58,9 +67,13 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {
|
|||||||
[selectedId],
|
[selectedId],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const clearSelection = useCallback(() => {
|
||||||
|
if (onClose) {
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [onClose, setSelection])
|
||||||
|
|
||||||
const handleMove = useCallback(() => {
|
const handleMove = useCallback(() => {
|
||||||
if (!selectedId) return
|
if (!selectedId) return
|
||||||
@@ -68,15 +81,27 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {
|
|||||||
if (!node) return
|
if (!node) return
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
useEditor.getState().setMovingNode(node as any)
|
useEditor.getState().setMovingNode(node as any)
|
||||||
setSelection({ selectedIds: [] })
|
clearSelection()
|
||||||
}, [selectedId, setSelection])
|
}, [selectedId, clearSelection])
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
const handleDelete = useCallback(
|
||||||
|
(withZoneContent = false) => {
|
||||||
if (!selectedId) return
|
if (!selectedId) return
|
||||||
|
const scene = useScene.getState()
|
||||||
|
const node = scene.nodes[selectedId]
|
||||||
|
if (!node) return
|
||||||
|
|
||||||
|
const ids =
|
||||||
|
withZoneContent && node.type === 'zone'
|
||||||
|
? [selectedId, ...collectZoneContentIds(scene.nodes, node as ZoneNode)]
|
||||||
|
: [selectedId]
|
||||||
|
|
||||||
sfxEmitter.emit('sfx:structure-delete')
|
sfxEmitter.emit('sfx:structure-delete')
|
||||||
useScene.getState().deleteNode(selectedId)
|
scene.deleteNodes(Array.from(new Set(ids)))
|
||||||
setSelection({ selectedIds: [] })
|
clearSelection()
|
||||||
}, [selectedId, setSelection])
|
},
|
||||||
|
[selectedId, clearSelection],
|
||||||
|
)
|
||||||
|
|
||||||
if (!selectedId || !def || !parametrics) return null
|
if (!selectedId || !def || !parametrics) return null
|
||||||
|
|
||||||
@@ -104,13 +129,20 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {
|
|||||||
const iconNode = renderIcon(presentation?.icon)
|
const iconNode = renderIcon(presentation?.icon)
|
||||||
const canMove = !!def.capabilities.movable
|
const canMove = !!def.capabilities.movable
|
||||||
const canDelete = def.capabilities.deletable !== false
|
const canDelete = def.capabilities.deletable !== false
|
||||||
|
const isZone = nodeType === 'zone'
|
||||||
|
|
||||||
const TrailingSection = parametrics.trailingSection
|
const TrailingSection = parametrics.trailingSection
|
||||||
? resolveCustomPanel(parametrics.trailingSection)
|
? resolveCustomPanel(parametrics.trailingSection)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelWrapper footer={footer} icon={iconNode} onClose={handleClose} title={title} width={320}>
|
<PanelWrapper
|
||||||
|
footer={footer}
|
||||||
|
icon={iconNode}
|
||||||
|
onClose={clearSelection}
|
||||||
|
title={title}
|
||||||
|
width={320}
|
||||||
|
>
|
||||||
{parametrics.groups.map((group, gi) => (
|
{parametrics.groups.map((group, gi) => (
|
||||||
<PanelSection key={`group-${gi}`} title={group.label}>
|
<PanelSection key={`group-${gi}`} title={group.label}>
|
||||||
{group.fields.map((field, fi) => (
|
{group.fields.map((field, fi) => (
|
||||||
@@ -130,21 +162,37 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {
|
|||||||
)}
|
)}
|
||||||
{(canMove || canDelete || (parametrics.actions && parametrics.actions.length > 0)) && (
|
{(canMove || canDelete || (parametrics.actions && parametrics.actions.length > 0)) && (
|
||||||
<PanelSection title="Actions">
|
<PanelSection title="Actions">
|
||||||
<ActionGroup>
|
<ActionGroup className={isZone ? 'flex-col' : undefined}>
|
||||||
{canMove && (
|
{canMove && (
|
||||||
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
|
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
|
||||||
)}
|
)}
|
||||||
{parametrics.actions?.map((action, i) => (
|
{parametrics.actions?.map((action, i) => (
|
||||||
<ParamActionButton action={action} key={`paramaction-${i}`} nodeId={selectedId} />
|
<ParamActionButton action={action} key={`paramaction-${i}`} nodeId={selectedId} />
|
||||||
))}
|
))}
|
||||||
{canDelete && (
|
{canDelete &&
|
||||||
|
(isZone ? (
|
||||||
|
<>
|
||||||
|
<ActionButton
|
||||||
|
className="w-full flex-none"
|
||||||
|
icon={<Trash2 className="h-4 w-4 text-red-400" />}
|
||||||
|
label="Delete"
|
||||||
|
onClick={() => handleDelete(false)}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
className="w-full flex-none"
|
||||||
|
icon={<Trash2 className="h-4 w-4 text-red-400" />}
|
||||||
|
label="Delete with contents"
|
||||||
|
onClick={() => handleDelete(true)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
|
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
|
||||||
icon={<Trash2 className="h-4 w-4" />}
|
icon={<Trash2 className="h-4 w-4" />}
|
||||||
label="Delete"
|
label="Delete"
|
||||||
onClick={handleDelete}
|
onClick={() => handleDelete()}
|
||||||
/>
|
/>
|
||||||
)}
|
))}
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { emitter, useScene, type ZoneNode } from '@pascal-app/core'
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
emitter,
|
||||||
|
useScene,
|
||||||
|
type ZoneNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Camera, Hexagon, Trash2 } from 'lucide-react'
|
import { Camera, Hexagon, Save, Trash2 } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { sfxEmitter } from './../../../../../lib/sfx-bus'
|
||||||
|
import { collectZoneContentIds } from './../../../../../lib/zone-content'
|
||||||
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
|
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
@@ -10,6 +17,8 @@ import {
|
|||||||
} from './../../../../../components/ui/primitives/popover'
|
} from './../../../../../components/ui/primitives/popover'
|
||||||
import { cn } from './../../../../../lib/utils'
|
import { cn } from './../../../../../lib/utils'
|
||||||
import useEditor from './../../../../../store/use-editor'
|
import useEditor from './../../../../../store/use-editor'
|
||||||
|
import { ActionButton } from '../../../controls/action-button'
|
||||||
|
import { PanelSection } from '../../../controls/panel-section'
|
||||||
|
|
||||||
function ZoneItem({ zone }: { zone: ZoneNode }) {
|
function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||||
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
|
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
|
||||||
@@ -26,6 +35,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
|||||||
|
|
||||||
const handleDelete = (e: React.MouseEvent) => {
|
const handleDelete = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
sfxEmitter.emit('sfx:structure-delete')
|
||||||
deleteNode(zone.id)
|
deleteNode(zone.id)
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
setSelection({ zoneId: null })
|
setSelection({ zoneId: null })
|
||||||
@@ -125,6 +135,8 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
|||||||
export function ZonePanel() {
|
export function ZonePanel() {
|
||||||
const nodes = useScene((state) => state.nodes)
|
const nodes = useScene((state) => state.nodes)
|
||||||
const currentLevelId = useViewer((state) => state.selection.levelId)
|
const currentLevelId = useViewer((state) => state.selection.levelId)
|
||||||
|
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
||||||
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
const setPhase = useEditor((state) => state.setPhase)
|
const setPhase = useEditor((state) => state.setPhase)
|
||||||
const setMode = useEditor((state) => state.setMode)
|
const setMode = useEditor((state) => state.setMode)
|
||||||
const setTool = useEditor((state) => state.setTool)
|
const setTool = useEditor((state) => state.setTool)
|
||||||
@@ -133,6 +145,7 @@ export function ZonePanel() {
|
|||||||
const levelZones = Object.values(nodes).filter(
|
const levelZones = Object.values(nodes).filter(
|
||||||
(node): node is ZoneNode => node.type === 'zone' && node.parentId === currentLevelId,
|
(node): node is ZoneNode => node.type === 'zone' && node.parentId === currentLevelId,
|
||||||
)
|
)
|
||||||
|
const selectedZone = levelZones.find((zone) => zone.id === selectedZoneId)
|
||||||
|
|
||||||
const handleAddZone = () => {
|
const handleAddZone = () => {
|
||||||
if (currentLevelId) {
|
if (currentLevelId) {
|
||||||
@@ -142,6 +155,17 @@ export function ZonePanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteSelectedZone = (withContent: boolean) => {
|
||||||
|
if (!selectedZone) return
|
||||||
|
const scene = useScene.getState()
|
||||||
|
const ids = withContent
|
||||||
|
? [selectedZone.id as AnyNodeId, ...collectZoneContentIds(scene.nodes, selectedZone)]
|
||||||
|
: [selectedZone.id as AnyNodeId]
|
||||||
|
sfxEmitter.emit('sfx:structure-delete')
|
||||||
|
scene.deleteNodes(Array.from(new Set(ids)))
|
||||||
|
setSelection({ selectedIds: [], zoneId: null })
|
||||||
|
}
|
||||||
|
|
||||||
if (!currentLevelId) {
|
if (!currentLevelId) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-4 text-muted-foreground text-sm">
|
<div className="px-3 py-4 text-muted-foreground text-sm">
|
||||||
@@ -162,6 +186,31 @@ export function ZonePanel() {
|
|||||||
) : (
|
) : (
|
||||||
levelZones.map((zone) => <ZoneItem key={zone.id} zone={zone} />)
|
levelZones.map((zone) => <ZoneItem key={zone.id} zone={zone} />)
|
||||||
)}
|
)}
|
||||||
|
{selectedZone ? (
|
||||||
|
<PanelSection className="mt-2 border-t" title="Actions">
|
||||||
|
<ActionButton
|
||||||
|
className="w-full flex-none"
|
||||||
|
icon={<Save className="h-4 w-4" />}
|
||||||
|
label="Save to catalog"
|
||||||
|
onClick={() => emitter.emit('room-preset:create', { zoneId: selectedZone.id })}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
className="w-full flex-none"
|
||||||
|
icon={<Trash2 className="h-4 w-4 text-red-400" />}
|
||||||
|
label="Delete"
|
||||||
|
onClick={() => deleteSelectedZone(false)}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
className="w-full flex-none"
|
||||||
|
icon={<Trash2 className="h-4 w-4 text-red-400" />}
|
||||||
|
label="Delete with contents"
|
||||||
|
onClick={() => deleteSelectedZone(true)}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,15 +298,6 @@ export const useKeyboard = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete selected zone
|
|
||||||
const selectedZoneId = useViewer.getState().selection.zoneId
|
|
||||||
if (selectedZoneId) {
|
|
||||||
sfxEmitter.emit('sfx:structure-delete')
|
|
||||||
useScene.getState().deleteNode(selectedZoneId as AnyNodeId)
|
|
||||||
useViewer.getState().setSelection({ zoneId: null })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||||
|
|
||||||
if (selectedNodeIds.length > 0) {
|
if (selectedNodeIds.length > 0) {
|
||||||
@@ -328,6 +319,15 @@ export const useKeyboard = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useScene.getState().deleteNodes(selectedNodeIds)
|
useScene.getState().deleteNodes(selectedNodeIds)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete selected zone when no explicit element selection is active.
|
||||||
|
const selectedZoneId = useViewer.getState().selection.zoneId
|
||||||
|
if (selectedZoneId) {
|
||||||
|
sfxEmitter.emit('sfx:structure-delete')
|
||||||
|
useScene.getState().deleteNode(selectedZoneId as AnyNodeId)
|
||||||
|
useViewer.getState().setSelection({ zoneId: null })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
type CeilingNode,
|
||||||
|
type ItemNode,
|
||||||
|
pointInPolygon2D,
|
||||||
|
pointOnSegment,
|
||||||
|
type SlabNode,
|
||||||
|
type WallNode,
|
||||||
|
type ZoneNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
|
||||||
|
type Point2D = [number, number]
|
||||||
|
|
||||||
|
const POINT_TOLERANCE = 0.5
|
||||||
|
const COLLINEAR_TOLERANCE = 1e-6
|
||||||
|
const SURFACE_POLYGON_TOLERANCE = 0.15
|
||||||
|
|
||||||
|
function getPointToSegmentDistance(point: Point2D, start: Point2D, end: Point2D): number {
|
||||||
|
const dx = end[0] - start[0]
|
||||||
|
const dz = end[1] - start[1]
|
||||||
|
const lengthSq = dx * dx + dz * dz
|
||||||
|
if (lengthSq === 0) return Math.hypot(point[0] - start[0], point[1] - start[1])
|
||||||
|
|
||||||
|
const rawT = ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSq
|
||||||
|
const t = Math.max(0, Math.min(1, rawT))
|
||||||
|
const projected: Point2D = [start[0] + t * dx, start[1] + t * dz]
|
||||||
|
return Math.hypot(point[0] - projected[0], point[1] - projected[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointInPolygonWithTolerance(point: Point2D, polygon: Point2D[]): boolean {
|
||||||
|
if (pointInPolygon2D(point, polygon, { includeBoundary: true })) return true
|
||||||
|
return polygon.some((start, index) => {
|
||||||
|
const end = polygon[(index + 1) % polygon.length]
|
||||||
|
return end ? getPointToSegmentDistance(point, start, end) <= POINT_TOLERANCE : false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function polygonContainsWithTolerance(
|
||||||
|
outer: Point2D[],
|
||||||
|
inner: Point2D[],
|
||||||
|
tolerance: number,
|
||||||
|
): boolean {
|
||||||
|
return inner.every((point) => {
|
||||||
|
if (pointInPolygon2D(point, outer, { includeBoundary: true })) return true
|
||||||
|
return outer.some((start, index) => {
|
||||||
|
const end = outer[(index + 1) % outer.length]
|
||||||
|
return end ? getPointToSegmentDistance(point, start, end) <= tolerance : false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function polygonMatchesZoneFootprint(surfacePolygon: Point2D[], footprint: Point2D[]): boolean {
|
||||||
|
if (surfacePolygon.length < 3) return false
|
||||||
|
return (
|
||||||
|
polygonContainsWithTolerance(footprint, surfacePolygon, SURFACE_POLYGON_TOLERANCE) &&
|
||||||
|
polygonContainsWithTolerance(surfacePolygon, footprint, SURFACE_POLYGON_TOLERANCE)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function areSegmentsCollinear(a: Point2D, b: Point2D, c: Point2D, d: Point2D): boolean {
|
||||||
|
const abx = b[0] - a[0]
|
||||||
|
const abz = b[1] - a[1]
|
||||||
|
const acx = c[0] - a[0]
|
||||||
|
const acz = c[1] - a[1]
|
||||||
|
const adx = d[0] - a[0]
|
||||||
|
const adz = d[1] - a[1]
|
||||||
|
const crossC = abx * acz - abz * acx
|
||||||
|
const crossD = abx * adz - abz * adx
|
||||||
|
return Math.abs(crossC) <= COLLINEAR_TOLERANCE && Math.abs(crossD) <= COLLINEAR_TOLERANCE
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentsOverlap(a: Point2D, b: Point2D, c: Point2D, d: Point2D): boolean {
|
||||||
|
const useX = Math.abs(b[0] - a[0]) >= Math.abs(b[1] - a[1])
|
||||||
|
const a0 = useX ? a[0] : a[1]
|
||||||
|
const a1 = useX ? b[0] : b[1]
|
||||||
|
const c0 = useX ? c[0] : c[1]
|
||||||
|
const c1 = useX ? d[0] : d[1]
|
||||||
|
const minA = Math.min(a0, a1)
|
||||||
|
const maxA = Math.max(a0, a1)
|
||||||
|
const minC = Math.min(c0, c1)
|
||||||
|
const maxC = Math.max(c0, c1)
|
||||||
|
return Math.max(minA, minC) <= Math.min(maxA, maxC) + COLLINEAR_TOLERANCE
|
||||||
|
}
|
||||||
|
|
||||||
|
function wallLiesOnZoneBoundary(wall: WallNode, polygon: Point2D[]): boolean {
|
||||||
|
return polygon.some((start, index) => {
|
||||||
|
const end = polygon[(index + 1) % polygon.length]
|
||||||
|
if (!end) return false
|
||||||
|
return (
|
||||||
|
areSegmentsCollinear(wall.start, wall.end, start, end) &&
|
||||||
|
segmentsOverlap(wall.start, wall.end, start, end) &&
|
||||||
|
pointOnSegment(wall.start, start, end, POINT_TOLERANCE) &&
|
||||||
|
pointOnSegment(wall.end, start, end, POINT_TOLERANCE)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectZoneContentIds(
|
||||||
|
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
|
||||||
|
zone: ZoneNode,
|
||||||
|
): AnyNodeId[] {
|
||||||
|
const levelId = zone.parentId
|
||||||
|
if (!levelId) return []
|
||||||
|
|
||||||
|
const footprint = zone.polygon.map((point) => [point[0], point[1]] as Point2D)
|
||||||
|
const boundaryWalls = Object.values(nodes)
|
||||||
|
.filter((node): node is WallNode => node.type === 'wall' && node.parentId === levelId)
|
||||||
|
.filter((wall) => wallLiesOnZoneBoundary(wall, footprint))
|
||||||
|
const surfaces = Object.values(nodes)
|
||||||
|
.filter(
|
||||||
|
(node): node is SlabNode | CeilingNode =>
|
||||||
|
(node.type === 'slab' || node.type === 'ceiling') && node.parentId === levelId,
|
||||||
|
)
|
||||||
|
.filter((surface) => {
|
||||||
|
const polygon = surface.polygon.map((point) => [point[0], point[1]] as Point2D)
|
||||||
|
return polygonMatchesZoneFootprint(polygon, footprint)
|
||||||
|
})
|
||||||
|
const floorItems = Object.values(nodes)
|
||||||
|
.filter((node): node is ItemNode => node.type === 'item' && node.parentId === levelId)
|
||||||
|
.filter((item) => pointInPolygonWithTolerance([item.position[0], item.position[2]], footprint))
|
||||||
|
|
||||||
|
return Array.from(
|
||||||
|
new Set<AnyNodeId>([
|
||||||
|
...boundaryWalls.map((wall) => wall.id as AnyNodeId),
|
||||||
|
...surfaces.map((surface) => surface.id as AnyNodeId),
|
||||||
|
...floorItems.map((item) => item.id as AnyNodeId),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -62,7 +62,16 @@ export type WorkspaceMode = 'edit' | 'studio'
|
|||||||
export type CaptureMode =
|
export type CaptureMode =
|
||||||
| { mode: 'idle' }
|
| { mode: 'idle' }
|
||||||
| { mode: 'standard' }
|
| { mode: 'standard' }
|
||||||
| { mode: 'preset'; isolated: AnyNodeId[] }
|
| {
|
||||||
|
mode: 'preset'
|
||||||
|
isolated: AnyNodeId[]
|
||||||
|
framingBounds?: {
|
||||||
|
min: [number, number]
|
||||||
|
max: [number, number]
|
||||||
|
center: [number, number]
|
||||||
|
size: [number, number]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type Phase = 'site' | 'structure' | 'furnish'
|
export type Phase = 'site' | 'structure' | 'furnish'
|
||||||
|
|
||||||
|
|||||||
@@ -126,9 +126,8 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
|||||||
deltaRef.current = [deltaX, deltaZ]
|
deltaRef.current = [deltaX, deltaZ]
|
||||||
setMeshOffset(ceilingId as AnyNodeId, deltaX, deltaZ, height)
|
setMeshOffset(ceilingId as AnyNodeId, deltaX, deltaZ, height)
|
||||||
// Aligned with slab/fence: the delta matches the direct mesh
|
// Aligned with slab/fence: the delta matches the direct mesh
|
||||||
// mutation. CeilingRenderer doesn't bind position via React, so
|
// mutation. CeilingRenderer also consumes this store so external
|
||||||
// this entry isn't consumed for rendering, but kept consistent
|
// movers can preview ceilings without rebuilding the polygon.
|
||||||
// in case other systems read it.
|
|
||||||
useLiveTransforms.getState().set(ceilingId, {
|
useLiveTransforms.getState().set(ceilingId, {
|
||||||
position: [deltaX, 0, deltaZ],
|
position: [deltaX, 0, deltaZ],
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
getMaterialPresetByRef,
|
getMaterialPresetByRef,
|
||||||
resolveMaterial,
|
resolveMaterial,
|
||||||
|
useLiveTransforms,
|
||||||
useRegistry,
|
useRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
@@ -79,6 +80,13 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
|||||||
const textures = useViewer((s) => s.textures)
|
const textures = useViewer((s) => s.textures)
|
||||||
const colorPreset = useViewer((s) => s.colorPreset)
|
const colorPreset = useViewer((s) => s.colorPreset)
|
||||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
const sceneTheme = useViewer((s) => s.sceneTheme)
|
||||||
|
const liveTransform = useLiveTransforms((s) => s.get(node.id))
|
||||||
|
const ceilingY = (node.height ?? 2.5) - 0.01 + (liveTransform?.position[1] ?? 0)
|
||||||
|
const position: [number, number, number] = [
|
||||||
|
liveTransform?.position[0] ?? 0,
|
||||||
|
ceilingY,
|
||||||
|
liveTransform?.position[2] ?? 0,
|
||||||
|
]
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
@@ -124,7 +132,12 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh geometry={placeholderGeometry} material={materials.bottomMaterial} ref={ref}>
|
<mesh
|
||||||
|
geometry={placeholderGeometry}
|
||||||
|
material={materials.bottomMaterial}
|
||||||
|
position={position}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
<mesh
|
<mesh
|
||||||
geometry={gridPlaceholderGeometry}
|
geometry={gridPlaceholderGeometry}
|
||||||
material={materials.topMaterial}
|
material={materials.topMaterial}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
type AnimationEffect,
|
type AnimationEffect,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
getScaledDimensions,
|
||||||
type Interactive,
|
type Interactive,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
type LightEffect,
|
type LightEffect,
|
||||||
@@ -91,9 +92,15 @@ export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => {
|
|||||||
() => (liveOverrides ? ({ ...storeNode, ...liveOverrides } as ItemNode) : storeNode),
|
() => (liveOverrides ? ({ ...storeNode, ...liveOverrides } as ItemNode) : storeNode),
|
||||||
[storeNode, liveOverrides],
|
[storeNode, liveOverrides],
|
||||||
)
|
)
|
||||||
|
const roomClearPreview =
|
||||||
|
(node as ItemNode & { roomClearPreview?: unknown }).roomClearPreview === true
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={node.position} ref={ref} rotation={node.rotation} visible={node.visible}>
|
<group position={node.position} ref={ref} rotation={node.rotation} visible={node.visible}>
|
||||||
|
{roomClearPreview ? (
|
||||||
|
<ClearPreviewModel node={node} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<ErrorBoundary fallback={<BrokenItemFallback node={node} />}>
|
<ErrorBoundary fallback={<BrokenItemFallback node={node} />}>
|
||||||
<Suspense fallback={<PreviewModel node={node} />}>
|
<Suspense fallback={<PreviewModel node={node} />}>
|
||||||
<ModelRenderer node={node} />
|
<ModelRenderer node={node} />
|
||||||
@@ -102,6 +109,8 @@ export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => {
|
|||||||
{node.children?.map((childId) => (
|
{node.children?.map((childId) => (
|
||||||
<NodeRenderer key={childId} nodeId={childId} />
|
<NodeRenderer key={childId} nodeId={childId} />
|
||||||
))}
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -133,6 +142,26 @@ const PreviewModel = ({ node }: { node: ItemNode }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ClearPreviewModel = ({ node }: { node: ItemNode }) => {
|
||||||
|
const shading = useViewer((s) => s.shading)
|
||||||
|
const [w, h, d] = getScaledDimensions(node)
|
||||||
|
const material = useMemo(() => {
|
||||||
|
const next = createDefaultMaterial('#ef4444', 1, shading) as MutableMaterial
|
||||||
|
next.depthTest = false
|
||||||
|
next.opacity = 0.35
|
||||||
|
next.transparent = true
|
||||||
|
next.wireframe = true
|
||||||
|
next.needsUpdate = true
|
||||||
|
return next
|
||||||
|
}, [shading])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh material={material} position-y={h / 2}>
|
||||||
|
<boxGeometry args={[w, h, d]} />
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const multiplyScales = (
|
const multiplyScales = (
|
||||||
a: [number, number, number],
|
a: [number, number, number],
|
||||||
b: [number, number, number],
|
b: [number, number, number],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
getEffectiveNode,
|
getEffectiveNode,
|
||||||
nodeRegistry,
|
nodeRegistry,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
@@ -104,9 +105,10 @@ function updateCeilingGeometry(
|
|||||||
// canonical position after the rebuild. Matches the pattern used by
|
// canonical position after the rebuild. Matches the pattern used by
|
||||||
// FenceSystem.updateFenceGeometry / GeometrySystem (both fully reset
|
// FenceSystem.updateFenceGeometry / GeometrySystem (both fully reset
|
||||||
// position+rotation after rebuild).
|
// position+rotation after rebuild).
|
||||||
mesh.position.x = 0
|
const liveTransform = useLiveTransforms.getState().get(node.id)
|
||||||
mesh.position.z = 0
|
mesh.position.x = liveTransform?.position[0] ?? 0
|
||||||
mesh.position.y = (node.height ?? 2.5) - 0.01 // Slight offset to avoid z-fighting with upper-level slabs
|
mesh.position.z = liveTransform?.position[2] ?? 0
|
||||||
|
mesh.position.y = (node.height ?? 2.5) - 0.01 + (liveTransform?.position[1] ?? 0) // Slight offset to avoid z-fighting with upper-level slabs
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user