From 13070a56306fd1af97a063d06a8ac11ff75d965d Mon Sep 17 00:00:00 2001 From: wass08 Date: Mon, 16 Feb 2026 08:59:28 +0900 Subject: [PATCH] ceiling holes --- .../tools/ceiling/ceiling-hole-editor.tsx | 47 +++ apps/editor/components/tools/tool-manager.tsx | 26 +- .../components/ui/panels/ceiling-panel.tsx | 286 ++++++++++++++++++ .../components/ui/panels/panel-manager.tsx | 3 + .../components/ui/panels/slab-panel.tsx | 40 +-- apps/editor/store/use-editor.tsx | 10 +- .../spatial-grid/spatial-grid-manager.ts | 11 +- packages/core/src/schema/nodes/ceiling.ts | 2 + .../src/systems/ceiling/ceiling-system.tsx | 18 ++ 9 files changed, 410 insertions(+), 33 deletions(-) create mode 100644 apps/editor/components/tools/ceiling/ceiling-hole-editor.tsx create mode 100644 apps/editor/components/ui/panels/ceiling-panel.tsx diff --git a/apps/editor/components/tools/ceiling/ceiling-hole-editor.tsx b/apps/editor/components/tools/ceiling/ceiling-hole-editor.tsx new file mode 100644 index 00000000..17c65086 --- /dev/null +++ b/apps/editor/components/tools/ceiling/ceiling-hole-editor.tsx @@ -0,0 +1,47 @@ +import { resolveLevelId, type CeilingNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useCallback } from 'react' +import { PolygonEditor } from '../shared/polygon-editor' + +interface CeilingHoleEditorProps { + ceilingId: CeilingNode['id'] + holeIndex: number +} + +/** + * Ceiling hole editor - allows editing a specific hole polygon within a ceiling + * Uses the generic PolygonEditor component + */ +export const CeilingHoleEditor: React.FC = ({ ceilingId, holeIndex }) => { + const ceilingNode = useScene((state) => state.nodes[ceilingId]) + const updateNode = useScene((state) => state.updateNode) + const setSelection = useViewer((state) => state.setSelection) + + const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null + const holes = ceiling?.holes || [] + const hole = holes[holeIndex] + + const handlePolygonChange = useCallback( + (newPolygon: Array<[number, number]>) => { + const updatedHoles = [...holes] + updatedHoles[holeIndex] = newPolygon + updateNode(ceilingId, { holes: updatedHoles }) + // Re-assert selection so the ceiling stays selected after the edit + setSelection({ selectedIds: [ceilingId] }) + }, + [ceilingId, holeIndex, holes, updateNode, setSelection], + ) + + if (!ceiling || !hole || hole.length < 3) return null + + return ( + + ) +} diff --git a/apps/editor/components/tools/tool-manager.tsx b/apps/editor/components/tools/tool-manager.tsx index 64dfd5c5..5a5ec72c 100644 --- a/apps/editor/components/tools/tool-manager.tsx +++ b/apps/editor/components/tools/tool-manager.tsx @@ -2,6 +2,7 @@ import { type AnyNodeId, type CeilingNode, type SlabNode, useScene } from '@pasc import { useViewer } from '@pascal-app/viewer' import useEditor, { type Phase, type Tool } from '@/store/use-editor' import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor' +import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor' import { CeilingTool } from './ceiling/ceiling-tool' import { ItemTool } from './item/item-tool' import { MoveTool } from './item/move-tool' @@ -36,7 +37,7 @@ export const ToolManager: React.FC = () => { const mode = useEditor((state) => state.mode) const tool = useEditor((state) => state.tool) const movingNode = useEditor((state) => state.movingNode) - const editingSlabHoleIndex = useEditor((state) => state.editingSlabHoleIndex) + const editingHole = useEditor((state) => state.editingHole) const selectedZoneId = useViewer((state) => state.selection.zoneId) const selectedIds = useViewer((state) => state.selection.selectedIds) const nodes = useScene((state) => state.nodes) @@ -56,15 +57,21 @@ export const ToolManager: React.FC = () => { // Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole) const showSlabBoundaryEditor = - phase === 'structure' && mode === 'select' && selectedSlabId !== undefined && editingSlabHoleIndex === null + phase === 'structure' && mode === 'select' && selectedSlabId !== undefined && + (!editingHole || editingHole.nodeId !== selectedSlabId) - // Show slab hole editor when editing a specific hole + // Show slab hole editor when editing a hole on the selected slab const showSlabHoleEditor = - selectedSlabId !== undefined && editingSlabHoleIndex !== null + selectedSlabId !== undefined && editingHole !== null && editingHole.nodeId === selectedSlabId - // Show ceiling boundary editor when in structure/select mode with a ceiling selected + // Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole) const showCeilingBoundaryEditor = - phase === 'structure' && mode === 'select' && selectedCeilingId !== undefined + phase === 'structure' && mode === 'select' && selectedCeilingId !== undefined && + (!editingHole || editingHole.nodeId !== selectedCeilingId) + + // Show ceiling hole editor when editing a hole on the selected ceiling + const showCeilingHoleEditor = + selectedCeilingId !== undefined && editingHole !== null && editingHole.nodeId === selectedCeilingId // Show zone boundary editor when in structure/select mode with a zone selected // Hide when editing a slab or ceiling to avoid overlapping handles @@ -85,12 +92,15 @@ export const ToolManager: React.FC = () => { {showSiteBoundaryEditor && } {showZoneBoundaryEditor && selectedZoneId && } {showSlabBoundaryEditor && selectedSlabId && } - {showSlabHoleEditor && selectedSlabId && editingSlabHoleIndex !== null && ( - + {showSlabHoleEditor && selectedSlabId && editingHole && ( + )} {showCeilingBoundaryEditor && selectedCeilingId && ( )} + {showCeilingHoleEditor && selectedCeilingId && editingHole && ( + + )} {movingNode && } {!movingNode && BuildToolComponent && } diff --git a/apps/editor/components/ui/panels/ceiling-panel.tsx b/apps/editor/components/ui/panels/ceiling-panel.tsx new file mode 100644 index 00000000..96b1db73 --- /dev/null +++ b/apps/editor/components/ui/panels/ceiling-panel.tsx @@ -0,0 +1,286 @@ +'use client' + +import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Edit, Plus, Trash2, X } from 'lucide-react' +import Image from 'next/image' +import { useCallback, useEffect } from 'react' +import useEditor from '@/store/use-editor' +import { NumberInput } from '@/components/ui/primitives/number-input' + +export function CeilingPanel() { + const selectedIds = useViewer((s) => s.selection.selectedIds) + const setSelection = useViewer((s) => s.setSelection) + const nodes = useScene((s) => s.nodes) + const updateNode = useScene((s) => s.updateNode) + const editingHole = useEditor((s) => s.editingHole) + const setEditingHole = useEditor((s) => s.setEditingHole) + + // Get the first selected node if it's a ceiling + const selectedId = selectedIds[0] + const node = selectedId + ? (nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) + : undefined + + const handleUpdate = useCallback( + (updates: Partial) => { + if (!selectedId) return + updateNode(selectedId as AnyNode['id'], updates) + }, + [selectedId, updateNode], + ) + + const handleClose = useCallback(() => { + setSelection({ selectedIds: [] }) + setEditingHole(null) + }, [setSelection, setEditingHole]) + + // Clear hole editing state when ceiling is deselected + useEffect(() => { + if (!node) { + setEditingHole(null) + } + }, [node, setEditingHole]) + + // Clear hole editing state on unmount + useEffect(() => { + return () => { + setEditingHole(null) + } + }, [setEditingHole]) + + const handleAddHole = useCallback(() => { + if (!node || !selectedId) return + + // Calculate centroid of the ceiling polygon + const polygon = node.polygon + let cx = 0 + let cz = 0 + for (const [x, z] of polygon) { + cx += x + cz += z + } + cx /= polygon.length + cz /= polygon.length + + // Create a default small rectangular hole centered at the ceiling's centroid + const holeSize = 0.5 + const newHole: Array<[number, number]> = [ + [cx - holeSize, cz - holeSize], + [cx + holeSize, cz - holeSize], + [cx + holeSize, cz + holeSize], + [cx - holeSize, cz + holeSize], + ] + const currentHoles = node?.holes || [] + handleUpdate({ holes: [...currentHoles, newHole] }) + // Enter edit mode for the new hole + setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length }) + }, [node, selectedId, handleUpdate, setEditingHole]) + + const handleEditHole = useCallback( + (index: number) => { + if (!selectedId) return + setEditingHole({ nodeId: selectedId, holeIndex: index }) + }, + [selectedId, setEditingHole], + ) + + const handleDeleteHole = useCallback( + (index: number) => { + if (!selectedId) return + const currentHoles = node?.holes || [] + const newHoles = currentHoles.filter((_, i) => i !== index) + handleUpdate({ holes: newHoles }) + if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) { + setEditingHole(null) + } + }, + [selectedId, node?.holes, handleUpdate, editingHole, setEditingHole], + ) + + // Only show if exactly one ceiling is selected + if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null + + // Calculate approximate area from polygon + const calculateArea = (polygon: Array<[number, number]>): number => { + if (polygon.length < 3) return 0 + let area = 0 + const n = polygon.length + for (let i = 0; i < n; i++) { + const j = (i + 1) % n + area += polygon[i]![0] * polygon[j]![1] + area -= polygon[j]![0] * polygon[i]![1] + } + return Math.abs(area) / 2 + } + + const area = calculateArea(node.polygon) + + return ( +
+ {/* Header */} +
+
+ +

+ {node.name || `Ceiling (${area.toFixed(1)}m²)`} +

+
+ +
+ + {/* Content */} +
+
+ {/* Height */} +
+ +
+ { + handleUpdate({ height: value }) + }} + precision={3} + className="flex-1" + /> + m +
+

+ Height from the floor where the ceiling is positioned +

+
+ + {/* Quick preset buttons */} +
+ +
+ + + +
+
+ + {/* Area info */} +
+ +
+ {area.toFixed(2)} m² +
+
+ + {/* Holes */} +
+
+ + {editingHole?.nodeId === selectedId ? ( + + ) : ( + + )} +
+ {node.holes && node.holes.length > 0 ? ( +
+ {node.holes.map((hole, index) => { + const holeArea = calculateArea(hole) + const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index + return ( +
+
+

+ Hole {index + 1} {isEditing && '(Editing)'} +

+

+ {holeArea.toFixed(2)} m² · {hole.length} vertices +

+
+
+ {!isEditing && ( + <> + + + + )} +
+
+ ) + })} +
+ ) : ( +

+ No holes. Click "Add Hole" to create one. +

+ )} +
+
+
+
+ ) +} diff --git a/apps/editor/components/ui/panels/panel-manager.tsx b/apps/editor/components/ui/panels/panel-manager.tsx index f5a6951a..afef33ad 100644 --- a/apps/editor/components/ui/panels/panel-manager.tsx +++ b/apps/editor/components/ui/panels/panel-manager.tsx @@ -3,6 +3,7 @@ import { AnyNodeId, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import useEditor from '@/store/use-editor' +import { CeilingPanel } from './ceiling-panel' import { ItemPanel } from './item-panel' import { ReferencePanel } from './reference-panel' import { RoofPanel } from './roof-panel' @@ -30,6 +31,8 @@ export function PanelManager() { return case 'slab': return + case 'ceiling': + return } } } diff --git a/apps/editor/components/ui/panels/slab-panel.tsx b/apps/editor/components/ui/panels/slab-panel.tsx index e5490d8e..a78bd41c 100644 --- a/apps/editor/components/ui/panels/slab-panel.tsx +++ b/apps/editor/components/ui/panels/slab-panel.tsx @@ -13,8 +13,8 @@ export function SlabPanel() { const setSelection = useViewer((s) => s.setSelection) const nodes = useScene((s) => s.nodes) const updateNode = useScene((s) => s.updateNode) - const editingHoleIndex = useEditor((s) => s.editingSlabHoleIndex) - const setEditingHoleIndex = useEditor((s) => s.setEditingSlabHoleIndex) + const editingHole = useEditor((s) => s.editingHole) + const setEditingHole = useEditor((s) => s.setEditingHole) // Get the first selected node if it's a slab const selectedId = selectedIds[0] @@ -32,25 +32,25 @@ export function SlabPanel() { const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) - setEditingHoleIndex(null) - }, [setSelection, setEditingHoleIndex]) + setEditingHole(null) + }, [setSelection, setEditingHole]) // Clear hole editing state when slab is deselected useEffect(() => { if (!node) { - setEditingHoleIndex(null) + setEditingHole(null) } - }, [node, setEditingHoleIndex]) + }, [node, setEditingHole]) // Clear hole editing state on unmount useEffect(() => { return () => { - setEditingHoleIndex(null) + setEditingHole(null) } - }, [setEditingHoleIndex]) + }, [setEditingHole]) const handleAddHole = useCallback(() => { - if (!node) return + if (!node || !selectedId) return // Calculate centroid of the slab polygon const polygon = node.polygon @@ -74,26 +74,28 @@ export function SlabPanel() { const currentHoles = node?.holes || [] handleUpdate({ holes: [...currentHoles, newHole] }) // Enter edit mode for the new hole - setEditingHoleIndex(currentHoles.length) - }, [node, handleUpdate, setEditingHoleIndex]) + setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length }) + }, [node, selectedId, handleUpdate, setEditingHole]) const handleEditHole = useCallback( (index: number) => { - setEditingHoleIndex(index) + if (!selectedId) return + setEditingHole({ nodeId: selectedId, holeIndex: index }) }, - [setEditingHoleIndex], + [selectedId, setEditingHole], ) const handleDeleteHole = useCallback( (index: number) => { + if (!selectedId) return const currentHoles = node?.holes || [] const newHoles = currentHoles.filter((_, i) => i !== index) handleUpdate({ holes: newHoles }) - if (editingHoleIndex === index) { - setEditingHoleIndex(null) + if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) { + setEditingHole(null) } }, - [node?.holes, handleUpdate, editingHoleIndex, setEditingHoleIndex], + [selectedId, node?.holes, handleUpdate, editingHole, setEditingHole], ) // Only show if exactly one slab is selected @@ -211,11 +213,11 @@ export function SlabPanel() { - {editingHoleIndex !== null ? ( + {editingHole?.nodeId === selectedId ? ( @@ -234,7 +236,7 @@ export function SlabPanel() {
{node.holes.map((hole, index) => { const holeArea = calculateArea(hole) - const isEditing = editingHoleIndex === index + const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index return (
setSpaces: (spaces: Record) => void - // Slab hole editing - editingSlabHoleIndex: number | null - setEditingSlabHoleIndex: (index: number | null) => void + // Generic hole editing (works for slabs, ceilings, and any future polygon nodes) + editingHole: { nodeId: string; holeIndex: number } | null + setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void } const useEditor = create()((set, get) => ({ @@ -198,8 +198,8 @@ const useEditor = create()((set, get) => ({ setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), spaces: {}, setSpaces: (spaces) => set({ spaces }), - editingSlabHoleIndex: null, - setEditingSlabHoleIndex: (index) => set({ editingSlabHoleIndex: index }), + editingHole: null, + setEditingHole: (hole) => set({ editingHole: hole }), })) export default useEditor 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 695b46e2..f58893f6 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -566,7 +566,7 @@ export class SpatialGridManager { /** * 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. + * Validates that the footprint is within the ceiling polygon (but not in any holes) and doesn't overlap other ceiling items. */ canPlaceOnCeiling( ceilingId: string, @@ -588,6 +588,15 @@ export class SpatialGridManager { } } + // Check if item center is in any hole (if so, it cannot be placed) + const [centerX, , centerZ] = position + const holes = ceiling.holes || [] + for (const hole of holes) { + if (hole.length >= 3 && pointInPolygon(centerX, centerZ, hole)) { + return { valid: false, conflictIds: [] } + } + } + // Check for overlaps with other ceiling items return this.getCeilingGrid(ceilingId).canPlace(position, dimensions, rotation, ignoreIds) } diff --git a/packages/core/src/schema/nodes/ceiling.ts b/packages/core/src/schema/nodes/ceiling.ts index e9dc14b6..b6e76787 100644 --- a/packages/core/src/schema/nodes/ceiling.ts +++ b/packages/core/src/schema/nodes/ceiling.ts @@ -10,11 +10,13 @@ export const CeilingNode = BaseNode.extend({ // Specific props // Polygon boundary - array of [x, z] coordinates defining the ceiling polygon: z.array(z.tuple([z.number(), z.number()])), + holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), height: z.number().default(2.5), // Height in meters }).describe( dedent` Ceiling node - used to represent a ceiling in the building - polygon: array of [x, z] points defining the ceiling boundary + - holes: array of polygons representing holes in the ceiling `, ) diff --git a/packages/core/src/systems/ceiling/ceiling-system.tsx b/packages/core/src/systems/ceiling/ceiling-system.tsx index 10324d2f..85cb598a 100644 --- a/packages/core/src/systems/ceiling/ceiling-system.tsx +++ b/packages/core/src/systems/ceiling/ceiling-system.tsx @@ -70,6 +70,24 @@ export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferG } shape.closePath() + // Add holes to the shape + const holes = ceilingNode.holes || [] + for (const holePolygon of holes) { + if (holePolygon.length < 3) continue + + const holePath = new THREE.Path() + const holeFirstPt = holePolygon[0]! + holePath.moveTo(holeFirstPt[0], -holeFirstPt[1]) + + for (let i = 1; i < holePolygon.length; i++) { + const pt = holePolygon[i]! + holePath.lineTo(pt[0], -pt[1]) + } + holePath.closePath() + + shape.holes.push(holePath) + } + // Create flat shape geometry (no extrusion) const geometry = new THREE.ShapeGeometry(shape)