working ceiling items

This commit is contained in:
wass08
2026-01-28 06:55:56 +09:00
parent 6bd34762e5
commit e27028a29a
4 changed files with 116 additions and 11 deletions
@@ -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<ItemNode | null>(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) {
@@ -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={<Square className="w-3.5 h-3.5" />}
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={<TreeNodeActions node={node} />}
/>
>
{node.children.map((childId) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
))}
</TreeNodeWrapper>
);
}
@@ -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<string, WallSpatialGrid>() // levelId -> wall grid
private walls = new Map<string, WallNode>() // wallId -> wall data (for length calculations)
private slabsByLevel = new Map<string, Map<string, SlabNode>>() // levelId -> (slabId -> slab)
private ceilingGrids = new Map<string, SpatialGrid>() // ceilingId -> grid
private ceilings = new Map<string, CeilingNode>() // ceilingId -> ceiling data
private itemCeilingMap = new Map<string, string>() // 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<string, SlabNode> {
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()
}
}
@@ -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 }
}