Implement stair-driven slab hole cutouts
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user