Fix stair opening previews and slab cutouts
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
},
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user