From 3df3d12f4673d7ee1b58739c0552994eeb8b3fb5 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 1 May 2026 00:33:57 +0530 Subject: [PATCH] Refactor guide UI state and slab rendering --- packages/core/src/index.ts | 3 +- packages/core/src/lib/slab-polygon.ts | 67 +++++++++++++++++++ packages/core/src/schema/nodes/guide.ts | 3 - .../core/src/systems/slab/slab-system.tsx | 67 +------------------ .../src/components/editor/floorplan-panel.tsx | 62 +++++++++++------ .../components/ui/panels/reference-panel.tsx | 33 ++++----- packages/editor/src/lib/local-guide-image.ts | 2 - packages/editor/src/store/use-editor.tsx | 41 +++++++++++- .../renderers/guide/guide-renderer.tsx | 2 +- 9 files changed, 172 insertions(+), 108 deletions(-) create mode 100644 packages/core/src/lib/slab-polygon.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b3ec8484..e81a36bb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,6 +33,7 @@ export { } from './hooks/spatial-grid/spatial-grid-sync' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' export { loadAssetUrl, saveAsset } from './lib/asset-storage' +export { getRenderableSlabPolygon } from './lib/slab-polygon' export { detectSpacesForLevel, initSpaceDetectionSync, @@ -71,7 +72,7 @@ export { DoorSystem } from './systems/door/door-system' export { FenceSystem } from './systems/fence/fence-system' export { ItemSystem } from './systems/item/item-system' export { RoofSystem } from './systems/roof/roof-system' -export { getRenderableSlabPolygon, SlabSystem } from './systems/slab/slab-system' +export { SlabSystem } from './systems/slab/slab-system' export { StairSystem } from './systems/stair/stair-system' export { getClampedWallCurveOffset, diff --git a/packages/core/src/lib/slab-polygon.ts b/packages/core/src/lib/slab-polygon.ts new file mode 100644 index 00000000..4dd2f4bf --- /dev/null +++ b/packages/core/src/lib/slab-polygon.ts @@ -0,0 +1,67 @@ +import type { SlabNode } from '../schema' +import { insetPolygonFromCentroid, simplifyClosedPolygon } from './polygon-geometry' + +/** Half of default wall thickness — used to extend slab geometry under walls */ +const SLAB_OUTSET = 0.05 +const AUTO_SLAB_INSET = 0.02 +const AUTO_SLAB_SIMPLIFY_TOLERANCE = 0.08 + +export function getRenderableSlabPolygon(slabNode: SlabNode): Array<[number, number]> { + return slabNode.autoFromWalls + ? simplifyClosedPolygon( + insetPolygonFromCentroid(slabNode.polygon, AUTO_SLAB_INSET), + AUTO_SLAB_SIMPLIFY_TOLERANCE, + ) + : outsetPolygon(slabNode.polygon, SLAB_OUTSET) +} + +/** + * Expand a polygon outward by a uniform distance. + * Offsets each edge outward then intersects consecutive offset edges. + */ +function outsetPolygon(polygon: Array<[number, number]>, amount: number): Array<[number, number]> { + const n = polygon.length + if (n < 3) return polygon + + // Determine winding via signed area + let area2 = 0 + for (let i = 0; i < n; i++) { + const j = (i + 1) % n + area2 += polygon[i]![0] * polygon[j]![1] - polygon[j]![0] * polygon[i]![1] + } + const s = area2 >= 0 ? 1 : -1 + + // Offset each edge outward by amount + const offEdges: Array<[number, number, number, number]> = [] + for (let i = 0; i < n; i++) { + const j = (i + 1) % n + const dx = polygon[j]![0] - polygon[i]![0] + const dz = polygon[j]![1] - polygon[i]![1] + const len = Math.sqrt(dx * dx + dz * dz) + if (len < 1e-9) { + offEdges.push([polygon[i]![0], polygon[i]![1], dx, dz]) + continue + } + const nx = ((s * dz) / len) * amount + const nz = ((s * -dx) / len) * amount + offEdges.push([polygon[i]![0] + nx, polygon[i]![1] + nz, dx, dz]) + } + + // Intersect consecutive offset edges to get new vertices + const result: Array<[number, number]> = [] + for (let i = 0; i < n; i++) { + const j = (i + 1) % n + const [ax, az, adx, adz] = offEdges[i]! + const [bx, bz, bdx, bdz] = offEdges[j]! + const denom = adx * bdz - adz * bdx + if (Math.abs(denom) < 1e-9) { + // Parallel edges — use offset endpoint + result.push([ax + adx, az + adz]) + } else { + const t = ((bx - ax) * bdz - (bz - az) * bdx) / denom + result.push([ax + t * adx, az + t * adz]) + } + } + + return result +} diff --git a/packages/core/src/schema/nodes/guide.ts b/packages/core/src/schema/nodes/guide.ts index 8d75af9a..8dafffb2 100644 --- a/packages/core/src/schema/nodes/guide.ts +++ b/packages/core/src/schema/nodes/guide.ts @@ -9,7 +9,6 @@ export const GuideScaleReference = z.object({ measuredLengthUnits: z.number().positive(), metersPerUnit: z.number().positive(), label: z.string(), - visible: z.boolean().default(true), }) export const GuideNode = BaseNode.extend({ @@ -20,8 +19,6 @@ export const GuideNode = BaseNode.extend({ rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), scale: z.number().default(1), opacity: z.number().min(0).max(100).default(50), - locked: z.boolean().default(false), - showIn3d: z.boolean().default(false), scaleReference: GuideScaleReference.nullable().default(null), }) diff --git a/packages/core/src/systems/slab/slab-system.tsx b/packages/core/src/systems/slab/slab-system.tsx index b4082efd..16920d3a 100644 --- a/packages/core/src/systems/slab/slab-system.tsx +++ b/packages/core/src/systems/slab/slab-system.tsx @@ -1,7 +1,7 @@ import { useFrame } from '@react-three/fiber' import * as THREE from 'three' import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import { insetPolygonFromCentroid, simplifyClosedPolygon } from '../../lib/polygon-geometry' +import { getRenderableSlabPolygon } from '../../lib/slab-polygon' import type { AnyNodeId, SlabNode } from '../../schema' import useScene from '../../store/use-scene' @@ -58,71 +58,6 @@ function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) { mesh.position.y = elevation < 0 ? elevation : 0 } -/** Half of default wall thickness — used to extend slab geometry under walls */ -const SLAB_OUTSET = 0.05 -const AUTO_SLAB_INSET = 0.02 -const AUTO_SLAB_SIMPLIFY_TOLERANCE = 0.08 - -export function getRenderableSlabPolygon(slabNode: SlabNode): Array<[number, number]> { - return slabNode.autoFromWalls - ? simplifyClosedPolygon( - insetPolygonFromCentroid(slabNode.polygon, AUTO_SLAB_INSET), - AUTO_SLAB_SIMPLIFY_TOLERANCE, - ) - : outsetPolygon(slabNode.polygon, SLAB_OUTSET) -} - -/** - * Expand a polygon outward by a uniform distance. - * Offsets each edge outward then intersects consecutive offset edges. - */ -function outsetPolygon(polygon: Array<[number, number]>, amount: number): Array<[number, number]> { - const n = polygon.length - if (n < 3) return polygon - - // Determine winding via signed area - let area2 = 0 - for (let i = 0; i < n; i++) { - const j = (i + 1) % n - area2 += polygon[i]![0] * polygon[j]![1] - polygon[j]![0] * polygon[i]![1] - } - const s = area2 >= 0 ? 1 : -1 - - // Offset each edge outward by amount - const offEdges: Array<[number, number, number, number]> = [] - for (let i = 0; i < n; i++) { - const j = (i + 1) % n - const dx = polygon[j]![0] - polygon[i]![0] - const dz = polygon[j]![1] - polygon[i]![1] - const len = Math.sqrt(dx * dx + dz * dz) - if (len < 1e-9) { - offEdges.push([polygon[i]![0], polygon[i]![1], dx, dz]) - continue - } - const nx = ((s * dz) / len) * amount - const nz = ((s * -dx) / len) * amount - offEdges.push([polygon[i]![0] + nx, polygon[i]![1] + nz, dx, dz]) - } - - // Intersect consecutive offset edges to get new vertices - const result: Array<[number, number]> = [] - for (let i = 0; i < n; i++) { - const j = (i + 1) % n - const [ax, az, adx, adz] = offEdges[i]! - const [bx, bz, bdx, bdz] = offEdges[j]! - const denom = adx * bdz - adz * bdx - if (Math.abs(denom) < 1e-9) { - // Parallel edges — use offset endpoint - result.push([ax + adx, az + adz]) - } else { - const t = ((bx - ax) * bdz - (bz - az) * bdx) / denom - result.push([ax + t * adx, az + t * adz]) - } - } - - return result -} - /** * Generates extruded slab geometry from polygon */ diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index faa92ee5..1c71b30b 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -55,6 +55,7 @@ import { useState, } from 'react' import { createPortal } from 'react-dom' +import { useShallow } from 'zustand/react/shallow' import { buildFloorplanItemEntry, buildFloorplanStairEntry as buildSharedFloorplanStairEntry, @@ -67,6 +68,7 @@ import { duplicateRoofSubtree } from '../../lib/roof-duplication' import { sfxEmitter } from '../../lib/sfx-bus' import { duplicateStairSubtree } from '../../lib/stair-duplication' import { cn } from '../../lib/utils' +import type { GuideUiState } from '../../store/use-editor' import useEditor from '../../store/use-editor' import { FloorplanActionMenuLayer as Editor2dFloorplanActionMenuLayer } from '../editor-2d/floorplan-action-menu-layer' import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay' @@ -3267,6 +3269,7 @@ const FloorplanGridLayer = memo(function FloorplanGridLayer({ }) const FloorplanGuideLayer = memo(function FloorplanGuideLayer({ + guideUi, guides, isInteractive, selectedGuideId, @@ -3275,6 +3278,7 @@ const FloorplanGuideLayer = memo(function FloorplanGuideLayer({ onGuideSelect, onGuideTranslateStart, }: { + guideUi: Record guides: GuideNode[] isInteractive: boolean selectedGuideId: GuideNode['id'] | null @@ -3303,7 +3307,7 @@ const FloorplanGuideLayer = memo(function FloorplanGuideLayer({ activeGuideInteractionGuideId === guide.id ? activeGuideInteractionMode : null } guide={guide} - isInteractive={isInteractive && guide.locked !== true} + isInteractive={isInteractive && guideUi[guide.id]?.locked !== true} isSelected={selectedGuideId === guide.id} key={guide.id} onGuideSelect={onGuideSelect} @@ -3405,21 +3409,24 @@ function FloorplanReferenceScaleLine({ function FloorplanReferenceScaleLayer({ draft, + guideUi, guides, palette, unit, unitsPerPixel, }: { draft: ReferenceScaleDraft | null + guideUi: Record guides: GuideNode[] palette: FloorplanPalette unit: 'metric' | 'imperial' unitsPerPixel: number }) { const visibleReferences = guides + .filter((guide) => guideUi[guide.id]?.scaleReferenceVisible !== false) .map((guide) => guide.scaleReference) .filter((reference): reference is NonNullable => - Boolean(reference && reference.visible !== false), + Boolean(reference), ) return ( @@ -6170,7 +6177,10 @@ export function FloorplanPanel() { const showReferenceFloor = useEditor((s) => s.showReferenceFloor) const referenceFloorOffset = useEditor((s) => s.referenceFloorOffset) const referenceFloorOpacity = useEditor((s) => s.referenceFloorOpacity) - const sceneNodes = useScene((state) => state.nodes) + const guideUi = useEditor((s) => s.guideUi) + const setGuideLocked = useEditor((s) => s.setGuideLocked) + const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible) + const clearGuideUi = useEditor((s) => s.clearGuideUi) const [floorplanMarqueeState, setFloorplanMarqueeState] = useState( null, ) @@ -6775,24 +6785,33 @@ export function FloorplanPanel() { return lowerLevels[referenceFloorOffset - 1] ?? lowerLevels[0] ?? null }, [floorplanLevels, levelNode, referenceFloorOffset, showReferenceFloor]) + const referenceFloorDescendants = useScene( + useShallow((state) => { + if (!referenceFloorLevel) { + return [] as AnyNode[] + } + + return collectLevelDescendants( + referenceFloorLevel, + state.nodes as Record, + ).filter((node) => node.visible !== false) + }), + ) const referenceFloorData = useMemo(() => { if (!referenceFloorLevel) { return null } - const children = referenceFloorLevel.children - .map((childId) => sceneNodes[childId]) - .filter((node): node is AnyNode => Boolean(node && node.visible !== false)) + const children = referenceFloorDescendants.filter( + (node) => node.parentId === referenceFloorLevel.id, + ) const referenceWalls = children.filter((node): node is WallNode => node.type === 'wall') const referenceFences = children.filter((node): node is FenceNode => node.type === 'fence') const referenceSlabs = children.filter((node): node is SlabNode => node.type === 'slab') const referenceCeilings = children.filter( (node): node is CeilingNode => node.type === 'ceiling', ) - const referenceDescendants = collectLevelDescendants( - referenceFloorLevel, - sceneNodes as Record, - ).filter((node) => node.visible !== false) + const referenceDescendants = referenceFloorDescendants const referenceDescendantById = new Map(referenceDescendants.map((node) => [node.id, node])) const referenceFloorplanWalls = referenceWalls.map(getFloorplanWall) @@ -6931,7 +6950,7 @@ export function FloorplanPanel() { slabPolygons, wallPolygons, } - }, [referenceFloorLevel, sceneNodes]) + }, [referenceFloorDescendants, referenceFloorLevel]) const hasPendingItemMeshFootprints = floorplanItemEntries.some((entry) => !entry.usesRealMesh) const floorplanStairEntries = useMemo( () => @@ -8729,13 +8748,14 @@ export function FloorplanPanel() { setReferenceScaleDraft((current) => (current?.guideId === payload.guideId ? null : current)) setPendingReferenceScale((current) => (current?.guideId === payload.guideId ? null : current)) + clearGuideUi(payload.guideId) } emitter.on('guide:deleted', handleDeleted) return () => { emitter.off('guide:deleted', handleDeleted) } - }, []) + }, [clearGuideUi]) const handleReferenceScaleConfirm = useCallback(() => { if (!pendingReferenceScale) { @@ -8784,7 +8804,6 @@ export function FloorplanPanel() { updateNode( pendingReferenceScale.guideId as AnyNodeId, { - locked: true, position: nextGuidePosition, scale: nextGuideScale, scaleReference: { @@ -8794,10 +8813,11 @@ export function FloorplanPanel() { measuredLengthUnits: scaledMeasuredLengthUnits, metersPerUnit, label: formatReferenceScaleLabel(displayLength, referenceScaleUnit), - visible: true, }, } as Partial, ) + setGuideLocked(pendingReferenceScale.guideId, true) + setGuideScaleReferenceVisible(pendingReferenceScale.guideId, true) setSelectedReferenceId(pendingReferenceScale.guideId) setPendingReferenceScale(null) }, [ @@ -8805,6 +8825,8 @@ export function FloorplanPanel() { pendingReferenceScale, referenceScaleUnit, referenceScaleValue, + setGuideLocked, + setGuideScaleReferenceVisible, setSelectedReferenceId, updateNode, ]) @@ -11703,7 +11725,7 @@ export function FloorplanPanel() { corner: GuideCorner, event: ReactPointerEvent, ) => { - if (event.button !== 0 || !canInteractWithGuides || guide.locked === true) { + if (event.button !== 0 || !canInteractWithGuides || guideUi[guide.id]?.locked === true) { return } @@ -11762,7 +11784,7 @@ export function FloorplanPanel() { guideTransformDraftRef.current = nextDraft setGuideTransformDraft(nextDraft) }, - [canInteractWithGuides, handleGuideSelect, theme], + [canInteractWithGuides, guideUi, handleGuideSelect, theme], ) const handleGuideTranslateStart = useCallback( (guide: GuideNode, event: ReactPointerEvent) => { @@ -11770,7 +11792,7 @@ export function FloorplanPanel() { event.button !== 0 || !canInteractWithGuides || selectedGuideId !== guide.id || - guide.locked === true + guideUi[guide.id]?.locked === true ) { return } @@ -11812,7 +11834,7 @@ export function FloorplanPanel() { guideTransformDraftRef.current = nextDraft setGuideTransformDraft(nextDraft) }, - [canInteractWithGuides, getSvgPointFromClientPoint, selectedGuideId], + [canInteractWithGuides, getSvgPointFromClientPoint, guideUi, selectedGuideId], ) const handleOpeningSelect = useCallback( @@ -14183,6 +14205,7 @@ export function FloorplanPanel() { )} diff --git a/packages/editor/src/components/ui/panels/reference-panel.tsx b/packages/editor/src/components/ui/panels/reference-panel.tsx index c52d6a56..57b771c8 100644 --- a/packages/editor/src/components/ui/panels/reference-panel.tsx +++ b/packages/editor/src/components/ui/panels/reference-panel.tsx @@ -20,18 +20,22 @@ import { PanelWrapper } from './panel-wrapper' type ReferenceNode = ScanNode | GuideNode -function getScaleStatus(guide: GuideNode) { +function getScaleStatus(guide: GuideNode, scaleReferenceVisible: boolean) { const reference = guide.scaleReference if (!reference) { return 'Uncalibrated' } - return `${reference.visible === false ? 'Scaled (hidden)' : 'Scaled'} · ${reference.label}` + return `${scaleReferenceVisible ? 'Scaled' : 'Scaled (hidden)'} · ${reference.label}` } export function ReferencePanel() { const selectedReferenceId = useEditor((s) => s.selectedReferenceId) const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId) + const guideUi = useEditor((s) => (selectedReferenceId ? s.guideUi[selectedReferenceId] : undefined)) + const setGuideLocked = useEditor((s) => s.setGuideLocked) + const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible) + const clearGuideUi = useEditor((s) => s.clearGuideUi) const updateNode = useScene((s) => s.updateNode) const deleteNode = useScene((s) => s.deleteNode) const replaceInputRef = useRef(null) @@ -78,13 +82,14 @@ export function ReferencePanel() { url: assetUrl, scaleReference: null, } as Partial) + setGuideScaleReferenceVisible(selectedReferenceId, true) } catch { setReplaceError('Could not replace that image.') } finally { setIsReplacing(false) } }, - [node?.type, selectedReferenceId, updateNode], + [node?.type, selectedReferenceId, setGuideScaleReferenceVisible, updateNode], ) const handleDeleteGuide = useCallback(() => { @@ -94,8 +99,9 @@ export function ReferencePanel() { deleteNode(selectedReferenceId as AnyNode['id']) emitter.emit('guide:deleted', { guideId: selectedReferenceId as GuideNode['id'] }) + clearGuideUi(selectedReferenceId) setSelectedReferenceId(null) - }, [deleteNode, node?.type, selectedReferenceId, setSelectedReferenceId]) + }, [clearGuideUi, deleteNode, node?.type, selectedReferenceId, setSelectedReferenceId]) const handleStartScale = useCallback(() => { if (node?.type !== 'guide') { @@ -130,7 +136,9 @@ export function ReferencePanel() { if (!node || (node.type !== 'scan' && node.type !== 'guide')) return null const isScan = node.type === 'scan' - const scaleStatus = !isScan ? getScaleStatus(node) : null + const guideLocked = !isScan && guideUi?.locked === true + const scaleReferenceVisible = !isScan && guideUi?.scaleReferenceVisible !== false + const scaleStatus = !isScan ? getScaleStatus(node, scaleReferenceVisible) : null return ( ) : ( ) } - label={node.locked ? 'Unlock' : 'Lock'} - onClick={() => handleUpdate({ locked: node.locked !== true } as Partial)} + label={guideLocked ? 'Unlock' : 'Lock'} + onClick={() => setGuideLocked(node.id, !guideLocked)} /> @@ -224,16 +232,11 @@ export function ReferencePanel() { { if (!node.scaleReference) return - handleUpdate({ - scaleReference: { - ...node.scaleReference, - visible: node.scaleReference.visible === false, - }, - } as Partial) + setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible) }} /> void @@ -181,6 +186,10 @@ type EditorState = { setPaintPanelOpen: (open: boolean) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void + guideUi: Record + setGuideLocked: (guideId: string, locked: boolean) => void + setGuideScaleReferenceVisible: (guideId: string, visible: boolean) => void + clearGuideUi: (guideId: string) => void // Space detection for cutaway mode spaces: Record setSpaces: (spaces: Record) => void @@ -632,6 +641,36 @@ const useEditor = create()( setPaintPanelOpen: (open) => set({ isPaintPanelOpen: open }), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), + guideUi: {}, + setGuideLocked: (guideId, locked) => + set((state) => ({ + guideUi: { + ...state.guideUi, + [guideId]: { + ...state.guideUi[guideId], + locked, + }, + }, + })), + setGuideScaleReferenceVisible: (guideId, visible) => + set((state) => ({ + guideUi: { + ...state.guideUi, + [guideId]: { + ...state.guideUi[guideId], + scaleReferenceVisible: visible, + }, + }, + })), + clearGuideUi: (guideId) => + set((state) => { + if (!state.guideUi[guideId]) { + return state + } + const guideUi = { ...state.guideUi } + delete guideUi[guideId] + return { guideUi } + }), spaces: {}, setSpaces: (spaces) => set({ spaces }), editingHole: null, diff --git a/packages/viewer/src/components/renderers/guide/guide-renderer.tsx b/packages/viewer/src/components/renderers/guide/guide-renderer.tsx index 6f9f4282..408c050c 100644 --- a/packages/viewer/src/components/renderers/guide/guide-renderer.tsx +++ b/packages/viewer/src/components/renderers/guide/guide-renderer.tsx @@ -19,7 +19,7 @@ export const GuideRenderer = ({ node }: { node: GuideNode }) => { position={node.position} ref={ref} rotation={[0, node.rotation[1], 0]} - visible={showGuides && node.showIn3d === true} + visible={showGuides && node.visible !== false} > {resolvedUrl && (