Implement stair-driven slab hole cutouts

This commit is contained in:
sudhir
2026-04-17 13:13:02 +05:30
parent 0effda29fe
commit b314a8fbf8
12 changed files with 824 additions and 15 deletions
@@ -356,8 +356,15 @@ export function FloatingActionMenu() {
[cx + holeSize, cz + holeSize],
[cx - holeSize, cz + holeSize],
]
const currentHoles = (node as SlabNode | CeilingNode).holes || []
updateNode(selectedId as AnyNodeId, { holes: [...currentHoles, newHole] })
const surfaceNode = node as SlabNode | CeilingNode
const currentHoles = surfaceNode.holes || []
const currentMetadata = currentHoles.map(
(_, index) => surfaceNode.holeMetadata?.[index] ?? { source: 'manual' as const },
)
updateNode(selectedId as AnyNodeId, {
holes: [...currentHoles, newHole],
holeMetadata: [...currentMetadata, { source: 'manual' }],
})
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
// Re-assert selection so the node stays selected
setSelection({ selectedIds: [selectedId] })
@@ -93,11 +93,21 @@ function commitStairPlacement(
position: [0, 0, 0],
})
const sortedLevels = Object.values(nodes)
.filter((node): node is LevelNode => node.type === 'level')
.sort((left, right) => left.level - right.level)
const currentLevelIndex = sortedLevels.findIndex((level) => level.id === levelId)
const nextLevelId = sortedLevels[currentLevelIndex + 1]?.id ?? levelId
const stair = StairNode.parse({
name,
position,
rotation,
stairType: DEFAULT_STAIR_TYPE,
fromLevelId: levelId,
toLevelId: nextLevelId,
slabOpeningMode: 'destination',
openingOffset: 0.08,
width: DEFAULT_STAIR_WIDTH,
totalRise: DEFAULT_STAIR_HEIGHT,
stepCount: DEFAULT_STAIR_STEP_COUNT,
@@ -86,7 +86,13 @@ export function CeilingPanel() {
[cx - holeSize, cz + holeSize],
]
const currentHoles = node?.holes || []
handleUpdate({ holes: [...currentHoles, newHole] })
const currentMetadata = currentHoles.map(
(_, index) => node?.holeMetadata?.[index] ?? { source: 'manual' as const },
)
handleUpdate({
holes: [...currentHoles, newHole],
holeMetadata: [...currentMetadata, { source: 'manual' }],
})
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
}, [node, selectedId, handleUpdate, setEditingHole])
@@ -102,13 +108,18 @@ export function CeilingPanel() {
(index: number) => {
if (!selectedId) return
const currentHoles = node?.holes || []
if (node?.holeMetadata?.[index]?.source === 'stair') return
const newHoles = currentHoles.filter((_, i) => i !== index)
handleUpdate({ holes: newHoles })
const currentMetadata = currentHoles.map(
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
)
const newMetadata = currentMetadata.filter((_, i) => i !== index)
handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
setEditingHole(null)
}
},
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
[selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole],
)
const handleMove = useCallback(() => {
@@ -126,8 +137,11 @@ export function CeilingPanel() {
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]
const current = polygon[i]
const next = polygon[j]
if (!(current && next)) continue
area += current[0] * next[1]
area -= next[0] * current[1]
}
return Math.abs(area) / 2
}
@@ -174,6 +188,8 @@ export function CeilingPanel() {
const holeArea = calculateArea(hole)
const isEditing =
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
const source = node.holeMetadata?.[index]?.source ?? 'manual'
const isAutoHole = source === 'stair'
return (
<div
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
@@ -190,7 +206,8 @@ export function CeilingPanel() {
Hole {index + 1} {isEditing && '(Editing)'}
</p>
<p className="text-[10px] text-muted-foreground">
{holeArea.toFixed(2)} m² · {hole.length} pts
{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
{isAutoHole ? 'Auto stair cutout' : 'Manual'}
</p>
</div>
<div className="flex items-center gap-1">
@@ -200,6 +217,10 @@ export function CeilingPanel() {
label="Done"
onClick={() => setEditingHole(null)}
/>
) : isAutoHole ? (
<div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
Auto
</div>
) : (
<>
<button
@@ -84,7 +84,13 @@ export function SlabPanel() {
[cx - holeSize, cz + holeSize],
]
const currentHoles = node?.holes || []
handleUpdate({ holes: [...currentHoles, newHole] })
const currentMetadata = currentHoles.map(
(_, index) => node?.holeMetadata?.[index] ?? { source: 'manual' as const },
)
handleUpdate({
holes: [...currentHoles, newHole],
holeMetadata: [...currentMetadata, { source: 'manual' }],
})
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
}, [node, selectedId, handleUpdate, setEditingHole])
@@ -100,13 +106,18 @@ export function SlabPanel() {
(index: number) => {
if (!selectedId) return
const currentHoles = node?.holes || []
if (node?.holeMetadata?.[index]?.source === 'stair') return
const newHoles = currentHoles.filter((_, i) => i !== index)
handleUpdate({ holes: newHoles })
const currentMetadata = currentHoles.map(
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
)
const newMetadata = currentMetadata.filter((_, i) => i !== index)
handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
setEditingHole(null)
}
},
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
[selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole],
)
const handleMove = useCallback(() => {
@@ -124,8 +135,11 @@ export function SlabPanel() {
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]
const current = polygon[i]
const next = polygon[j]
if (!(current && next)) continue
area += current[0] * next[1]
area -= next[0] * current[1]
}
return Math.abs(area) / 2
}
@@ -173,6 +187,8 @@ export function SlabPanel() {
const holeArea = calculateArea(hole)
const isEditing =
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
const source = node.holeMetadata?.[index]?.source ?? 'manual'
const isAutoHole = source === 'stair'
return (
<div
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
@@ -189,7 +205,8 @@ export function SlabPanel() {
Hole {index + 1} {isEditing && '(Editing)'}
</p>
<p className="text-[10px] text-muted-foreground">
{holeArea.toFixed(2)} m² · {hole.length} pts
{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
{isAutoHole ? 'Auto stair cutout' : 'Manual'}
</p>
</div>
<div className="flex items-center gap-1">
@@ -199,6 +216,10 @@ export function SlabPanel() {
label="Done"
onClick={() => setEditingHole(null)}
/>
) : isAutoHole ? (
<div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
Auto
</div>
) : (
<>
<button
@@ -3,9 +3,11 @@
import {
type AnyNode,
type AnyNodeId,
type LevelNode,
type MaterialSchema,
type StairNode,
type StairRailingMode,
type StairSlabOpeningMode,
type StairTopLandingMode,
type StairType,
StairNode as StairNodeSchema,
@@ -21,6 +23,7 @@ import useEditor from '../../../store/use-editor'
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
@@ -45,6 +48,11 @@ const TOP_LANDING_MODE_OPTIONS: { label: string; value: StairTopLandingMode }[]
{ label: 'Integrated', value: 'integrated' },
]
const STAIR_SLAB_OPENING_OPTIONS: { label: string; value: StairSlabOpeningMode }[] = [
{ label: 'None', value: 'none' },
{ label: 'Destination', value: 'destination' },
]
export function StairPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
@@ -202,6 +210,11 @@ export function StairPanel() {
if (!node || node.type !== 'stair' || selectedIds.length !== 1) return null
const levels = Object.values(nodes)
.filter((entry): entry is LevelNode => entry.type === 'level')
.sort((left, right) => left.level - right.level)
const resolvedFromLevelId = node.fromLevelId ?? node.parentId ?? levels[0]?.id ?? null
const resolvedToLevelId = node.toLevelId ?? resolvedFromLevelId
const segments = (node.children ?? [])
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
@@ -231,6 +244,63 @@ export function StairPanel() {
/>
</PanelSection>
<PanelSection title="Opening">
<div className="space-y-3">
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
From Level
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) => handleUpdate({ fromLevelId: event.target.value })}
value={resolvedFromLevelId ?? ''}
>
{levels.map((level) => (
<option key={level.id} value={level.id}>
{level.name || `Level ${level.level + 1}`}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
To Level
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) => handleUpdate({ toLevelId: event.target.value })}
value={resolvedToLevelId ?? ''}
>
{levels.map((level) => (
<option key={level.id} value={level.id}>
{level.name || `Level ${level.level + 1}`}
</option>
))}
</select>
</div>
<SegmentedControl
onChange={(value) => handleUpdate({ slabOpeningMode: value as StairSlabOpeningMode })}
options={STAIR_SLAB_OPENING_OPTIONS}
value={node.slabOpeningMode ?? 'none'}
/>
{(node.slabOpeningMode ?? 'none') === 'destination' ? (
<MetricControl
label="Opening Offset"
max={0.5}
min={0}
onChange={(value) => handleUpdate({ openingOffset: value })}
precision={2}
step={0.01}
unit="m"
value={Math.round((node.openingOffset ?? 0) * 100) / 100}
/>
) : null}
</div>
</PanelSection>
{node.stairType === 'straight' && (
<PanelSection title="Segments">
<div className="flex flex-col gap-1">