slab holes
This commit is contained in:
@@ -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<SlabHoleEditorProps> = ({ 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 (
|
||||
<PolygonEditor
|
||||
polygon={hole}
|
||||
color="#ef4444" // red for holes
|
||||
onPolygonChange={handlePolygonChange}
|
||||
minVertices={3}
|
||||
levelId={resolveLevelId(slab, useScene.getState().nodes)}
|
||||
surfaceHeight={slab.elevation ?? 0.05}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 && <SiteBoundaryEditor />}
|
||||
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
|
||||
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
|
||||
{showSlabHoleEditor && selectedSlabId && editingSlabHoleIndex !== null && (
|
||||
<SlabHoleEditor slabId={selectedSlabId} holeIndex={editingSlabHoleIndex} />
|
||||
)}
|
||||
{showCeilingBoundaryEditor && selectedCeilingId && (
|
||||
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
|
||||
)}
|
||||
|
||||
@@ -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²
|
||||
</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>
|
||||
{editingHoleIndex !== null ? (
|
||||
<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={() => setEditingHoleIndex(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 = editingHoleIndex === 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>
|
||||
|
||||
@@ -63,6 +63,9 @@ type EditorState = {
|
||||
// Space detection for cutaway mode
|
||||
spaces: Record<string, Space>
|
||||
setSpaces: (spaces: Record<string, Space>) => void
|
||||
// Slab hole editing
|
||||
editingSlabHoleIndex: number | null
|
||||
setEditingSlabHoleIndex: (index: number | null) => void
|
||||
}
|
||||
|
||||
const useEditor = create<EditorState>()((set, get) => ({
|
||||
@@ -181,6 +184,8 @@ const useEditor = create<EditorState>()((set, get) => ({
|
||||
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
||||
spaces: {},
|
||||
setSpaces: (spaces) => set({ spaces }),
|
||||
editingSlabHoleIndex: null,
|
||||
setEditingSlabHoleIndex: (index) => set({ editingSlabHoleIndex: index }),
|
||||
}))
|
||||
|
||||
export default useEditor
|
||||
|
||||
Reference in New Issue
Block a user