ceiling holes
This commit is contained in:
@@ -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<CeilingHoleEditorProps> = ({ 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 (
|
||||||
|
<PolygonEditor
|
||||||
|
polygon={hole}
|
||||||
|
color="#ef4444" // red for holes
|
||||||
|
onPolygonChange={handlePolygonChange}
|
||||||
|
minVertices={3}
|
||||||
|
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
|
||||||
|
surfaceHeight={ceiling.height ?? 2.5}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { type AnyNodeId, type CeilingNode, type SlabNode, useScene } from '@pasc
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import useEditor, { type Phase, type Tool } from '@/store/use-editor'
|
import useEditor, { type Phase, type Tool } from '@/store/use-editor'
|
||||||
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
|
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
|
||||||
|
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
|
||||||
import { CeilingTool } from './ceiling/ceiling-tool'
|
import { CeilingTool } from './ceiling/ceiling-tool'
|
||||||
import { ItemTool } from './item/item-tool'
|
import { ItemTool } from './item/item-tool'
|
||||||
import { MoveTool } from './item/move-tool'
|
import { MoveTool } from './item/move-tool'
|
||||||
@@ -36,7 +37,7 @@ export const ToolManager: React.FC = () => {
|
|||||||
const mode = useEditor((state) => state.mode)
|
const mode = useEditor((state) => state.mode)
|
||||||
const tool = useEditor((state) => state.tool)
|
const tool = useEditor((state) => state.tool)
|
||||||
const movingNode = useEditor((state) => state.movingNode)
|
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 selectedZoneId = useViewer((state) => state.selection.zoneId)
|
||||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||||
const nodes = useScene((state) => state.nodes)
|
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)
|
// Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole)
|
||||||
const showSlabBoundaryEditor =
|
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 =
|
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 =
|
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
|
// Show zone boundary editor when in structure/select mode with a zone selected
|
||||||
// Hide when editing a slab or ceiling to avoid overlapping handles
|
// Hide when editing a slab or ceiling to avoid overlapping handles
|
||||||
@@ -85,12 +92,15 @@ export const ToolManager: React.FC = () => {
|
|||||||
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
||||||
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
|
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
|
||||||
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
|
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
|
||||||
{showSlabHoleEditor && selectedSlabId && editingSlabHoleIndex !== null && (
|
{showSlabHoleEditor && selectedSlabId && editingHole && (
|
||||||
<SlabHoleEditor slabId={selectedSlabId} holeIndex={editingSlabHoleIndex} />
|
<SlabHoleEditor slabId={selectedSlabId} holeIndex={editingHole.holeIndex} />
|
||||||
)}
|
)}
|
||||||
{showCeilingBoundaryEditor && selectedCeilingId && (
|
{showCeilingBoundaryEditor && selectedCeilingId && (
|
||||||
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
|
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
|
||||||
)}
|
)}
|
||||||
|
{showCeilingHoleEditor && selectedCeilingId && editingHole && (
|
||||||
|
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
|
||||||
|
)}
|
||||||
{movingNode && <MoveTool />}
|
{movingNode && <MoveTool />}
|
||||||
{!movingNode && BuildToolComponent && <BuildToolComponent />}
|
{!movingNode && BuildToolComponent && <BuildToolComponent />}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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<CeilingNode>) => {
|
||||||
|
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 (
|
||||||
|
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-72 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b p-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Image src="/icons/ceiling.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
||||||
|
<h2 className="font-semibold text-foreground text-sm truncate">
|
||||||
|
{node.name || `Ceiling (${area.toFixed(1)}m²)`}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Height */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Height
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<NumberInput
|
||||||
|
label="Height"
|
||||||
|
value={Math.round(node.height * 1000) / 1000}
|
||||||
|
onChange={(value) => {
|
||||||
|
handleUpdate({ height: value })
|
||||||
|
}}
|
||||||
|
precision={3}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Height from the floor where the ceiling is positioned
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick preset buttons */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Presets
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() => handleUpdate({ height: 2.4 })}
|
||||||
|
>
|
||||||
|
Low (2.4m)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() => handleUpdate({ height: 2.5 })}
|
||||||
|
>
|
||||||
|
Standard (2.5m)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() => handleUpdate({ height: 3.0 })}
|
||||||
|
>
|
||||||
|
High (3m)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Area info */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Area
|
||||||
|
</label>
|
||||||
|
<div className="rounded border border-border bg-muted/50 px-3 py-2 text-sm">
|
||||||
|
{area.toFixed(2)} m²
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Holes */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Holes
|
||||||
|
</label>
|
||||||
|
{editingHole?.nodeId === selectedId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 rounded border border-green-500 bg-green-500/10 px-2 py-1 text-xs text-green-600 hover:bg-green-500/20 cursor-pointer"
|
||||||
|
onClick={() => setEditingHole(null)}
|
||||||
|
>
|
||||||
|
<span>Done Editing</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={handleAddHole}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
<span>Add Hole</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{node.holes && node.holes.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{node.holes.map((hole, index) => {
|
||||||
|
const holeArea = calculateArea(hole)
|
||||||
|
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`flex items-center justify-between rounded border px-3 py-2 ${
|
||||||
|
isEditing
|
||||||
|
? 'border-green-500 bg-green-500/10'
|
||||||
|
: 'border-border bg-muted/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className={`text-sm font-medium ${isEditing ? 'text-green-600' : ''}`}>
|
||||||
|
Hole {index + 1} {isEditing && '(Editing)'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{holeArea.toFixed(2)} m² · {hole.length} vertices
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{!isEditing && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
||||||
|
onClick={() => handleEditHole(index)}
|
||||||
|
aria-label="Edit hole"
|
||||||
|
>
|
||||||
|
<Edit className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
||||||
|
onClick={() => handleDeleteHole(index)}
|
||||||
|
aria-label="Delete hole"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
No holes. Click "Add Hole" to create one.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { AnyNodeId, useScene } from '@pascal-app/core'
|
import { AnyNodeId, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import useEditor from '@/store/use-editor'
|
import useEditor from '@/store/use-editor'
|
||||||
|
import { CeilingPanel } from './ceiling-panel'
|
||||||
import { ItemPanel } from './item-panel'
|
import { ItemPanel } from './item-panel'
|
||||||
import { ReferencePanel } from './reference-panel'
|
import { ReferencePanel } from './reference-panel'
|
||||||
import { RoofPanel } from './roof-panel'
|
import { RoofPanel } from './roof-panel'
|
||||||
@@ -30,6 +31,8 @@ export function PanelManager() {
|
|||||||
return <RoofPanel />
|
return <RoofPanel />
|
||||||
case 'slab':
|
case 'slab':
|
||||||
return <SlabPanel />
|
return <SlabPanel />
|
||||||
|
case 'ceiling':
|
||||||
|
return <CeilingPanel />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export function SlabPanel() {
|
|||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const nodes = useScene((s) => s.nodes)
|
const nodes = useScene((s) => s.nodes)
|
||||||
const updateNode = useScene((s) => s.updateNode)
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
const editingHoleIndex = useEditor((s) => s.editingSlabHoleIndex)
|
const editingHole = useEditor((s) => s.editingHole)
|
||||||
const setEditingHoleIndex = useEditor((s) => s.setEditingSlabHoleIndex)
|
const setEditingHole = useEditor((s) => s.setEditingHole)
|
||||||
|
|
||||||
// Get the first selected node if it's a slab
|
// Get the first selected node if it's a slab
|
||||||
const selectedId = selectedIds[0]
|
const selectedId = selectedIds[0]
|
||||||
@@ -32,25 +32,25 @@ export function SlabPanel() {
|
|||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
setEditingHoleIndex(null)
|
setEditingHole(null)
|
||||||
}, [setSelection, setEditingHoleIndex])
|
}, [setSelection, setEditingHole])
|
||||||
|
|
||||||
// Clear hole editing state when slab is deselected
|
// Clear hole editing state when slab is deselected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
setEditingHoleIndex(null)
|
setEditingHole(null)
|
||||||
}
|
}
|
||||||
}, [node, setEditingHoleIndex])
|
}, [node, setEditingHole])
|
||||||
|
|
||||||
// Clear hole editing state on unmount
|
// Clear hole editing state on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
setEditingHoleIndex(null)
|
setEditingHole(null)
|
||||||
}
|
}
|
||||||
}, [setEditingHoleIndex])
|
}, [setEditingHole])
|
||||||
|
|
||||||
const handleAddHole = useCallback(() => {
|
const handleAddHole = useCallback(() => {
|
||||||
if (!node) return
|
if (!node || !selectedId) return
|
||||||
|
|
||||||
// Calculate centroid of the slab polygon
|
// Calculate centroid of the slab polygon
|
||||||
const polygon = node.polygon
|
const polygon = node.polygon
|
||||||
@@ -74,26 +74,28 @@ export function SlabPanel() {
|
|||||||
const currentHoles = node?.holes || []
|
const currentHoles = node?.holes || []
|
||||||
handleUpdate({ holes: [...currentHoles, newHole] })
|
handleUpdate({ holes: [...currentHoles, newHole] })
|
||||||
// Enter edit mode for the new hole
|
// Enter edit mode for the new hole
|
||||||
setEditingHoleIndex(currentHoles.length)
|
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
|
||||||
}, [node, handleUpdate, setEditingHoleIndex])
|
}, [node, selectedId, handleUpdate, setEditingHole])
|
||||||
|
|
||||||
const handleEditHole = useCallback(
|
const handleEditHole = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
setEditingHoleIndex(index)
|
if (!selectedId) return
|
||||||
|
setEditingHole({ nodeId: selectedId, holeIndex: index })
|
||||||
},
|
},
|
||||||
[setEditingHoleIndex],
|
[selectedId, setEditingHole],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDeleteHole = useCallback(
|
const handleDeleteHole = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
|
if (!selectedId) return
|
||||||
const currentHoles = node?.holes || []
|
const currentHoles = node?.holes || []
|
||||||
const newHoles = currentHoles.filter((_, i) => i !== index)
|
const newHoles = currentHoles.filter((_, i) => i !== index)
|
||||||
handleUpdate({ holes: newHoles })
|
handleUpdate({ holes: newHoles })
|
||||||
if (editingHoleIndex === index) {
|
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
|
||||||
setEditingHoleIndex(null)
|
setEditingHole(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[node?.holes, handleUpdate, editingHoleIndex, setEditingHoleIndex],
|
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Only show if exactly one slab is selected
|
// Only show if exactly one slab is selected
|
||||||
@@ -211,11 +213,11 @@ export function SlabPanel() {
|
|||||||
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
Holes
|
Holes
|
||||||
</label>
|
</label>
|
||||||
{editingHoleIndex !== null ? (
|
{editingHole?.nodeId === selectedId ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-1 rounded border border-green-500 bg-green-500/10 px-2 py-1 text-xs text-green-600 hover:bg-green-500/20 cursor-pointer"
|
className="flex items-center gap-1 rounded border border-green-500 bg-green-500/10 px-2 py-1 text-xs text-green-600 hover:bg-green-500/20 cursor-pointer"
|
||||||
onClick={() => setEditingHoleIndex(null)}
|
onClick={() => setEditingHole(null)}
|
||||||
>
|
>
|
||||||
<span>Done Editing</span>
|
<span>Done Editing</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -234,7 +236,7 @@ export function SlabPanel() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{node.holes.map((hole, index) => {
|
{node.holes.map((hole, index) => {
|
||||||
const holeArea = calculateArea(hole)
|
const holeArea = calculateArea(hole)
|
||||||
const isEditing = editingHoleIndex === index
|
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ type EditorState = {
|
|||||||
// Space detection for cutaway mode
|
// Space detection for cutaway mode
|
||||||
spaces: Record<string, Space>
|
spaces: Record<string, Space>
|
||||||
setSpaces: (spaces: Record<string, Space>) => void
|
setSpaces: (spaces: Record<string, Space>) => void
|
||||||
// Slab hole editing
|
// Generic hole editing (works for slabs, ceilings, and any future polygon nodes)
|
||||||
editingSlabHoleIndex: number | null
|
editingHole: { nodeId: string; holeIndex: number } | null
|
||||||
setEditingSlabHoleIndex: (index: number | null) => void
|
setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const useEditor = create<EditorState>()((set, get) => ({
|
const useEditor = create<EditorState>()((set, get) => ({
|
||||||
@@ -198,8 +198,8 @@ const useEditor = create<EditorState>()((set, get) => ({
|
|||||||
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
||||||
spaces: {},
|
spaces: {},
|
||||||
setSpaces: (spaces) => set({ spaces }),
|
setSpaces: (spaces) => set({ spaces }),
|
||||||
editingSlabHoleIndex: null,
|
editingHole: null,
|
||||||
setEditingSlabHoleIndex: (index) => set({ editingSlabHoleIndex: index }),
|
setEditingHole: (hole) => set({ editingHole: hole }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export default useEditor
|
export default useEditor
|
||||||
|
|||||||
@@ -566,7 +566,7 @@ export class SpatialGridManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an item can be placed on a ceiling.
|
* 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(
|
canPlaceOnCeiling(
|
||||||
ceilingId: string,
|
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
|
// Check for overlaps with other ceiling items
|
||||||
return this.getCeilingGrid(ceilingId).canPlace(position, dimensions, rotation, ignoreIds)
|
return this.getCeilingGrid(ceilingId).canPlace(position, dimensions, rotation, ignoreIds)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ export const CeilingNode = BaseNode.extend({
|
|||||||
// Specific props
|
// Specific props
|
||||||
// Polygon boundary - array of [x, z] coordinates defining the ceiling
|
// Polygon boundary - array of [x, z] coordinates defining the ceiling
|
||||||
polygon: z.array(z.tuple([z.number(), z.number()])),
|
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
|
height: z.number().default(2.5), // Height in meters
|
||||||
}).describe(
|
}).describe(
|
||||||
dedent`
|
dedent`
|
||||||
Ceiling node - used to represent a ceiling in the building
|
Ceiling node - used to represent a ceiling in the building
|
||||||
- polygon: array of [x, z] points defining the ceiling boundary
|
- polygon: array of [x, z] points defining the ceiling boundary
|
||||||
|
- holes: array of polygons representing holes in the ceiling
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,24 @@ export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferG
|
|||||||
}
|
}
|
||||||
shape.closePath()
|
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)
|
// Create flat shape geometry (no extrusion)
|
||||||
const geometry = new THREE.ShapeGeometry(shape)
|
const geometry = new THREE.ShapeGeometry(shape)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user