diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index a2e01262..faa92ee5 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -2,6 +2,7 @@ import { Icon } from '@iconify/react' import { + type AnyNode, type AnyNodeId, type BuildingNode, type CeilingNode, @@ -57,6 +58,7 @@ import { createPortal } from 'react-dom' import { buildFloorplanItemEntry, buildFloorplanStairEntry as buildSharedFloorplanStairEntry, + collectLevelDescendants, getFloorplanWall as getSharedFloorplanWall, rotatePlanVector as rotateSharedPlanVector, type FloorplanNodeTransform as SharedFloorplanNodeTransform, @@ -582,6 +584,15 @@ type FloorplanItemEntry = { depth: number } +type ReferenceFloorData = { + ceilingPolygons: CeilingPolygonEntry[] + fenceEntries: FloorplanFenceEntry[] + itemEntries: FloorplanItemEntry[] + openingPolygons: OpeningPolygonEntry[] + slabPolygons: SlabPolygonEntry[] + wallPolygons: WallPolygonEntry[] +} + type FloorplanStairSegmentEntry = { centerLine: FloorplanLineSegment | null innerPoints: string @@ -3611,6 +3622,97 @@ function FloorplanGuideHandleHint({ ) } +const FloorplanReferenceFloorLayer = memo(function FloorplanReferenceFloorLayer({ + data, + opacity, +}: { + data: ReferenceFloorData | null + opacity: number +}) { + if (!data) { + return null + } + + const clampedOpacity = clamp(opacity, 0.1, 0.8) + + return ( + + {data.slabPolygons.map(({ path, slab }) => ( + + ))} + + {data.ceilingPolygons.map(({ ceiling, path }) => ( + + ))} + + {data.wallPolygons.map(({ polygon, points, wall }) => + polygon.length >= 3 ? ( + + ) : null, + )} + + {data.fenceEntries.map(({ fence, path }) => ( + + ))} + + {data.openingPolygons.map(({ opening, points }) => ( + + ))} + + {data.itemEntries.map(({ item, points }) => ( + + ))} + + ) +}) + const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ canFocusGeometry, canSelectGeometry, @@ -6065,6 +6167,10 @@ export function FloorplanPanel() { const [hoveredGuideCorner, setHoveredGuideCorner] = useState(null) const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool) const setFloorplanSelectionTool = useEditor((s) => s.setFloorplanSelectionTool) + 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 [floorplanMarqueeState, setFloorplanMarqueeState] = useState( null, ) @@ -6658,6 +6764,174 @@ export function FloorplanPanel() { ] }) }, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision]) + const referenceFloorLevel = useMemo(() => { + if (!(showReferenceFloor && levelNode)) { + return null + } + + const lowerLevels = floorplanLevels + .filter((floorLevel) => floorLevel.id !== levelNode.id && floorLevel.level < levelNode.level) + .sort((a, b) => b.level - a.level) + + return lowerLevels[referenceFloorOffset - 1] ?? lowerLevels[0] ?? null + }, [floorplanLevels, levelNode, referenceFloorOffset, showReferenceFloor]) + 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 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 referenceDescendantById = new Map(referenceDescendants.map((node) => [node.id, node])) + + const referenceFloorplanWalls = referenceWalls.map(getFloorplanWall) + const referenceWallMiterData = calculateLevelMiters(referenceFloorplanWalls) + const referenceFloorplanWallById = new Map( + referenceFloorplanWalls.map((wall) => [wall.id, wall] as const), + ) + + const wallPolygons = referenceWalls.map((wall) => { + const floorplanWall = referenceFloorplanWallById.get(wall.id) ?? getFloorplanWall(wall) + const polygon = getWallPlanFootprint(floorplanWall, referenceWallMiterData) + + return { + points: formatPolygonPoints(polygon), + polygon, + wall, + } + }) + + const openingPolygons = referenceDescendants.flatMap((node) => { + if (!(node.type === 'door' || node.type === 'window')) { + return [] + } + + const wall = referenceFloorplanWallById.get(node.parentId as WallNode['id']) + if (!wall) { + return [] + } + + const polygon = getOpeningFootprint(wall, node) + return [ + { + opening: node, + points: formatPolygonPoints(polygon), + polygon, + }, + ] + }) + + const slabPolygons = referenceSlabs.flatMap((slab) => { + const polygon = toFloorplanPolygon(slab.polygon) + if (polygon.length < 3) { + return [] + } + + const holes = (slab.holes ?? []) + .map((hole) => toFloorplanPolygon(hole)) + .filter((hole) => hole.length >= 3) + const visualPolygon = toFloorplanPolygon(getRenderableSlabPolygon(slab)) + const visualHoles = holes + + return [ + { + slab, + polygon, + holes, + visualPolygon, + visualHoles, + path: formatPolygonPath(visualPolygon, visualHoles), + }, + ] + }) + + const ceilingPolygons = referenceCeilings.flatMap((ceiling) => { + const polygon = toFloorplanPolygon(ceiling.polygon) + if (polygon.length < 3) { + return [] + } + + const holes = (ceiling.holes ?? []) + .map((hole) => toFloorplanPolygon(hole)) + .filter((hole) => hole.length >= 3) + + return [ + { + ceiling, + polygon, + holes, + path: formatPolygonPath(polygon, holes), + }, + ] + }) + + const fenceEntries = referenceFences.flatMap((fence) => { + const centerline = isCurvedWall(fence) + ? sampleWallCenterline(fence, 24) + : [ + { x: fence.start[0], y: fence.start[1] }, + { x: fence.end[0], y: fence.end[1] }, + ] + const path = buildSvgPolylinePath(centerline) + if (!path) { + return [] + } + + return [{ fence, centerline, markerFrames: [], path }] + }) + + const transformCache = new Map() + const itemEntries = referenceDescendants.flatMap((node) => { + if ( + !( + node.type === 'item' && + node.asset.category !== 'door' && + node.asset.category !== 'window' + ) + ) { + return [] + } + + const entry = buildFloorplanItemEntry(node, referenceDescendantById, transformCache) + if (!entry) { + return [] + } + + return [ + { + dimensionPolygon: entry.dimensionPolygon, + item: entry.item, + points: formatPolygonPoints(entry.polygon), + polygon: entry.polygon, + usesRealMesh: entry.usesRealMesh, + center: entry.center, + rotation: entry.rotation, + width: entry.width, + depth: entry.depth, + }, + ] + }) + + return { + ceilingPolygons, + fenceEntries, + itemEntries, + openingPolygons, + slabPolygons, + wallPolygons, + } + }, [referenceFloorLevel, sceneNodes]) const hasPendingItemMeshFootprints = floorplanItemEntries.some((entry) => !entry.usesRealMesh) const floorplanStairEntries = useMemo( () => @@ -13901,6 +14175,11 @@ export function FloorplanPanel() { showGrid={showGrid} /> + + s.selection.levelId) + return useScene( + useShallow((state) => { + if (!levelId) return [] as LevelNode[] + const activeLevel = state.nodes[levelId] + if (!activeLevel || activeLevel.type !== 'level') return [] as LevelNode[] + const buildingId = activeLevel.parentId as BuildingNode['id'] | undefined + const building = buildingId ? state.nodes[buildingId] : null + if (!building || building.type !== 'building') return [] as LevelNode[] + + return (building.children ?? []) + .map((id) => state.nodes[id]) + .filter( + (node): node is LevelNode => + node?.type === 'level' && node.id !== levelId && node.level < activeLevel.level, + ) + .sort((a, b) => b.level - a.level) + }), + ) +} + // ── Shared upload button for dropdowns ────────────────────────────────────── function UploadButton({ onError }: { onError: (message: string | null) => void }) { @@ -578,6 +601,156 @@ function ScansControl() { ) } +function ReferenceFloorControl() { + const showReferenceFloor = useEditor((state) => state.showReferenceFloor) + const toggleReferenceFloor = useEditor((state) => state.toggleReferenceFloor) + const referenceFloorOffset = useEditor((state) => state.referenceFloorOffset) + const setReferenceFloorOffset = useEditor((state) => state.setReferenceFloorOffset) + const referenceFloorOpacity = useEditor((state) => state.referenceFloorOpacity) + const setReferenceFloorOpacity = useEditor((state) => state.setReferenceFloorOpacity) + const [isOpen, setIsOpen] = useState(false) + const lowerLevels = useLowerReferenceLevels() + const hasLowerLevels = lowerLevels.length > 0 + const selectedLevel = lowerLevels[referenceFloorOffset - 1] ?? lowerLevels[0] ?? null + + return ( + +
+ { + if (hasLowerLevels) toggleReferenceFloor() + }} + size="icon" + variant="ghost" + > +
+ + + {lowerLevels.length} + +
+
+ + + + +
+ + +
+
+ + + +
+

