From e27028a29a357db30deae1a6235b9078ff7fcc77 Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 28 Jan 2026 06:55:56 +0900 Subject: [PATCH] working ceiling items --- .../components/tools/item/item-tool.tsx | 18 ++++- .../panels/site-panel/ceiling-tree-node.tsx | 16 ++-- .../spatial-grid/spatial-grid-manager.ts | 76 ++++++++++++++++++- .../hooks/spatial-grid/use-spatial-query.ts | 17 ++++- 4 files changed, 116 insertions(+), 11 deletions(-) diff --git a/apps/editor/components/tools/item/item-tool.tsx b/apps/editor/components/tools/item/item-tool.tsx index 95829657..7d4f3f53 100644 --- a/apps/editor/components/tools/item/item-tool.tsx +++ b/apps/editor/components/tools/item/item-tool.tsx @@ -1,5 +1,6 @@ import { type CeilingEvent, + type CeilingNode, emitter, type GridEvent, ItemNode, @@ -112,7 +113,7 @@ export const ItemTool: React.FC = () => { const draftItem = useRef(null) const gridPosition = useRef(new Vector3(0, 0, 0)) const selectedItem = useEditor((state) => state.selectedItem) - const { canPlaceOnFloor, canPlaceOnWall } = useSpatialQuery() + const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery() const isOnWall = useRef(false) const isOnCeiling = useRef(false) @@ -129,7 +130,18 @@ export const ItemTool: React.FC = () => { if (currentLevelId && draftItem.current) { let placeable = true if (draftItem.current.asset.attachTo === 'ceiling') { - placeable = isOnCeiling.current && !!currentCeilingId + if (!isOnCeiling.current || !currentCeilingId) { + placeable = false + } else { + const result = canPlaceOnCeiling( + currentCeilingId as CeilingNode['id'], + [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], + draftItem.current.asset.dimensions, + draftItem.current.rotation, + [draftItem.current.id], + ) + placeable = result.valid + } } else if (draftItem.current.asset.attachTo) { if (!isOnWall.current || !currentWallId) { placeable = false @@ -541,7 +553,7 @@ export const ItemTool: React.FC = () => { emitter.off('ceiling:leave', onCeilingLeave) window.removeEventListener('keydown', onKeyDown) } - }, [selectedItem, canPlaceOnFloor, canPlaceOnWall]) + }, [selectedItem, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling]) useFrame((_, delta) => { if (draftItem.current && !isOnWall.current && !isOnCeiling.current) { diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx index 18d00311..33b718b3 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx @@ -1,7 +1,8 @@ import { CeilingNode } from "@pascal-app/core"; import { useViewer } from "@pascal-app/viewer"; import { Square } from "lucide-react"; -import { TreeNodeWrapper } from "./tree-node"; +import { useState } from "react"; +import { TreeNode, TreeNodeWrapper } from "./tree-node"; import { TreeNodeActions } from "./tree-node-actions"; interface CeilingTreeNodeProps { @@ -10,6 +11,7 @@ interface CeilingTreeNodeProps { } export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) { + const [expanded, setExpanded] = useState(false); const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id)); const isHovered = useViewer((state) => state.hoveredId === node.id); const setSelection = useViewer((state) => state.setSelection); @@ -35,16 +37,20 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) { icon={} label={node.name || `Ceiling (${area}m²)`} depth={depth} - hasChildren={false} - expanded={false} - onToggle={() => {}} + hasChildren={node.children.length > 0} + expanded={expanded} + onToggle={() => setExpanded(!expanded)} onClick={handleClick} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} isSelected={isSelected} isHovered={isHovered} actions={} - /> + > + {node.children.map((childId) => ( + + ))} + ); } diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index 6861dcd0..bc01f718 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,4 +1,4 @@ -import type { AnyNode, ItemNode, SlabNode, WallNode } from '../../schema' +import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { SpatialGrid } from './spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid' @@ -160,6 +160,9 @@ export class SpatialGridManager { private wallGrids = new Map() // levelId -> wall grid private walls = new Map() // wallId -> wall data (for length calculations) private slabsByLevel = new Map>() // levelId -> (slabId -> slab) + private ceilingGrids = new Map() // ceilingId -> grid + private ceilings = new Map() // ceilingId -> ceiling data + private itemCeilingMap = new Map() // itemId -> ceilingId (reverse lookup) constructor(private cellSize = 0.5) {} @@ -190,6 +193,13 @@ export class SpatialGridManager { return wall?.height ?? 2.5 // Default wall height } + private getCeilingGrid(ceilingId: string): SpatialGrid { + if (!this.ceilingGrids.has(ceilingId)) { + this.ceilingGrids.set(ceilingId, new SpatialGrid({ cellSize: this.cellSize })) + } + return this.ceilingGrids.get(ceilingId)! + } + private getSlabMap(levelId: string): Map { if (!this.slabsByLevel.has(levelId)) { this.slabsByLevel.set(levelId, new Map()) @@ -201,6 +211,8 @@ export class SpatialGridManager { handleNodeCreated(node: AnyNode, levelId: string) { if (node.type === 'slab') { this.getSlabMap(levelId).set(node.id, node as SlabNode) + } else if (node.type === 'ceiling') { + this.ceilings.set(node.id, node as CeilingNode) } else if (node.type === 'wall') { const wall = node as WallNode this.walls.set(wall.id, wall) @@ -229,6 +241,13 @@ export class SpatialGridManager { }) } } + } else if (item.asset.attachTo === 'ceiling') { + // Ceiling item - use parentId as the ceiling ID + const ceilingId = item.parentId + if (ceilingId && this.ceilings.has(ceilingId)) { + this.getCeilingGrid(ceilingId).insert(item.id, item.position, item.asset.dimensions, item.rotation) + this.itemCeilingMap.set(item.id, ceilingId) + } } else if (!item.asset.attachTo) { // Floor item this.getFloorGrid(levelId).insert( @@ -244,6 +263,8 @@ export class SpatialGridManager { handleNodeUpdated(node: AnyNode, levelId: string) { if (node.type === 'slab') { this.getSlabMap(levelId).set(node.id, node as SlabNode) + } else if (node.type === 'ceiling') { + this.ceilings.set(node.id, node as CeilingNode) } else if (node.type === 'wall') { const wall = node as WallNode this.walls.set(wall.id, wall) @@ -273,6 +294,19 @@ export class SpatialGridManager { }) } } + } else if (item.asset.attachTo === 'ceiling') { + // Remove from old ceiling grid + const oldCeilingId = this.itemCeilingMap.get(item.id) + if (oldCeilingId) { + this.getCeilingGrid(oldCeilingId).remove(item.id) + this.itemCeilingMap.delete(item.id) + } + // Insert into new ceiling grid + const ceilingId = item.parentId + if (ceilingId && this.ceilings.has(ceilingId)) { + this.getCeilingGrid(ceilingId).insert(item.id, item.position, item.asset.dimensions, item.rotation) + this.itemCeilingMap.set(item.id, ceilingId) + } } else if (!item.asset.attachTo) { this.getFloorGrid(levelId).update( item.id, @@ -287,6 +321,9 @@ export class SpatialGridManager { handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) { if (nodeType === 'slab') { this.getSlabMap(levelId).delete(nodeId) + } else if (nodeType === 'ceiling') { + this.ceilings.delete(nodeId) + this.ceilingGrids.delete(nodeId) } else if (nodeType === 'wall') { this.walls.delete(nodeId) // Remove all items attached to this wall from the spatial grid @@ -295,6 +332,12 @@ export class SpatialGridManager { } else if (nodeType === 'item') { this.getFloorGrid(levelId).remove(nodeId) this.getWallGrid(levelId).removeByItemId(nodeId) + // Also clean up ceiling grid + const oldCeilingId = this.itemCeilingMap.get(nodeId) + if (oldCeilingId) { + this.getCeilingGrid(oldCeilingId).remove(nodeId) + this.itemCeilingMap.delete(nodeId) + } } return [] } @@ -438,6 +481,34 @@ export class SpatialGridManager { return maxElevation } + /** + * Check if an item can be placed on a ceiling. + * Validates that the footprint is within the ceiling polygon and doesn't overlap other ceiling items. + */ + canPlaceOnCeiling( + ceilingId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ignoreIds?: string[], + ): { valid: boolean; conflictIds: string[] } { + const ceiling = this.ceilings.get(ceilingId) + if (!ceiling || ceiling.polygon.length < 3) { + return { valid: false, conflictIds: [] } + } + + // Check that the item footprint is entirely within the ceiling polygon + const corners = getItemFootprint(position, dimensions, rotation) + for (const [cx, cz] of corners) { + if (!pointInPolygon(cx, cz, ceiling.polygon)) { + return { valid: false, conflictIds: [] } + } + } + + // Check for overlaps with other ceiling items + return this.getCeilingGrid(ceilingId).canPlace(position, dimensions, rotation, ignoreIds) + } + clearLevel(levelId: string) { this.floorGrids.delete(levelId) this.wallGrids.delete(levelId) @@ -449,6 +520,9 @@ export class SpatialGridManager { this.wallGrids.clear() this.walls.clear() this.slabsByLevel.clear() + this.ceilingGrids.clear() + this.ceilings.clear() + this.itemCeilingMap.clear() } } diff --git a/packages/core/src/hooks/spatial-grid/use-spatial-query.ts b/packages/core/src/hooks/spatial-grid/use-spatial-query.ts index 4b1dc0d6..2d8a82cc 100644 --- a/packages/core/src/hooks/spatial-grid/use-spatial-query.ts +++ b/packages/core/src/hooks/spatial-grid/use-spatial-query.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react' -import type { LevelNode, WallNode } from '../../schema' +import type { CeilingNode, LevelNode, WallNode } from '../../schema' import { spatialGridManager } from './spatial-grid-manager' export function useSpatialQuery() { @@ -41,5 +41,18 @@ export function useSpatialQuery() { [], ) - return { canPlaceOnFloor, canPlaceOnWall } + const canPlaceOnCeiling = useCallback( + ( + ceilingId: CeilingNode['id'], + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ignoreIds?: string[], + ) => { + return spatialGridManager.canPlaceOnCeiling(ceilingId, position, dimensions, rotation, ignoreIds) + }, + [], + ) + + return { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } }