Fix stair opening previews and slab cutouts

This commit is contained in:
Aymeric Rabot
2026-06-07 00:46:51 -04:00
parent 21b79c0d62
commit 03f57b1f4f
21 changed files with 1766 additions and 195 deletions
@@ -0,0 +1,43 @@
import { describe, expect, test } from 'bun:test'
import { pointInPolygon2D, SlabNode } from '@pascal-app/core'
import { slabDefinition } from '../definition'
function getHeightHandlePosition(slab: SlabNode) {
const handles =
typeof slabDefinition.handles === 'function'
? slabDefinition.handles(slab)
: (slabDefinition.handles ?? [])
const heightHandle = handles.find(
(handle) => handle.kind === 'linear-resize' && handle.axis === 'y',
)
if (!(heightHandle && heightHandle.kind === 'linear-resize')) {
throw new Error('Missing slab height handle')
}
return heightHandle.placement.position(slab, {} as never)
}
describe('slabDefinition handles', () => {
test('keeps the height handle over solid slab area when the center is a hole', () => {
const slab = SlabNode.parse({
polygon: [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
],
holes: [
[
[1, 1],
[3, 1],
[3, 3],
[1, 3],
],
],
})
const [x, , z] = getHeightHandlePosition(slab)
expect(pointInPolygon2D([x, z], slab.polygon, { includeBoundary: false })).toBe(true)
expect(pointInPolygon2D([x, z], slab.holes[0]!, { includeBoundary: true })).toBe(false)
})
})
+70 -9
View File
@@ -1,4 +1,9 @@
import type { HandleDescriptor, NodeDefinition, SlabNode as SlabNodeType } from '@pascal-app/core'
import {
type HandleDescriptor,
type NodeDefinition,
pointInPolygon2D,
type SlabNode as SlabNodeType,
} from '@pascal-app/core'
import { buildSlabFloorplan } from './floorplan'
import {
slabAddVertexAffordance,
@@ -13,8 +18,7 @@ import { SlabNode } from './schema'
const HEIGHT_HANDLE_OFFSET = 0.22
const MIN_SLAB_ELEVATION = 0.02
function slabPolygonCenter(n: SlabNodeType): [number, number] {
const polygon = n.polygon ?? []
function polygonVertexAverage(polygon: SlabNodeType['polygon']): [number, number] {
if (polygon.length === 0) return [0, 0]
let cx = 0
let cz = 0
@@ -25,11 +29,68 @@ function slabPolygonCenter(n: SlabNodeType): [number, number] {
return [cx / polygon.length, cz / polygon.length]
}
// Slab height arrow — vertical chevron at the polygon centroid, just
// above the slab's top face. Drags elevation (the extrusion thickness)
// with `anchor: 'min'` so the bottom stays at world Y=0 and the top
// follows the pointer. Same registry-handle pipeline as the column
// height arrow, so live override + commit-on-release come for free.
function pointIsOnSolidSlab(point: [number, number], slab: SlabNodeType) {
if (!pointInPolygon2D(point, slab.polygon, { includeBoundary: false })) return false
return !(slab.holes ?? []).some(
(hole) => hole.length >= 3 && pointInPolygon2D(point, hole, { includeBoundary: true }),
)
}
function slabHandleAnchor(slab: SlabNodeType): [number, number] {
const polygon = slab.polygon ?? []
const fallback = polygonVertexAverage(polygon)
if (polygon.length < 3) return fallback
if (pointIsOnSolidSlab(fallback, slab)) return fallback
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minZ = Number.POSITIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
for (const [x, z] of polygon) {
minX = Math.min(minX, x)
maxX = Math.max(maxX, x)
minZ = Math.min(minZ, z)
maxZ = Math.max(maxZ, z)
}
const candidates: [number, number][] = []
for (const point of polygon) {
candidates.push([
fallback[0] + (point[0] - fallback[0]) * 0.35,
fallback[1] + (point[1] - fallback[1]) * 0.35,
])
}
const steps = 12
for (let xi = 1; xi < steps; xi += 1) {
const x = minX + ((maxX - minX) * xi) / steps
for (let zi = 1; zi < steps; zi += 1) {
const z = minZ + ((maxZ - minZ) * zi) / steps
candidates.push([x, z])
}
}
let best: [number, number] | null = null
let bestDistance = Number.POSITIVE_INFINITY
for (const candidate of candidates) {
if (!pointIsOnSolidSlab(candidate, slab)) continue
const dx = candidate[0] - fallback[0]
const dz = candidate[1] - fallback[1]
const distance = dx * dx + dz * dz
if (distance < bestDistance) {
best = candidate
bestDistance = distance
}
}
return best ?? fallback
}
// Slab height arrow — vertical chevron on solid slab surface near the
// polygon center. Drags elevation (the extrusion thickness) with
// `anchor: 'min'` so the bottom stays at world Y=0 and the top follows
// the pointer. Same registry-handle pipeline as the column height arrow,
// so live override + commit-on-release come for free.
function slabHeightHandle(): HandleDescriptor<SlabNodeType> {
return {
kind: 'linear-resize',
@@ -40,7 +101,7 @@ function slabHeightHandle(): HandleDescriptor<SlabNodeType> {
apply: (_n, newValue) => ({ elevation: newValue }),
placement: {
position: (n) => {
const [cx, cz] = slabPolygonCenter(n)
const [cx, cz] = slabHandleAnchor(n)
const elevation = n.elevation ?? 0.05
return [cx, elevation + HEIGHT_HANDLE_OFFSET, cz]
},
+49 -16
View File
@@ -18,9 +18,13 @@ import {
ActionGroup,
DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE,
duplicateStairSubtree,
getStairLevelOptions,
MetricControl,
PanelSection,
PanelWrapper,
resolveStairDestinationLevel,
resolveStairFromLevelId,
resolveStairToLevelId,
SegmentedControl,
SliderControl,
ToggleControl,
@@ -29,7 +33,7 @@ import {
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { useCallback, useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
@@ -62,16 +66,14 @@ export default function StairPanel() {
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const nodes = useScene((s) => s.nodes)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
)
const levels = useScene(
useShallow((s) =>
Object.values(s.nodes)
.filter((entry): entry is LevelNode => entry.type === 'level')
.sort((left, right) => left.level - right.level),
),
const levels = useMemo<LevelNode[]>(
() => (node?.type === 'stair' ? getStairLevelOptions(nodes, node) : []),
[node, nodes],
)
const segments = useScene(
useShallow((s) => {
@@ -96,6 +98,41 @@ export default function StairPanel() {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleAutoCutoutChange = useCallback(
(checked: boolean) => {
if (!node) return
const updates: Partial<StairNode> = {
slabOpeningMode: checked ? 'destination' : 'none',
}
const sceneNodes = useScene.getState().nodes
const fromLevelId = resolveStairFromLevelId(sceneNodes, node)
if (checked && fromLevelId) updates.fromLevelId = fromLevelId
if (checked && (!node.toLevelId || node.toLevelId === fromLevelId)) {
const plan = resolveStairDestinationLevel({
fromLevelId,
nodes: sceneNodes,
})
if (plan?.toLevel.id) updates.toLevelId = plan.toLevel.id
}
handleUpdate(updates)
},
[node, handleUpdate],
)
const handleFromLevelChange = useCallback(
(fromLevelId: string) => {
const plan = resolveStairDestinationLevel({
fromLevelId: fromLevelId as AnyNodeId,
nodes: useScene.getState().nodes,
})
handleUpdate({
fromLevelId,
toLevelId: plan?.toLevel.id ?? fromLevelId,
})
},
[handleUpdate],
)
const getLastSegmentFillDefaults = useCallback(() => {
if (!node) return { fillToFloor: true }
const children = node.children ?? []
@@ -184,8 +221,8 @@ export default function StairPanel() {
if (!(node && node.type === 'stair' && selectedId && selectedCount === 1)) return null
const resolvedFromLevelId = node.fromLevelId ?? node.parentId ?? levels[0]?.id ?? null
const resolvedToLevelId = node.toLevelId ?? resolvedFromLevelId
const resolvedFromLevelId = resolveStairFromLevelId(nodes, node, levels)
const resolvedToLevelId = resolveStairToLevelId(nodes, node, resolvedFromLevelId, levels)
return (
<PanelWrapper
@@ -217,11 +254,7 @@ export default function StairPanel() {
<ToggleControl
checked={(node.slabOpeningMode ?? 'none') === 'destination'}
label="Auto Cutout"
onChange={(checked) =>
handleUpdate({
slabOpeningMode: checked ? 'destination' : 'none',
})
}
onChange={handleAutoCutoutChange}
/>
<div className="space-y-1.5">
@@ -230,7 +263,7 @@ export default function StairPanel() {
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm"
onChange={(event) => handleUpdate({ fromLevelId: event.target.value })}
onChange={(event) => handleFromLevelChange(event.target.value)}
value={resolvedFromLevelId ?? ''}
>
{levels.map((level) => (
@@ -259,7 +292,7 @@ export default function StairPanel() {
</div>
<SegmentedControl
onChange={(value) => handleUpdate({ slabOpeningMode: value as StairSlabOpeningMode })}
onChange={(value) => handleAutoCutoutChange(value === 'destination')}
options={STAIR_SLAB_OPENING_OPTIONS}
value={node.slabOpeningMode ?? 'none'}
/>