From 40e3ce8d843bfabd57ffc176cf595e5608cbb011 Mon Sep 17 00:00:00 2001 From: wass08 Date: Thu, 5 Feb 2026 15:07:49 +0900 Subject: [PATCH] site boundary editor --- .../tools/shared/polygon-editor.tsx | 298 ++++++++++++++++++ .../tools/site/site-boundary-editor.tsx | 42 +++ apps/editor/components/tools/tool-manager.tsx | 9 +- .../tools/zone/zone-boundary-editor.tsx | 292 +---------------- apps/editor/store/use-editor.tsx | 18 +- packages/core/src/schema/nodes/site.ts | 10 +- .../renderers/site/site-renderer.tsx | 91 +++++- 7 files changed, 465 insertions(+), 295 deletions(-) create mode 100644 apps/editor/components/tools/shared/polygon-editor.tsx create mode 100644 apps/editor/components/tools/site/site-boundary-editor.tsx diff --git a/apps/editor/components/tools/shared/polygon-editor.tsx b/apps/editor/components/tools/shared/polygon-editor.tsx new file mode 100644 index 00000000..e71f1266 --- /dev/null +++ b/apps/editor/components/tools/shared/polygon-editor.tsx @@ -0,0 +1,298 @@ +import { useThree } from '@react-three/fiber' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + BufferGeometry, + Float32BufferAttribute, + type Mesh, + Plane, + Raycaster, + Vector2, + Vector3, +} from 'three' + +const Y_OFFSET = 0.02 + +type DragState = { + isDragging: boolean + vertexIndex: number + initialPosition: [number, number] + pointerId: number +} + +export interface PolygonEditorProps { + polygon: Array<[number, number]> + color?: string + onPolygonChange: (polygon: Array<[number, number]>) => void + minVertices?: number +} + +/** + * Generic polygon editor component for editing polygon vertices + * Used by zone and site boundary editors + */ +export const PolygonEditor: React.FC = ({ + polygon, + color = '#3b82f6', + onPolygonChange, + minVertices = 3, +}) => { + const { gl, camera } = useThree() + + // Local state for dragging + const [dragState, setDragState] = useState(null) + const [previewPolygon, setPreviewPolygon] = useState | null>(null) + const [hoveredVertex, setHoveredVertex] = useState(null) + const [hoveredMidpoint, setHoveredMidpoint] = useState(null) + + // Refs for raycasting during drag + const dragPlane = useRef(new Plane(new Vector3(0, 1, 0), -Y_OFFSET)) + const raycaster = useRef(new Raycaster()) + const lineRef = useRef(null!) + + // The polygon to display (preview during drag, or actual polygon) + const displayPolygon = previewPolygon ?? polygon + + // Calculate midpoints for adding new vertices + const midpoints = useMemo(() => { + if (displayPolygon.length < 2) return [] + return displayPolygon.map(([x1, z1], index) => { + const nextIndex = (index + 1) % displayPolygon.length + const [x2, z2] = displayPolygon[nextIndex]! + return [(x1! + x2) / 2, (z1! + z2) / 2] as [number, number] + }) + }, [displayPolygon]) + + // Handle vertex drag + const handleVertexDrag = useCallback( + (clientX: number, clientY: number, vertexIndex: number) => { + const canvas = gl.domElement + const rect = canvas.getBoundingClientRect() + const x = ((clientX - rect.left) / rect.width) * 2 - 1 + const y = -((clientY - rect.top) / rect.height) * 2 + 1 + + raycaster.current.setFromCamera(new Vector2(x, y), camera) + const intersection = new Vector3() + raycaster.current.ray.intersectPlane(dragPlane.current, intersection) + + if (intersection) { + // Snap to 0.5 grid + const gridX = Math.round(intersection.x * 2) / 2 + const gridZ = Math.round(intersection.z * 2) / 2 + + const basePolygon = previewPolygon ?? polygon + const newPolygon = [...basePolygon] + newPolygon[vertexIndex] = [gridX, gridZ] + setPreviewPolygon(newPolygon) + } + }, + [gl, camera, previewPolygon, polygon], + ) + + // Commit polygon changes + const commitPolygonChange = useCallback(() => { + if (previewPolygon) { + onPolygonChange(previewPolygon) + } + setPreviewPolygon(null) + setDragState(null) + }, [previewPolygon, onPolygonChange]) + + // Handle adding a new vertex at midpoint + const handleAddVertex = useCallback( + (afterIndex: number, position: [number, number]) => { + const basePolygon = previewPolygon ?? polygon + const newPolygon = [ + ...basePolygon.slice(0, afterIndex + 1), + position, + ...basePolygon.slice(afterIndex + 1), + ] + + setPreviewPolygon(newPolygon) + return afterIndex + 1 // Return new vertex index + }, + [polygon, previewPolygon], + ) + + // Handle deleting a vertex + const handleDeleteVertex = useCallback( + (index: number) => { + const basePolygon = previewPolygon ?? polygon + if (basePolygon.length <= minVertices) return // Need at least minVertices points + + const newPolygon = basePolygon.filter((_, i) => i !== index) + onPolygonChange(newPolygon) + setPreviewPolygon(null) + }, + [polygon, previewPolygon, onPolygonChange, minVertices], + ) + + // Set up pointer move/up listeners for dragging with pointer capture + useEffect(() => { + if (!dragState?.isDragging) return + + const canvas = gl.domElement + const pointerId = dragState.pointerId + + // Capture pointer to prevent R3F events from firing on other objects (like the grid) + canvas.setPointerCapture(pointerId) + + const handlePointerMove = (e: PointerEvent) => { + handleVertexDrag(e.clientX, e.clientY, dragState.vertexIndex) + } + + const handlePointerUp = (e: PointerEvent) => { + // Release pointer capture + if (canvas.hasPointerCapture(e.pointerId)) { + canvas.releasePointerCapture(e.pointerId) + } + commitPolygonChange() + } + + canvas.addEventListener('pointermove', handlePointerMove) + canvas.addEventListener('pointerup', handlePointerUp) + + return () => { + // Release capture on cleanup + if (canvas.hasPointerCapture(pointerId)) { + canvas.releasePointerCapture(pointerId) + } + canvas.removeEventListener('pointermove', handlePointerMove) + canvas.removeEventListener('pointerup', handlePointerUp) + } + }, [dragState, gl, handleVertexDrag, commitPolygonChange]) + + // Update line geometry when polygon changes + useEffect(() => { + if (!lineRef.current || displayPolygon.length < 2) return + + const positions: number[] = [] + for (const [x, z] of displayPolygon) { + positions.push(x!, Y_OFFSET + 0.01, z!) + } + // Close the loop + const first = displayPolygon[0]! + positions.push(first[0]!, Y_OFFSET + 0.01, first[1]!) + + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + + lineRef.current.geometry.dispose() + lineRef.current.geometry = geometry + }, [displayPolygon]) + + if (displayPolygon.length < minVertices) return null + + const canDelete = displayPolygon.length > minVertices + + return ( + + {/* Border line */} + {/* @ts-ignore */} + + + + + + {/* Vertex handles */} + {displayPolygon.map(([x, z], index) => { + const isHovered = hoveredVertex === index + const isDragging = dragState?.vertexIndex === index + + return ( + { + e.stopPropagation() + setHoveredVertex(index) + }} + onPointerLeave={(e) => { + e.stopPropagation() + setHoveredVertex(null) + }} + onPointerDown={(e) => { + e.stopPropagation() + setDragState({ + isDragging: true, + vertexIndex: index, + initialPosition: [x!, z!], + pointerId: e.nativeEvent.pointerId, + }) + }} + onClick={(e) => { + e.stopPropagation() + }} + onDoubleClick={(e) => { + e.stopPropagation() + if (canDelete) { + handleDeleteVertex(index) + } + }} + > + + + + ) + })} + + {/* Midpoint handles for adding vertices (hidden while dragging) */} + {!dragState && + midpoints.map(([x, z], index) => { + const isHovered = hoveredMidpoint === index + + return ( + { + e.stopPropagation() + setHoveredMidpoint(index) + }} + onPointerLeave={(e) => { + e.stopPropagation() + setHoveredMidpoint(null) + }} + onPointerDown={(e) => { + e.stopPropagation() + const newVertexIndex = handleAddVertex(index, [x!, z!]) + if (newVertexIndex >= 0) { + setDragState({ + isDragging: true, + vertexIndex: newVertexIndex, + initialPosition: [x!, z!], + pointerId: e.nativeEvent.pointerId, + }) + setHoveredMidpoint(null) + } + }} + onClick={(e) => { + e.stopPropagation() + }} + > + + + + ) + })} + + ) +} diff --git a/apps/editor/components/tools/site/site-boundary-editor.tsx b/apps/editor/components/tools/site/site-boundary-editor.tsx new file mode 100644 index 00000000..bf197fff --- /dev/null +++ b/apps/editor/components/tools/site/site-boundary-editor.tsx @@ -0,0 +1,42 @@ +import { useScene, type SiteNode } from '@pascal-app/core' +import { useCallback } from 'react' +import { PolygonEditor } from '../shared/polygon-editor' + +/** + * Site boundary editor - allows editing site polygon when in site phase + * Uses the generic PolygonEditor component + */ +export const SiteBoundaryEditor: React.FC = () => { + const nodes = useScene((state) => state.nodes) + const rootNodeIds = useScene((state) => state.rootNodeIds) + const updateNode = useScene((state) => state.updateNode) + + // Get the site node (first root node) + const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null + const site = siteNode?.type === 'site' ? (siteNode as SiteNode) : null + + const handlePolygonChange = useCallback( + (newPolygon: Array<[number, number]>) => { + if (site) { + updateNode(site.id, { + polygon: { + type: 'polygon', + points: newPolygon, + }, + }) + } + }, + [site, updateNode], + ) + + if (!site || !site.polygon?.points || site.polygon.points.length < 3) return null + + return ( + + ) +} diff --git a/apps/editor/components/tools/tool-manager.tsx b/apps/editor/components/tools/tool-manager.tsx index bd3b03eb..d7740e54 100644 --- a/apps/editor/components/tools/tool-manager.tsx +++ b/apps/editor/components/tools/tool-manager.tsx @@ -4,13 +4,16 @@ import { CeilingTool } from "./ceiling/ceiling-tool"; import { ItemTool } from "./item/item-tool"; import { MoveTool } from "./item/move-tool"; import { RoofTool } from "./roof/roof-tool"; +import { SiteBoundaryEditor } from "./site/site-boundary-editor"; import { SlabTool } from "./slab/slab-tool"; import { WallTool } from "./wall/wall-tool"; import { ZoneBoundaryEditor } from "./zone/zone-boundary-editor"; import { ZoneTool } from "./zone/zone-tool"; const tools: Record>> = { - site: {}, + site: { + "property-line": SiteBoundaryEditor, + }, structure: { wall: WallTool, slab: SlabTool, @@ -31,6 +34,9 @@ export const ToolManager: React.FC = () => { const movingNode = useEditor((state) => state.movingNode); const selectedZoneId = useViewer((state) => state.selection.zoneId); + // Show site boundary editor when in site phase and edit mode + const showSiteBoundaryEditor = phase === "site" && mode === "edit"; + // Show zone boundary editor when in structure/select mode with a zone selected const showZoneBoundaryEditor = phase === "structure" && mode === "select" && selectedZoneId !== null; @@ -42,6 +48,7 @@ export const ToolManager: React.FC = () => { return ( <> + {showSiteBoundaryEditor && } {showZoneBoundaryEditor && } {movingNode && } {!movingNode && BuildToolComponent && } diff --git a/apps/editor/components/tools/zone/zone-boundary-editor.tsx b/apps/editor/components/tools/zone/zone-boundary-editor.tsx index 8968f0a6..2e66eef9 100644 --- a/apps/editor/components/tools/zone/zone-boundary-editor.tsx +++ b/apps/editor/components/tools/zone/zone-boundary-editor.tsx @@ -1,299 +1,37 @@ import { useScene, type ZoneNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useThree } from '@react-three/fiber' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - BufferGeometry, - Float32BufferAttribute, - type Mesh, - Plane, - Raycaster, - Vector2, - Vector3, -} from 'three' - -const Y_OFFSET = 0.02 - -type DragState = { - isDragging: boolean - vertexIndex: number - initialPosition: [number, number] - pointerId: number -} +import { useCallback } from 'react' +import { PolygonEditor } from '../shared/polygon-editor' /** * Zone boundary editor - allows editing zone polygon vertices when a zone is selected - * Uses the event emitter system for grid interactions + * Uses the generic PolygonEditor component */ export const ZoneBoundaryEditor: React.FC = () => { - const { gl, camera } = useThree() const selectedZoneId = useViewer((state) => state.selection.zoneId) const zoneNode = useScene((state) => (selectedZoneId ? state.nodes[selectedZoneId] : null)) const zone = zoneNode?.type === 'zone' ? (zoneNode as ZoneNode) : null const updateNode = useScene((state) => state.updateNode) - // Local state for dragging - const [dragState, setDragState] = useState(null) - const [previewPolygon, setPreviewPolygon] = useState | null>(null) - const [hoveredVertex, setHoveredVertex] = useState(null) - const [hoveredMidpoint, setHoveredMidpoint] = useState(null) - - // Refs for raycasting during drag - const dragPlane = useRef(new Plane(new Vector3(0, 1, 0), -Y_OFFSET)) - const raycaster = useRef(new Raycaster()) - const lineRef = useRef(null!) - - // The polygon to display (preview during drag, or actual zone polygon) - const displayPolygon = previewPolygon ?? zone?.polygon ?? [] - - // Calculate midpoints for adding new vertices - const midpoints = useMemo(() => { - if (displayPolygon.length < 2) return [] - return displayPolygon.map(([x1, z1], index) => { - const nextIndex = (index + 1) % displayPolygon.length - const [x2, z2] = displayPolygon[nextIndex]! - return [(x1! + x2) / 2, (z1! + z2) / 2] as [number, number] - }) - }, [displayPolygon]) - - // Handle vertex drag - const handleVertexDrag = useCallback( - (clientX: number, clientY: number, vertexIndex: number) => { - if (!zone) return - - const canvas = gl.domElement - const rect = canvas.getBoundingClientRect() - const x = ((clientX - rect.left) / rect.width) * 2 - 1 - const y = -((clientY - rect.top) / rect.height) * 2 + 1 - - raycaster.current.setFromCamera(new Vector2(x, y), camera) - const intersection = new Vector3() - raycaster.current.ray.intersectPlane(dragPlane.current, intersection) - - if (intersection) { - // Snap to 0.5 grid - const gridX = Math.round(intersection.x * 2) / 2 - const gridZ = Math.round(intersection.z * 2) / 2 - - const basePolygon = previewPolygon ?? zone.polygon - const newPolygon = [...basePolygon] - newPolygon[vertexIndex] = [gridX, gridZ] - setPreviewPolygon(newPolygon) + const handlePolygonChange = useCallback( + (newPolygon: Array<[number, number]>) => { + if (selectedZoneId) { + updateNode(selectedZoneId, { polygon: newPolygon }) } }, - [zone, gl, camera, previewPolygon], + [selectedZoneId, updateNode], ) - // Commit polygon changes - const commitPolygonChange = useCallback(() => { - if (previewPolygon && selectedZoneId) { - updateNode(selectedZoneId, { polygon: previewPolygon }) - } - setPreviewPolygon(null) - setDragState(null) - }, [previewPolygon, selectedZoneId, updateNode]) + if (!zone || !zone.polygon || zone.polygon.length < 3) return null - // Handle adding a new vertex at midpoint - const handleAddVertex = useCallback( - (afterIndex: number, position: [number, number]) => { - if (!zone) return -1 - - const basePolygon = previewPolygon ?? zone.polygon - const newPolygon = [ - ...basePolygon.slice(0, afterIndex + 1), - position, - ...basePolygon.slice(afterIndex + 1), - ] - - setPreviewPolygon(newPolygon) - return afterIndex + 1 // Return new vertex index - }, - [zone, previewPolygon], - ) - - // Handle deleting a vertex - const handleDeleteVertex = useCallback( - (index: number) => { - if (!zone || !selectedZoneId) return - - const basePolygon = previewPolygon ?? zone.polygon - if (basePolygon.length <= 3) return // Need at least 3 points - - const newPolygon = basePolygon.filter((_, i) => i !== index) - updateNode(selectedZoneId, { polygon: newPolygon }) - setPreviewPolygon(null) - }, - [zone, selectedZoneId, previewPolygon, updateNode], - ) - - // Set up pointer move/up listeners for dragging with pointer capture - useEffect(() => { - if (!dragState?.isDragging) return - - const canvas = gl.domElement - const pointerId = dragState.pointerId - - // Capture pointer to prevent R3F events from firing on other objects (like the grid) - canvas.setPointerCapture(pointerId) - - const handlePointerMove = (e: PointerEvent) => { - handleVertexDrag(e.clientX, e.clientY, dragState.vertexIndex) - } - - const handlePointerUp = (e: PointerEvent) => { - // Release pointer capture - if (canvas.hasPointerCapture(e.pointerId)) { - canvas.releasePointerCapture(e.pointerId) - } - commitPolygonChange() - } - - canvas.addEventListener('pointermove', handlePointerMove) - canvas.addEventListener('pointerup', handlePointerUp) - - return () => { - // Release capture on cleanup - if (canvas.hasPointerCapture(pointerId)) { - canvas.releasePointerCapture(pointerId) - } - canvas.removeEventListener('pointermove', handlePointerMove) - canvas.removeEventListener('pointerup', handlePointerUp) - } - }, [dragState, gl, handleVertexDrag, commitPolygonChange]) - - // Update line geometry when polygon changes - useEffect(() => { - if (!lineRef.current || displayPolygon.length < 2) return - - const positions: number[] = [] - for (const [x, z] of displayPolygon) { - positions.push(x!, Y_OFFSET + 0.01, z!) - } - // Close the loop - const first = displayPolygon[0]! - positions.push(first[0]!, Y_OFFSET + 0.01, first[1]!) - - const geometry = new BufferGeometry() - geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) - - lineRef.current.geometry.dispose() - lineRef.current.geometry = geometry - }, [displayPolygon]) - - if (!zone || displayPolygon.length < 3) return null - - const canDelete = displayPolygon.length > 3 const zoneColor = zone.color || '#3b82f6' return ( - - {/* Border line */} - {/* @ts-ignore */} - - - - - - {/* Vertex handles */} - {displayPolygon.map(([x, z], index) => { - const isHovered = hoveredVertex === index - const isDragging = dragState?.vertexIndex === index - - return ( - { - e.stopPropagation() - setHoveredVertex(index) - }} - onPointerLeave={(e) => { - e.stopPropagation() - setHoveredVertex(null) - }} - onPointerDown={(e) => { - e.stopPropagation() - setDragState({ - isDragging: true, - vertexIndex: index, - initialPosition: [x!, z!], - pointerId: e.nativeEvent.pointerId, - }) - }} - onClick={(e) => { - e.stopPropagation() - }} - onDoubleClick={(e) => { - e.stopPropagation() - if (canDelete) { - handleDeleteVertex(index) - } - }} - > - - - - ) - })} - - {/* Midpoint handles for adding vertices (hidden while dragging) */} - {!dragState && - midpoints.map(([x, z], index) => { - const isHovered = hoveredMidpoint === index - - return ( - { - e.stopPropagation() - setHoveredMidpoint(index) - }} - onPointerLeave={(e) => { - e.stopPropagation() - setHoveredMidpoint(null) - }} - onPointerDown={(e) => { - e.stopPropagation() - const newVertexIndex = handleAddVertex(index, [x!, z!]) - if (newVertexIndex >= 0) { - setDragState({ - isDragging: true, - vertexIndex: newVertexIndex, - initialPosition: [x!, z!], - pointerId: e.nativeEvent.pointerId, - }) - setHoveredMidpoint(null) - } - }} - onClick={(e) => { - e.stopPropagation() - }} - > - - - - ) - })} - + ) } diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx index ee4ece60..bd8dc6f1 100644 --- a/apps/editor/store/use-editor.tsx +++ b/apps/editor/store/use-editor.tsx @@ -83,15 +83,17 @@ const useEditor = create()((set, get) => ({ const selectBuildingAndLevel0 = () => { let buildingId = viewer.selection.buildingId - // If no building selected, find the first one + // If no building selected, find the first one from site's children if (!buildingId) { - const firstBuildingId = scene.rootNodeIds.find((id) => { - const node = scene.nodes[id] - return node?.type === 'building' - }) - if (firstBuildingId) { - buildingId = firstBuildingId as BuildingNode['id'] - viewer.setSelection({ buildingId }) + const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null + if (siteNode?.type === 'site') { + const firstBuilding = siteNode.children + .map((child) => (typeof child === 'string' ? scene.nodes[child] : child)) + .find((node) => node?.type === 'building') + if (firstBuilding) { + buildingId = firstBuilding.id as BuildingNode['id'] + viewer.setSelection({ buildingId }) + } } } diff --git a/packages/core/src/schema/nodes/site.ts b/packages/core/src/schema/nodes/site.ts index b621c741..d52410e9 100644 --- a/packages/core/src/schema/nodes/site.ts +++ b/packages/core/src/schema/nodes/site.ts @@ -24,12 +24,12 @@ export const SiteNode = BaseNode.extend({ // Specific props polygon: PropertyLineData.optional().default({ type: 'polygon', - // Default 30x30 square matching GRID_SIZE + // Default 30x30 square centered at origin points: [ - [0, 0], - [30, 0], - [30, 30], - [0, 30], + [-15, -15], + [15, -15], + [15, 15], + [-15, 15], ], }), // terrain: TerrainData, diff --git a/packages/viewer/src/components/renderers/site/site-renderer.tsx b/packages/viewer/src/components/renderers/site/site-renderer.tsx index 8d4f7387..58e3d222 100644 --- a/packages/viewer/src/components/renderers/site/site-renderer.tsx +++ b/packages/viewer/src/components/renderers/site/site-renderer.tsx @@ -1,20 +1,103 @@ import { type SiteNode, useRegistry } from '@pascal-app/core' -import { useRef } from 'react' -import type { Group } from 'three' +import { useMemo, useRef } from 'react' +import { BufferGeometry, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' import { NodeRenderer } from '../node-renderer' +const Y_OFFSET = 0.01 +const LINE_HEIGHT = 0.5 + +/** + * Creates simple line geometry for site boundary + * Single horizontal line at ground level + */ +const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeometry => { + const geometry = new BufferGeometry() + + if (points.length < 2) return geometry + + const positions: number[] = [] + + // Create a simple line loop at ground level + for (const [x, z] of points) { + positions.push(x!, Y_OFFSET, z!) + } + // Close the loop + positions.push(points[0]![0]!, Y_OFFSET, points[0]![1]!) + + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + + return geometry +} + export const SiteRenderer = ({ node }: { node: SiteNode }) => { const ref = useRef(null!) - useRegistry(node.id, node.type, ref) + useRegistry(node.id, 'site', ref) + + // Create floor shape from polygon points + const floorShape = useMemo(() => { + if (!node?.polygon?.points || node.polygon.points.length < 3) return null + const shape = new Shape() + const firstPt = node.polygon.points[0]! + + // Shape is in X-Y plane, we rotate it to X-Z plane + // Negate Y (which becomes Z) to get correct orientation + shape.moveTo(firstPt[0]!, -firstPt[1]!) + + for (let i = 1; i < node.polygon.points.length; i++) { + const pt = node.polygon.points[i]! + shape.lineTo(pt[0]!, -pt[1]!) + } + shape.closePath() + + return shape + }, [node?.polygon?.points]) + + // Create boundary line geometry + const lineGeometry = useMemo(() => { + if (!node?.polygon?.points || node.polygon.points.length < 2) return null + return createBoundaryLineGeometry(node.polygon.points) + }, [node?.polygon?.points]) + const handlers = useNodeEvents(node, 'site') + if (!node || !floorShape || !lineGeometry) { + return null + } + return ( + {/* Render children (buildings and items) */} {node.children.map((child) => ( - + ))} + + {/* Transparent floor fill */} + + + + + + {/* Simple boundary line */} + {/* @ts-ignore */} + + + ) }