From 50304d7d1077ae91caf3d67a8b6b09ac6dc8ca14 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 9 Jun 2026 16:36:31 -0400 Subject: [PATCH] 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 --- packages/core/src/events/bus.ts | 11 +- packages/core/src/index.ts | 1 + .../core/src/utils/clone-scene-graph.test.ts | 70 ++++++++++ packages/core/src/utils/clone-scene-graph.ts | 18 ++- .../editor/custom-camera-controls.tsx | 17 ++- .../components/ui/panels/panel-manager.tsx | 13 ++ .../ui/panels/parametric-inspector.tsx | 92 ++++++++++--- .../ui/sidebar/panels/zone-panel/index.tsx | 53 ++++++- packages/editor/src/hooks/use-keyboard.ts | 18 +-- packages/editor/src/lib/zone-content.ts | 130 ++++++++++++++++++ packages/editor/src/store/use-editor.tsx | 11 +- packages/nodes/src/ceiling/move-tool.tsx | 5 +- packages/nodes/src/ceiling/renderer.tsx | 15 +- packages/nodes/src/item/renderer.tsx | 45 ++++-- .../src/systems/ceiling/ceiling-system.tsx | 8 +- 15 files changed, 450 insertions(+), 57 deletions(-) create mode 100644 packages/core/src/utils/clone-scene-graph.test.ts create mode 100644 packages/editor/src/lib/zone-content.ts diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 9a563cec..99bc7e83 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -220,6 +220,14 @@ type AIChatEvents = { } } +export interface RoomPresetCreateEvent { + zoneId: ZoneNode['id'] +} + +type RoomPresetEvents = { + 'room-preset:create': RoomPresetCreateEvent +} + type EditorEvents = GridEvents & NodeEvents<'wall', WallEvent> & NodeEvents<'fence', FenceEvent> & @@ -260,6 +268,7 @@ type EditorEvents = GridEvents & WindowAnimationEvents & ThumbnailEvents & SnapshotEvents & - AIChatEvents + AIChatEvents & + RoomPresetEvents export const emitter = mitt() diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 02192d0c..f3acc318 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,6 +20,7 @@ export type { RidgeVentEvent, RoofEvent, RoofSegmentEvent, + RoomPresetCreateEvent, ScanEvent, ShelfEvent, SiteEvent, diff --git a/packages/core/src/utils/clone-scene-graph.test.ts b/packages/core/src/utils/clone-scene-graph.test.ts new file mode 100644 index 00000000..99456783 --- /dev/null +++ b/packages/core/src/utils/clone-scene-graph.test.ts @@ -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 = {}): 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) + }) +}) diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index 3776e1d4..2e2894b3 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -226,12 +226,22 @@ export function cloneLevelSubtree( 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 - * scan and guide nodes (and their references) since those contain user-uploaded - * imagery that shouldn't carry over to a forked project. + * Forks a scene graph for use as a new project: clones with new IDs and, by + * default, strips scan and guide nodes since they contain user-uploaded imagery. */ -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 // First, identify scan and guide node IDs to exclude (user-uploaded imagery) diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 7dd50917..176c504e 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -948,10 +948,21 @@ export const CustomCameraControls = () => { const sub = new Box3().setFromObject(obj) if (!sub.isEmpty()) tempBox.union(sub) } - if (tempBox.isEmpty()) return + 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 - tempBox.getCenter(tempCenter) - tempBox.getSize(tempSize) + tempBox.getCenter(tempCenter) + tempBox.getSize(tempSize) + } // Distance heuristic: fit the subject inside the 75%-of-shorter- // side square crop with comfortable padding. Multiplier 2.4 leaves diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index 1d58f585..7ff45474 100644 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -171,6 +171,8 @@ function MobilePanelLayer({ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.ReactNode }) { const isMobile = useIsMobile() 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 isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen) const mode = useEditor((s) => s.mode) @@ -215,5 +217,16 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac return } + if (selectedZoneId && selectedIds.length === 0) { + return ( + setSelection({ zoneId: null })} + /> + ) + } + return panelForType(selectedNodeType, inspectorFooter) } diff --git a/packages/editor/src/components/ui/panels/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx index 78227c34..e046fbf2 100644 --- a/packages/editor/src/components/ui/panels/parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/parametric-inspector.tsx @@ -8,12 +8,14 @@ import { type ParamAction, type ParamField, useScene, + type ZoneNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Icon } from '@iconify/react' import { Move, Trash2 } from 'lucide-react' import { type ComponentType, lazy, Suspense, useCallback } from 'react' import { sfxEmitter } from '../../../lib/sfx-bus' +import { collectZoneContentIds } from '../../../lib/zone-content' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' 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 * can't be auto-generated (topology editors etc.). */ -export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {}) { - const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined +export function ParametricInspector({ + 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) // Subscribe only to the *type* — a string primitive that doesn't change // when slider values change. Without this, every updateNode tick during @@ -58,9 +67,13 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = { [selectedId], ) - const handleClose = useCallback(() => { + const clearSelection = useCallback(() => { + if (onClose) { + onClose() + return + } setSelection({ selectedIds: [] }) - }, [setSelection]) + }, [onClose, setSelection]) const handleMove = useCallback(() => { if (!selectedId) return @@ -68,15 +81,27 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = { if (!node) return sfxEmitter.emit('sfx:item-pick') useEditor.getState().setMovingNode(node as any) - setSelection({ selectedIds: [] }) - }, [selectedId, setSelection]) + clearSelection() + }, [selectedId, clearSelection]) - const handleDelete = useCallback(() => { - if (!selectedId) return - sfxEmitter.emit('sfx:structure-delete') - useScene.getState().deleteNode(selectedId) - setSelection({ selectedIds: [] }) - }, [selectedId, setSelection]) + const handleDelete = useCallback( + (withZoneContent = false) => { + 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') + scene.deleteNodes(Array.from(new Set(ids))) + clearSelection() + }, + [selectedId, clearSelection], + ) if (!selectedId || !def || !parametrics) return null @@ -104,13 +129,20 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = { const iconNode = renderIcon(presentation?.icon) const canMove = !!def.capabilities.movable const canDelete = def.capabilities.deletable !== false + const isZone = nodeType === 'zone' const TrailingSection = parametrics.trailingSection ? resolveCustomPanel(parametrics.trailingSection) : null return ( - + {parametrics.groups.map((group, gi) => ( {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 && ( } label="Move" onClick={handleMove} /> )} {parametrics.actions?.map((action, i) => ( ))} - {canDelete && ( - } - label="Delete" - onClick={handleDelete} - /> - )} + {canDelete && + (isZone ? ( + <> + } + label="Delete" + onClick={() => handleDelete(false)} + /> + } + label="Delete with contents" + onClick={() => handleDelete(true)} + /> + + ) : ( + } + label="Delete" + onClick={() => handleDelete()} + /> + ))} )} diff --git a/packages/editor/src/components/ui/sidebar/panels/zone-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/zone-panel/index.tsx index aeacd05f..a61bf142 100644 --- a/packages/editor/src/components/ui/sidebar/panels/zone-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/zone-panel/index.tsx @@ -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 { Camera, Hexagon, Trash2 } from 'lucide-react' +import { Camera, Hexagon, Save, Trash2 } from 'lucide-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 { Popover, @@ -10,6 +17,8 @@ import { } from './../../../../../components/ui/primitives/popover' import { cn } from './../../../../../lib/utils' import useEditor from './../../../../../store/use-editor' +import { ActionButton } from '../../../controls/action-button' +import { PanelSection } from '../../../controls/panel-section' function ZoneItem({ zone }: { zone: ZoneNode }) { const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false) @@ -26,6 +35,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) { const handleDelete = (e: React.MouseEvent) => { e.stopPropagation() + sfxEmitter.emit('sfx:structure-delete') deleteNode(zone.id) if (isSelected) { setSelection({ zoneId: null }) @@ -125,6 +135,8 @@ function ZoneItem({ zone }: { zone: ZoneNode }) { export function ZonePanel() { const nodes = useScene((state) => state.nodes) 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 setMode = useEditor((state) => state.setMode) const setTool = useEditor((state) => state.setTool) @@ -133,6 +145,7 @@ export function ZonePanel() { const levelZones = Object.values(nodes).filter( (node): node is ZoneNode => node.type === 'zone' && node.parentId === currentLevelId, ) + const selectedZone = levelZones.find((zone) => zone.id === selectedZoneId) const handleAddZone = () => { 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) { return (
@@ -162,6 +186,31 @@ export function ZonePanel() { ) : ( levelZones.map((zone) => ) )} + {selectedZone ? ( + + } + label="Save to catalog" + onClick={() => emitter.emit('room-preset:create', { zoneId: selectedZone.id })} + type="button" + /> + } + label="Delete" + onClick={() => deleteSelectedZone(false)} + type="button" + /> + } + label="Delete with contents" + onClick={() => deleteSelectedZone(true)} + type="button" + /> + + ) : null}
) } diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index acf391e2..6e4d4695 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -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[] if (selectedNodeIds.length > 0) { @@ -328,6 +319,15 @@ export const useKeyboard = ({ } 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 }) } } } diff --git a/packages/editor/src/lib/zone-content.ts b/packages/editor/src/lib/zone-content.ts new file mode 100644 index 00000000..738c4ea8 --- /dev/null +++ b/packages/editor/src/lib/zone-content.ts @@ -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>, + 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([ + ...boundaryWalls.map((wall) => wall.id as AnyNodeId), + ...surfaces.map((surface) => surface.id as AnyNodeId), + ...floorItems.map((item) => item.id as AnyNodeId), + ]), + ) +} diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index d67a9b5b..d4097598 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -62,7 +62,16 @@ export type WorkspaceMode = 'edit' | 'studio' export type CaptureMode = | { mode: 'idle' } | { 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' diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx index cdd25261..3e255313 100644 --- a/packages/nodes/src/ceiling/move-tool.tsx +++ b/packages/nodes/src/ceiling/move-tool.tsx @@ -126,9 +126,8 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { deltaRef.current = [deltaX, deltaZ] setMeshOffset(ceilingId as AnyNodeId, deltaX, deltaZ, height) // Aligned with slab/fence: the delta matches the direct mesh - // mutation. CeilingRenderer doesn't bind position via React, so - // this entry isn't consumed for rendering, but kept consistent - // in case other systems read it. + // mutation. CeilingRenderer also consumes this store so external + // movers can preview ceilings without rebuilding the polygon. useLiveTransforms.getState().set(ceilingId, { position: [deltaX, 0, deltaZ], rotation: 0, diff --git a/packages/nodes/src/ceiling/renderer.tsx b/packages/nodes/src/ceiling/renderer.tsx index 4e0e9fe7..263c4fd8 100644 --- a/packages/nodes/src/ceiling/renderer.tsx +++ b/packages/nodes/src/ceiling/renderer.tsx @@ -4,6 +4,7 @@ import { type CeilingNode, getMaterialPresetByRef, resolveMaterial, + useLiveTransforms, useRegistry, useScene, } from '@pascal-app/core' @@ -79,6 +80,13 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) 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( () => () => { @@ -124,7 +132,12 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { ]) return ( - + { () => (liveOverrides ? ({ ...storeNode, ...liveOverrides } as ItemNode) : storeNode), [storeNode, liveOverrides], ) + const roomClearPreview = + (node as ItemNode & { roomClearPreview?: unknown }).roomClearPreview === true return ( - }> - }> - - - - {node.children?.map((childId) => ( - - ))} + {roomClearPreview ? ( + + ) : ( + <> + }> + }> + + + + {node.children?.map((childId) => ( + + ))} + + )} ) } @@ -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 ( + + + + ) +} + const multiplyScales = ( a: [number, number, number], b: [number, number, number], diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx index 496ec333..a36eea3f 100644 --- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -4,6 +4,7 @@ import { getEffectiveNode, nodeRegistry, sceneRegistry, + useLiveTransforms, useScene, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' @@ -104,9 +105,10 @@ function updateCeilingGeometry( // canonical position after the rebuild. Matches the pattern used by // FenceSystem.updateFenceGeometry / GeometrySystem (both fully reset // position+rotation after rebuild). - mesh.position.x = 0 - mesh.position.z = 0 - mesh.position.y = (node.height ?? 2.5) - 0.01 // Slight offset to avoid z-fighting with upper-level slabs + const liveTransform = useLiveTransforms.getState().get(node.id) + mesh.position.x = liveTransform?.position[0] ?? 0 + 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 } /**