Reference floor

+ {selectedLevel && ( +

{selectedLevel.name}

+ )} +
+ +
+ + {hasLowerLevels ? ( + <> +
+ {lowerLevels.map((level, index) => { + const isSelected = referenceFloorOffset === index + 1 + return ( + + ) + })} +
+ + + + ) : ( +
+ No lower floor available. +
+ )} +
+
+
+ ) +} + // ── Main ViewToggles ──────────────────────────────────────────────────────── export function ViewToggles() { @@ -588,6 +761,8 @@ export function ViewToggles() { {/* Guides (toggle + dropdown) */} + + ) } @@ -599,6 +774,7 @@ export function SecondaryToggles() { + ) } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index d25b33a5..cd3ed6f8 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -96,7 +96,11 @@ export type MovingFenceEndpoint = { endpoint: 'start' | 'end' } -export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole | SingleSurfaceMaterialRole +export type MaterialTargetRole = + | WallSurfaceSide + | StairSurfaceMaterialRole + | RoofSurfaceMaterialRole + | SingleSurfaceMaterialRole export type SelectedMaterialTarget = { nodeId: AnyNodeId @@ -197,6 +201,13 @@ type EditorState = { setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void gridSnapStep: GridSnapStep setGridSnapStep: (step: GridSnapStep) => void + showReferenceFloor: boolean + toggleReferenceFloor: () => void + setShowReferenceFloor: (show: boolean) => void + referenceFloorOffset: number + setReferenceFloorOffset: (offset: number) => void + referenceFloorOpacity: number + setReferenceFloorOpacity: (opacity: number) => void // First-person walkthrough mode (street view) isFirstPersonMode: boolean _viewModeBeforeFirstPerson: ViewMode | null @@ -226,6 +237,9 @@ type PersistedEditorLayoutState = Pick< | 'splitOrientation' | 'floorplanSelectionTool' | 'gridSnapStep' + | 'showReferenceFloor' + | 'referenceFloorOffset' + | 'referenceFloorOpacity' > type PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState @@ -245,6 +259,9 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = splitOrientation: 'horizontal', floorplanSelectionTool: 'click', gridSnapStep: 0.5, + showReferenceFloor: false, + referenceFloorOffset: 1, + referenceFloorOpacity: 0.35, } const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05] @@ -354,6 +371,16 @@ function normalizePersistedEditorLayoutState( gridSnapStep: GRID_SNAP_STEPS.includes(state?.gridSnapStep as GridSnapStep) ? (state?.gridSnapStep as GridSnapStep) : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep, + showReferenceFloor: state?.showReferenceFloor === true, + referenceFloorOffset: + typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1 + ? Math.floor(state.referenceFloorOffset) + : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOffset, + referenceFloorOpacity: + typeof state?.referenceFloorOpacity === 'number' && + Number.isFinite(state.referenceFloorOpacity) + ? Math.min(0.8, Math.max(0.1, state.referenceFloorOpacity)) + : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOpacity, } } @@ -632,6 +659,16 @@ const useEditor = create()( setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }), gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep, setGridSnapStep: (step) => set({ gridSnapStep: step }), + showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor, + toggleReferenceFloor: () => + set((state) => ({ showReferenceFloor: !state.showReferenceFloor })), + setShowReferenceFloor: (show) => set({ showReferenceFloor: show }), + referenceFloorOffset: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOffset, + setReferenceFloorOffset: (offset) => + set({ referenceFloorOffset: Math.max(1, Math.floor(offset)) }), + referenceFloorOpacity: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOpacity, + setReferenceFloorOpacity: (opacity) => + set({ referenceFloorOpacity: Math.min(0.8, Math.max(0.1, opacity)) }), allowUndergroundCamera: false, setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }), isFirstPersonMode: false, @@ -702,6 +739,9 @@ const useEditor = create()( splitOrientation: state.splitOrientation, floorplanSelectionTool: state.floorplanSelectionTool, gridSnapStep: state.gridSnapStep, + showReferenceFloor: state.showReferenceFloor, + referenceFloorOffset: state.referenceFloorOffset, + referenceFloorOpacity: state.referenceFloorOpacity, }), }, ),