From 29e7fa09efda232aa643ba983b0602ca046aa810 Mon Sep 17 00:00:00 2001 From: wass08 Date: Thu, 12 Feb 2026 10:41:37 +0900 Subject: [PATCH] slab holes --- .../tools/slab/slab-hole-editor.tsx | 47 ++++++ apps/editor/components/tools/tool-manager.tsx | 13 +- .../components/ui/panels/slab-panel.tsx | 144 +++++++++++++++++- apps/editor/store/use-editor.tsx | 5 + packages/core/src/schema/nodes/slab.ts | 1 + .../core/src/systems/slab/slab-system.tsx | 19 +++ 6 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 apps/editor/components/tools/slab/slab-hole-editor.tsx diff --git a/apps/editor/components/tools/slab/slab-hole-editor.tsx b/apps/editor/components/tools/slab/slab-hole-editor.tsx new file mode 100644 index 00000000..73bbb51a --- /dev/null +++ b/apps/editor/components/tools/slab/slab-hole-editor.tsx @@ -0,0 +1,47 @@ +import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useCallback } from 'react' +import { PolygonEditor } from '../shared/polygon-editor' + +interface SlabHoleEditorProps { + slabId: SlabNode['id'] + holeIndex: number +} + +/** + * Slab hole editor - allows editing a specific hole polygon within a slab + * Uses the generic PolygonEditor component + */ +export const SlabHoleEditor: React.FC = ({ slabId, holeIndex }) => { + const slabNode = useScene((state) => state.nodes[slabId]) + const updateNode = useScene((state) => state.updateNode) + const setSelection = useViewer((state) => state.setSelection) + + const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null + const holes = slab?.holes || [] + const hole = holes[holeIndex] + + const handlePolygonChange = useCallback( + (newPolygon: Array<[number, number]>) => { + const updatedHoles = [...holes] + updatedHoles[holeIndex] = newPolygon + updateNode(slabId, { holes: updatedHoles }) + // Re-assert selection so the slab stays selected after the edit + setSelection({ selectedIds: [slabId] }) + }, + [slabId, holeIndex, holes, updateNode, setSelection], + ) + + if (!slab || !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 a467c411..64dfd5c5 100644 --- a/apps/editor/components/tools/tool-manager.tsx +++ b/apps/editor/components/tools/tool-manager.tsx @@ -8,6 +8,7 @@ import { MoveTool } from './item/move-tool' import { RoofTool } from './roof/roof-tool' import { SiteBoundaryEditor } from './site/site-boundary-editor' import { SlabBoundaryEditor } from './slab/slab-boundary-editor' +import { SlabHoleEditor } from './slab/slab-hole-editor' import { SlabTool } from './slab/slab-tool' import { WallTool } from './wall/wall-tool' import { ZoneBoundaryEditor } from './zone/zone-boundary-editor' @@ -35,6 +36,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 selectedZoneId = useViewer((state) => state.selection.zoneId) const selectedIds = useViewer((state) => state.selection.selectedIds) const nodes = useScene((state) => state.nodes) @@ -52,9 +54,13 @@ export const ToolManager: React.FC = () => { // Show site boundary editor when in site phase and edit mode const showSiteBoundaryEditor = phase === 'site' && mode === 'edit' - // Show slab boundary editor when in structure/select mode with a slab selected + // 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 + phase === 'structure' && mode === 'select' && selectedSlabId !== undefined && editingSlabHoleIndex === null + + // Show slab hole editor when editing a specific hole + const showSlabHoleEditor = + selectedSlabId !== undefined && editingSlabHoleIndex !== null // Show ceiling boundary editor when in structure/select mode with a ceiling selected const showCeilingBoundaryEditor = @@ -79,6 +85,9 @@ export const ToolManager: React.FC = () => { {showSiteBoundaryEditor && } {showZoneBoundaryEditor && selectedZoneId && } {showSlabBoundaryEditor && selectedSlabId && } + {showSlabHoleEditor && selectedSlabId && editingSlabHoleIndex !== null && ( + + )} {showCeilingBoundaryEditor && selectedCeilingId && ( )} diff --git a/apps/editor/components/ui/panels/slab-panel.tsx b/apps/editor/components/ui/panels/slab-panel.tsx index 1c126b77..29815234 100644 --- a/apps/editor/components/ui/panels/slab-panel.tsx +++ b/apps/editor/components/ui/panels/slab-panel.tsx @@ -2,9 +2,10 @@ import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { X } from 'lucide-react' +import { Edit, Plus, Trash2, X } from 'lucide-react' import Image from 'next/image' -import { useCallback } from 'react' +import { useCallback, useEffect } from 'react' +import useEditor from '@/store/use-editor' import { NumberInput } from '@/components/ui/primitives/number-input' export function SlabPanel() { @@ -12,6 +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) // Get the first selected node if it's a slab const selectedId = selectedIds[0] @@ -29,7 +32,62 @@ export function SlabPanel() { const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) - }, [setSelection]) + setEditingHoleIndex(null) + }, [setSelection, setEditingHoleIndex]) + + // Clear hole editing state when slab is deselected + useEffect(() => { + if (!node) { + setEditingHoleIndex(null) + } + }, [node, setEditingHoleIndex]) + + const handleAddHole = useCallback(() => { + if (!node) return + + // Calculate centroid of the slab 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 slab'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 + setEditingHoleIndex(currentHoles.length) + }, [node, handleUpdate, setEditingHoleIndex]) + + const handleEditHole = useCallback( + (index: number) => { + setEditingHoleIndex(index) + }, + [setEditingHoleIndex], + ) + + const handleDeleteHole = useCallback( + (index: number) => { + const currentHoles = node?.holes || [] + const newHoles = currentHoles.filter((_, i) => i !== index) + handleUpdate({ holes: newHoles }) + if (editingHoleIndex === index) { + setEditingHoleIndex(null) + } + }, + [node?.holes, handleUpdate, editingHoleIndex], + ) // Only show if exactly one slab is selected if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null @@ -139,6 +197,86 @@ export function SlabPanel() { {area.toFixed(2)} m² + + {/* Holes */} +
+
+ + {editingHoleIndex !== null ? ( + + ) : ( + + )} +
+ {node.holes && node.holes.length > 0 ? ( +
+ {node.holes.map((hole, index) => { + const holeArea = calculateArea(hole) + const isEditing = editingHoleIndex === 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/store/use-editor.tsx b/apps/editor/store/use-editor.tsx index bd8dc6f1..952d669b 100644 --- a/apps/editor/store/use-editor.tsx +++ b/apps/editor/store/use-editor.tsx @@ -63,6 +63,9 @@ type EditorState = { // Space detection for cutaway mode spaces: Record setSpaces: (spaces: Record) => void + // Slab hole editing + editingSlabHoleIndex: number | null + setEditingSlabHoleIndex: (index: number | null) => void } const useEditor = create()((set, get) => ({ @@ -181,6 +184,8 @@ const useEditor = create()((set, get) => ({ setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), spaces: {}, setSpaces: (spaces) => set({ spaces }), + editingSlabHoleIndex: null, + setEditingSlabHoleIndex: (index) => set({ editingSlabHoleIndex: index }), })) export default useEditor diff --git a/packages/core/src/schema/nodes/slab.ts b/packages/core/src/schema/nodes/slab.ts index 7e051552..1edb28a8 100644 --- a/packages/core/src/schema/nodes/slab.ts +++ b/packages/core/src/schema/nodes/slab.ts @@ -8,6 +8,7 @@ export const SlabNode = BaseNode.extend({ // Specific props // Polygon boundary - array of [x, z] coordinates defining the slab polygon: z.array(z.tuple([z.number(), z.number()])), + holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), elevation: z.number().default(0.05), // Elevation in meters }).describe( dedent` diff --git a/packages/core/src/systems/slab/slab-system.tsx b/packages/core/src/systems/slab/slab-system.tsx index ff03cb12..a9cc1e6d 100644 --- a/packages/core/src/systems/slab/slab-system.tsx +++ b/packages/core/src/systems/slab/slab-system.tsx @@ -123,6 +123,25 @@ export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { } shape.closePath() + // Add holes to the shape + if (slabNode.holes && slabNode.holes.length > 0) { + for (const holePolygon of slabNode.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) + } + } + // Extrude the shape by elevation const geometry = new THREE.ExtrudeGeometry(shape, { depth: elevation,