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
+10
View File
@@ -59,6 +59,15 @@ export {
isOperationDoorType, isOperationDoorType,
SECTIONAL_GARAGE_RENDER_OPEN_SCALE, SECTIONAL_GARAGE_RENDER_OPEN_SCALE,
} from './lib/door-operation' } from './lib/door-operation'
export {
type Point2D as PolygonPoint2D,
pointInPolygon as pointInPolygon2D,
pointOnSegment,
polygonContainsPolygon,
polygonsIntersect,
polygonsOverlap,
segmentsIntersect,
} from './lib/polygon-relations'
export { getRenderableSlabPolygon } from './lib/slab-polygon' export { getRenderableSlabPolygon } from './lib/slab-polygon'
export { export {
type AutoCeilingPlanningContext, type AutoCeilingPlanningContext,
@@ -161,6 +170,7 @@ export {
resolveElevatorServiceLevels, resolveElevatorServiceLevels,
} from './systems/elevator/elevator-service' } from './systems/elevator/elevator-service'
export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint' export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint'
export { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview'
export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync' export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync'
export { StairOpeningSystem } from './systems/stair/stair-opening-system' export { StairOpeningSystem } from './systems/stair/stair-opening-system'
export { export {
+102
View File
@@ -0,0 +1,102 @@
export type Point2D = [number, number]
export function pointOnSegment(point: Point2D, start: Point2D, end: Point2D, tolerance = 1e-6) {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const cross = (point[0] - start[0]) * dz - (point[1] - start[1]) * dx
if (Math.abs(cross) > tolerance) return false
const dot =
(point[0] - start[0]) * (point[0] - end[0]) + (point[1] - start[1]) * (point[1] - end[1])
return dot <= tolerance
}
export function pointInPolygon(
point: Point2D,
polygon: Point2D[],
options?: { includeBoundary?: boolean },
) {
if (polygon.length < 3) return false
const includeBoundary = options?.includeBoundary ?? true
if (
polygon.some((start, index) =>
pointOnSegment(point, start, polygon[(index + 1) % polygon.length]!),
)
) {
return includeBoundary
}
let inside = false
const [x, z] = point
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const current = polygon[i]!
const previous = polygon[j]!
const intersects =
current[1] > z !== previous[1] > z &&
x < ((previous[0] - current[0]) * (z - current[1])) / (previous[1] - current[1]) + current[0]
if (intersects) inside = !inside
}
return inside
}
export function segmentsIntersect(a: Point2D, b: Point2D, c: Point2D, d: Point2D) {
const cross = (ux: number, uz: number, vx: number, vz: number) => ux * vz - uz * vx
const abx = b[0] - a[0]
const abz = b[1] - a[1]
const acx = c[0] - a[0]
const acz = c[1] - a[1]
const adx = d[0] - a[0]
const adz = d[1] - a[1]
const cdx = d[0] - c[0]
const cdz = d[1] - c[1]
const cax = a[0] - c[0]
const caz = a[1] - c[1]
const cbx = b[0] - c[0]
const cbz = b[1] - c[1]
const o1 = cross(abx, abz, acx, acz)
const o2 = cross(abx, abz, adx, adz)
const o3 = cross(cdx, cdz, cax, caz)
const o4 = cross(cdx, cdz, cbx, cbz)
if (Math.sign(o1) !== Math.sign(o2) && Math.sign(o3) !== Math.sign(o4)) return true
return (
pointOnSegment(c, a, b) ||
pointOnSegment(d, a, b) ||
pointOnSegment(a, c, d) ||
pointOnSegment(b, c, d)
)
}
export function polygonsIntersect(left: Point2D[], right: Point2D[]) {
for (let leftIndex = 0; leftIndex < left.length; leftIndex++) {
const leftStart = left[leftIndex]!
const leftEnd = left[(leftIndex + 1) % left.length]!
for (let rightIndex = 0; rightIndex < right.length; rightIndex++) {
if (
segmentsIntersect(
leftStart,
leftEnd,
right[rightIndex]!,
right[(rightIndex + 1) % right.length]!,
)
) {
return true
}
}
}
return false
}
export function polygonContainsPolygon(outer: Point2D[], inner: Point2D[]) {
return inner.every((point) => pointInPolygon(point, outer))
}
export function polygonsOverlap(left: Point2D[], right: Point2D[]) {
return (
polygonsIntersect(left, right) ||
left.some((point) => pointInPolygon(point, right)) ||
right.some((point) => pointInPolygon(point, left))
)
}
@@ -8,6 +8,7 @@ type LiveNodeOverrideState = {
setMany(entries: ReadonlyArray<readonly [string, LiveNodeOverrides]>): void setMany(entries: ReadonlyArray<readonly [string, LiveNodeOverrides]>): void
get(nodeId: string): LiveNodeOverrides | undefined get(nodeId: string): LiveNodeOverrides | undefined
clear(nodeId: string): void clear(nodeId: string): void
clearFields(nodeId: string, keys: readonly string[]): void
clearAll(): void clearAll(): void
} }
@@ -39,6 +40,24 @@ const useLiveNodeOverrides = create<LiveNodeOverrideState>((set, get) => ({
next.delete(nodeId) next.delete(nodeId)
return { overrides: next } return { overrides: next }
}), }),
clearFields: (nodeId, keys) =>
set((state) => {
const current = state.overrides.get(nodeId)
if (!current) return state
const nextValues = { ...current }
for (const key of keys) {
delete nextValues[key]
}
const next = new Map(state.overrides)
if (Object.keys(nextValues).length === 0) {
next.delete(nodeId)
} else {
next.set(nodeId, nextValues)
}
return { overrides: next }
}),
clearAll: () => set({ overrides: new Map() }), clearAll: () => set({ overrides: new Map() }),
})) }))
+1 -1
View File
@@ -43,7 +43,7 @@ function getEnumValue<T extends readonly string[]>(
} }
function getNullableString(value: unknown) { function getNullableString(value: unknown) {
return typeof value === 'string' ? value : null return typeof value === 'string' && value.length > 0 ? value : null
} }
function getStringArray(value: unknown) { function getStringArray(value: unknown) {
@@ -0,0 +1,99 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode } from '../../schema'
import { BuildingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema'
import {
getNodesWithLiveStairOpeningInputs,
hasLiveStairOpeningInputs,
} from './stair-opening-preview'
import { syncAutoStairOpenings } from './stair-opening-sync'
describe('stair opening previews', () => {
test('computes auto openings from live stair transforms', () => {
const building = BuildingNode.parse({ name: 'Building' })
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id })
const slab = SlabNode.parse({
name: 'Upper Slab',
parentId: upper.id,
polygon: [
[0, 0],
[5, 0],
[5, 4],
[0, 4],
],
})
const segment = StairSegmentNode.parse({
parentId: 'stair_live',
width: 1,
length: 3,
height: 2.5,
stepCount: 12,
})
const stair = StairNode.parse({
id: 'stair_live',
name: 'Live Stair',
parentId: ground.id,
position: [1, 0, 0.2],
stairType: 'straight',
fromLevelId: ground.id,
toLevelId: upper.id,
slabOpeningMode: 'destination',
children: [segment.id],
})
const nodes = Object.fromEntries(
[building, ground, upper, slab, stair, { ...segment, parentId: stair.id }].map((node) => [
node.id,
node,
]),
) as Record<string, AnyNode>
const liveTransforms = new Map([
[stair.id, { position: [3, 0, 0.2] as [number, number, number], rotation: 0 }],
])
const liveOverrides = new Map<string, Record<string, unknown>>()
expect(hasLiveStairOpeningInputs(nodes, liveTransforms, liveOverrides, new Set())).toBe(true)
const previewNodes = getNodesWithLiveStairOpeningInputs(
nodes,
liveTransforms,
liveOverrides,
new Set(),
)
const updates = syncAutoStairOpenings(previewNodes)
const hole = updates.find((update) => update.id === slab.id)?.data.holes?.[0]
expect(hole).toBeDefined()
expect(Math.max(...hole!.map(([x]) => x))).toBeGreaterThan(3.4)
})
test('ignores its own live surface overrides as preview inputs', () => {
const slab = SlabNode.parse({
polygon: [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
],
})
const nodes = { [slab.id]: slab } as Record<string, AnyNode>
const liveOverrides = new Map<string, Record<string, unknown>>([
[
slab.id,
{
holes: [
[
[1, 1],
[2, 1],
[2, 2],
[1, 2],
],
],
},
],
])
expect(hasLiveStairOpeningInputs(nodes, new Map(), liveOverrides, new Set([slab.id]))).toBe(
false,
)
})
})
@@ -0,0 +1,120 @@
import type { AnyNode, AnyNodeId, CeilingNode, SlabNode } from '../../schema'
import useLiveNodeOverrides, { type LiveNodeOverrides } from '../../store/use-live-node-overrides'
import type { LiveTransform } from '../../store/use-live-transforms'
import useScene from '../../store/use-scene'
type SurfaceOpeningUpdate = {
id: AnyNodeId
data: Partial<SlabNode | CeilingNode>
}
const SURFACE_OPENING_FIELDS = ['holes', 'holeMetadata'] as const
function isSurface(node: AnyNode | undefined): node is SlabNode | CeilingNode {
return node?.type === 'slab' || node?.type === 'ceiling'
}
function isStairOpeningInputNode(node: AnyNode | undefined) {
return node?.type === 'stair' || node?.type === 'stair-segment'
}
function omitPreviewSurfaceFields(override: LiveNodeOverrides) {
const next = { ...override }
for (const field of SURFACE_OPENING_FIELDS) {
delete next[field]
}
return next
}
export function hasLiveStairOpeningInputs(
nodes: Record<string, AnyNode>,
liveTransforms: ReadonlyMap<string, LiveTransform>,
liveOverrides: ReadonlyMap<string, LiveNodeOverrides>,
previewSurfaceIds: ReadonlySet<string>,
) {
for (const nodeId of liveTransforms.keys()) {
if (nodes[nodeId]?.type === 'stair') return true
}
for (const [nodeId, override] of liveOverrides) {
if (previewSurfaceIds.has(nodeId)) continue
if (Object.keys(override).length > 0 && isStairOpeningInputNode(nodes[nodeId])) return true
}
return false
}
export function getNodesWithLiveStairOpeningInputs(
nodes: Record<string, AnyNode>,
liveTransforms: ReadonlyMap<string, LiveTransform>,
liveOverrides: ReadonlyMap<string, LiveNodeOverrides>,
previewSurfaceIds: ReadonlySet<string>,
) {
const nextNodes: Record<string, AnyNode> = { ...nodes }
for (const [nodeId, override] of liveOverrides) {
const node = nextNodes[nodeId]
if (!node) continue
const values = previewSurfaceIds.has(nodeId) ? omitPreviewSurfaceFields(override) : override
if (Object.keys(values).length === 0) continue
nextNodes[nodeId] = { ...node, ...values } as AnyNode
}
for (const [nodeId, transform] of liveTransforms) {
const node = nextNodes[nodeId]
if (node?.type !== 'stair') continue
nextNodes[nodeId] = {
...node,
position: transform.position,
rotation: transform.rotation,
}
}
return nextNodes
}
export function createSurfaceOpeningPreviewController() {
const previewSurfaceIds = new Set<AnyNodeId>()
const clearSurface = (id: AnyNodeId) => {
useLiveNodeOverrides.getState().clearFields(id, SURFACE_OPENING_FIELDS)
useScene.getState().markDirty(id)
}
return {
previewSurfaceIds,
apply(updates: SurfaceOpeningUpdate[]) {
const scene = useScene.getState()
const nextSurfaceIds = new Set<AnyNodeId>()
for (const update of updates) {
const node = scene.nodes[update.id]
if (!isSurface(node)) continue
if (!('holes' in update.data || 'holeMetadata' in update.data)) continue
nextSurfaceIds.add(update.id)
useLiveNodeOverrides.getState().set(update.id, {
holes: update.data.holes ?? [],
holeMetadata: update.data.holeMetadata ?? [],
})
scene.markDirty(update.id)
}
for (const id of previewSurfaceIds) {
if (!nextSurfaceIds.has(id)) clearSurface(id)
}
previewSurfaceIds.clear()
for (const id of nextSurfaceIds) {
previewSurfaceIds.add(id)
}
},
clear() {
for (const id of previewSurfaceIds) {
clearSurface(id)
}
previewSurfaceIds.clear()
},
}
}
@@ -11,7 +11,7 @@ import {
import { syncAutoStairOpenings } from './stair-opening-sync' import { syncAutoStairOpenings } from './stair-opening-sync'
describe('syncAutoStairOpenings', () => { describe('syncAutoStairOpenings', () => {
test('only applies stair holes to destination slabs that contain the opening', () => { test('only applies stair holes to destination slabs that overlap the opening', () => {
const building = BuildingNode.parse({ name: 'Building' }) const building = BuildingNode.parse({ name: 'Building' })
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id })
@@ -74,6 +74,255 @@ describe('syncAutoStairOpenings', () => {
expect(bedroomUpdate).toBeUndefined() expect(bedroomUpdate).toBeUndefined()
}) })
test('applies stair holes to a later destination slab when the configured offset overhangs the slab edge', () => {
const building = BuildingNode.parse({ name: 'Building' })
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id })
const landingSlab = SlabNode.parse({
name: 'Landing Slab',
parentId: upper.id,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
})
const segment = StairSegmentNode.parse({
parentId: 'stair_edge',
width: 1,
length: 2.6,
height: 2.5,
stepCount: 12,
})
const stair = StairNode.parse({
id: 'stair_edge',
name: 'Edge Stair',
parentId: ground.id,
position: [2, 0, 0],
stairType: 'straight',
fromLevelId: ground.id,
toLevelId: upper.id,
slabOpeningMode: 'destination',
openingOffset: 0.08,
children: [segment.id],
})
const nodes = Object.fromEntries(
[building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map(
(node) => [node.id, node],
),
) as Record<string, AnyNode>
const updates = syncAutoStairOpenings(nodes)
const landingUpdate = updates.find((update) => update.id === landingSlab.id)
const hole = landingUpdate?.data.holes?.[0]
expect(hole).toBeDefined()
expect(Math.min(...hole!.map(([, z]) => z))).toBeCloseTo(-0.08)
expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }])
})
test('does not apply stair holes to slabs on another building with a matching level number', () => {
const buildingA = BuildingNode.parse({ name: 'Building A' })
const groundA = LevelNode.parse({ name: 'Ground A', level: 0, parentId: buildingA.id })
const upperA = LevelNode.parse({ name: 'Upper A', level: 1, parentId: buildingA.id })
const buildingB = BuildingNode.parse({ name: 'Building B' })
const upperB = LevelNode.parse({ name: 'Upper B', level: 1, parentId: buildingB.id })
const slabA = SlabNode.parse({
name: 'Upper A Slab',
parentId: upperA.id,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
})
const slabB = SlabNode.parse({
name: 'Upper B Slab',
parentId: upperB.id,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
})
const segment = StairSegmentNode.parse({
parentId: 'stair_scoped',
width: 1,
length: 2.6,
height: 2.5,
stepCount: 12,
})
const stair = StairNode.parse({
id: 'stair_scoped',
name: 'Scoped Stair',
parentId: groundA.id,
position: [2, 0, 0.2],
stairType: 'straight',
fromLevelId: groundA.id,
toLevelId: upperA.id,
slabOpeningMode: 'destination',
children: [segment.id],
})
const nodes = Object.fromEntries(
[
buildingA,
groundA,
upperA,
buildingB,
upperB,
slabA,
slabB,
stair,
{ ...segment, parentId: stair.id },
].map((node) => [node.id, node]),
) as Record<string, AnyNode>
const updates = syncAutoStairOpenings(nodes)
expect(updates.find((update) => update.id === slabA.id)?.data.holes).toHaveLength(1)
expect(updates.find((update) => update.id === slabB.id)).toBeUndefined()
})
test('uses the parent level when a stair has stale from-level data', () => {
const building = BuildingNode.parse({ name: 'Building' })
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id })
const landingSlab = SlabNode.parse({
name: 'Landing Slab',
parentId: upper.id,
polygon: [
[0, 0],
[4, 0],
[4, 8],
[0, 8],
],
})
const segment = StairSegmentNode.parse({
parentId: 'stair_stale_from',
width: 1,
length: 6,
height: 2.5,
stepCount: 12,
})
const stair = StairNode.parse({
id: 'stair_stale_from',
name: 'Stale From Stair',
parentId: ground.id,
position: [2, 0, 0.2],
stairType: 'straight',
fromLevelId: 'default',
toLevelId: upper.id,
slabOpeningMode: 'destination',
children: [segment.id],
})
const nodes = Object.fromEntries(
[building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map(
(node) => [node.id, node],
),
) as Record<string, AnyNode>
const updates = syncAutoStairOpenings(nodes)
const landingUpdate = updates.find((update) => update.id === landingSlab.id)
const hole = landingUpdate?.data.holes?.[0]
expect(hole).toBeDefined()
expect(Math.min(...hole!.map(([, z]) => z))).toBeGreaterThan(0.9)
expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }])
})
test('infers the destination level when a destination stair has blank level fields', () => {
const building = BuildingNode.parse({ name: 'Building' })
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id })
const landingSlab = SlabNode.parse({
name: 'Landing Slab',
parentId: upper.id,
polygon: [
[0, 0],
[4, 0],
[4, 8],
[0, 8],
],
})
const segment = StairSegmentNode.parse({
parentId: 'stair_blank_levels',
width: 1,
length: 6,
height: 2.5,
stepCount: 12,
})
const stair = StairNode.parse({
id: 'stair_blank_levels',
name: 'Blank Level Stair',
parentId: ground.id,
position: [2, 0, 0.2],
stairType: 'straight',
fromLevelId: '',
toLevelId: '',
slabOpeningMode: 'destination',
children: [segment.id],
})
const nodes = Object.fromEntries(
[building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map(
(node) => [node.id, node],
),
) as Record<string, AnyNode>
const updates = syncAutoStairOpenings(nodes)
const landingUpdate = updates.find((update) => update.id === landingSlab.id)
expect(landingUpdate?.data.holes).toHaveLength(1)
expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }])
})
test('infers the destination level when a destination stair targets its source level', () => {
const building = BuildingNode.parse({ name: 'Building' })
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id })
const landingSlab = SlabNode.parse({
name: 'Landing Slab',
parentId: upper.id,
polygon: [
[0, 0],
[4, 0],
[4, 8],
[0, 8],
],
})
const segment = StairSegmentNode.parse({
parentId: 'stair_self_target',
width: 1,
length: 6,
height: 2.5,
stepCount: 12,
})
const stair = StairNode.parse({
id: 'stair_self_target',
name: 'Self Target Stair',
parentId: ground.id,
position: [2, 0, 0.2],
stairType: 'straight',
fromLevelId: ground.id,
toLevelId: ground.id,
slabOpeningMode: 'destination',
children: [segment.id],
})
const nodes = Object.fromEntries(
[building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map(
(node) => [node.id, node],
),
) as Record<string, AnyNode>
const updates = syncAutoStairOpenings(nodes)
const landingUpdate = updates.find((update) => update.id === landingSlab.id)
expect(landingUpdate?.data.holes).toHaveLength(1)
expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }])
})
test('does not add stair holes when a manual surface hole already covers them', () => { test('does not add stair holes when a manual surface hole already covers them', () => {
const building = BuildingNode.parse({ name: 'Building' }) const building = BuildingNode.parse({ name: 'Building' })
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
@@ -1,4 +1,5 @@
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' import { resolveBuildingForLevel, resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import { type Point2D, polygonContainsPolygon, polygonsOverlap } from '../../lib/polygon-relations'
import type { import type {
AnyNode, AnyNode,
AnyNodeId, AnyNodeId,
@@ -11,8 +12,6 @@ import type {
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint' import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
import { computeSegmentTransforms, rotateXZ } from './stair-footprint' import { computeSegmentTransforms, rotateXZ } from './stair-footprint'
type Point2D = [number, number]
type SegmentTransform = { type SegmentTransform = {
position: [number, number, number] position: [number, number, number]
rotation: number rotation: number
@@ -85,10 +84,106 @@ function getLevelNumber(levelId: string | null, nodes: Record<string, AnyNode>)
return node?.type === 'level' ? node.level : undefined return node?.type === 'level' ? node.level : undefined
} }
function getLevelBuildingId(levelId: string | null, nodes: Record<string, AnyNode>) {
if (!levelId) return null
return resolveBuildingForLevel(levelId as AnyNodeId, nodes as Record<AnyNodeId, AnyNode>)
}
function normalizeLevelId(levelId: string | null | undefined, nodes: Record<string, AnyNode>) {
if (!levelId) return null
return nodes[levelId as AnyNodeId]?.type === 'level' ? levelId : null
}
function getBuildingLevels(buildingId: string | null, nodes: Record<string, AnyNode>) {
const building = buildingId ? nodes[buildingId as AnyNodeId] : null
if (building?.type !== 'building') return []
const levels = new Map<string, Extract<AnyNode, { type: 'level' }>>()
for (const childId of building.children ?? []) {
const child = nodes[childId as AnyNodeId]
if (child?.type === 'level') levels.set(child.id, child)
}
for (const candidate of Object.values(nodes)) {
if (candidate?.type === 'level' && candidate.parentId === building.id) {
levels.set(candidate.id, candidate)
}
}
return Array.from(levels.values()).sort((left, right) => left.level - right.level)
}
function inferSourceLevelForDestination(
destinationLevelId: string | null,
nodes: Record<string, AnyNode>,
) {
if (!destinationLevelId) return null
const destination = nodes[destinationLevelId as AnyNodeId]
if (destination?.type !== 'level') return null
const buildingId = getLevelBuildingId(destinationLevelId, nodes)
return (
getBuildingLevels(buildingId, nodes)
.filter((level) => level.level < destination.level)
.at(-1)?.id ?? null
)
}
function inferDestinationLevelForSource(
sourceLevelId: string | null,
nodes: Record<string, AnyNode>,
) {
if (!sourceLevelId) return null
const source = nodes[sourceLevelId as AnyNodeId]
if (source?.type !== 'level') return null
const buildingId = getLevelBuildingId(sourceLevelId, nodes)
return (
getBuildingLevels(buildingId, nodes).find((level) => level.level > source.level)?.id ?? null
)
}
function levelsShareBuilding(
leftLevelId: string | null,
rightLevelId: string | null,
nodes: Record<string, AnyNode>,
) {
if (!(leftLevelId && rightLevelId)) return true
const leftBuildingId = getLevelBuildingId(leftLevelId, nodes)
const rightBuildingId = getLevelBuildingId(rightLevelId, nodes)
return !(leftBuildingId && rightBuildingId && leftBuildingId !== rightBuildingId)
}
function isInStairBuildingScope(
stair: StairNode,
surfaceLevelId: string,
nodes: Record<string, AnyNode>,
) {
const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes)
const fromBuildingId = getLevelBuildingId(fromLevelId, nodes)
const toBuildingId = getLevelBuildingId(toLevelId, nodes)
const surfaceBuildingId = getLevelBuildingId(surfaceLevelId, nodes)
if (fromBuildingId && toBuildingId && fromBuildingId !== toBuildingId) return false
if (fromBuildingId && surfaceBuildingId && fromBuildingId !== surfaceBuildingId) return false
if (toBuildingId && surfaceBuildingId && toBuildingId !== surfaceBuildingId) return false
return true
}
function getResolvedStairLevelIds(stair: StairNode, nodes: Record<string, AnyNode>) { function getResolvedStairLevelIds(stair: StairNode, nodes: Record<string, AnyNode>) {
const parentLevelId = resolveLevelId(stair, nodes) const parentLevelId = normalizeLevelId(resolveLevelId(stair, nodes), nodes)
const fromLevelId = stair.fromLevelId ?? parentLevelId const explicitToLevelId = normalizeLevelId(stair.toLevelId, nodes)
const toLevelId = stair.toLevelId ?? fromLevelId const fromLevelId =
normalizeLevelId(stair.fromLevelId, nodes) ??
parentLevelId ??
inferSourceLevelForDestination(explicitToLevelId, nodes)
const explicitToLevelIsUsable =
explicitToLevelId &&
explicitToLevelId !== fromLevelId &&
levelsShareBuilding(fromLevelId, explicitToLevelId, nodes)
const toLevelId = explicitToLevelIsUsable
? explicitToLevelId
: inferDestinationLevelForSource(fromLevelId, nodes)
return { fromLevelId, toLevelId } return { fromLevelId, toLevelId }
} }
@@ -192,36 +287,6 @@ function polygonArea(points: Point2D[]) {
return area / 2 return area / 2
} }
function pointOnSegment(point: Point2D, a: Point2D, b: Point2D, tolerance = 1e-6) {
const cross = (point[1] - a[1]) * (b[0] - a[0]) - (point[0] - a[0]) * (b[1] - a[1])
if (Math.abs(cross) > tolerance) return false
const dot = (point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1])
if (dot < -tolerance) return false
const lenSq = (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2
return dot <= lenSq + tolerance
}
function pointInPolygon(point: Point2D, polygon: Point2D[]) {
if (polygon.length < 3) return false
let inside = false
const [x, z] = point
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const a = polygon[i]!
const b = polygon[j]!
if (pointOnSegment(point, a, b)) return true
const intersects =
a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0]
if (intersects) inside = !inside
}
return inside
}
function polygonContainsPolygon(outer: Point2D[], inner: Point2D[]) {
return inner.every((point) => pointInPolygon(point, outer))
}
function isCoveredByExistingHole(existingHoles: Point2D[][], autoHole: Point2D[]) { function isCoveredByExistingHole(existingHoles: Point2D[][], autoHole: Point2D[]) {
return existingHoles.some((existingHole) => polygonContainsPolygon(existingHole, autoHole)) return existingHoles.some((existingHole) => polygonContainsPolygon(existingHole, autoHole))
} }
@@ -409,13 +474,14 @@ function getStraightOpeningPolygonsForSurface(
stair: StairNode, stair: StairNode,
nodes: Record<string, AnyNode>, nodes: Record<string, AnyNode>,
targetElevation: number, targetElevation: number,
openingOffsetOverride?: number,
) { ) {
const layouts = getStraightStairLayouts(stair, nodes) const layouts = getStraightStairLayouts(stair, nodes)
if (layouts.length === 0) return [] if (layouts.length === 0) return []
const riserHeight = (stair.totalRise ?? 2.5) / Math.max(stair.stepCount ?? 10, 1) const riserHeight = (stair.totalRise ?? 2.5) / Math.max(stair.stepCount ?? 10, 1)
const targetThreshold = Math.max(riserHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN) const targetThreshold = Math.max(riserHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN)
const openingOffset = Math.max(stair.openingOffset ?? 0, 0.15) const openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0)
const openingRects: AxisAlignedRect[] = [] const openingRects: AxisAlignedRect[] = []
for (let index = 0; index < layouts.length; index += 1) { for (let index = 0; index < layouts.length; index += 1) {
@@ -493,22 +559,22 @@ function getStairOpeningPolygons(
stair: StairNode, stair: StairNode,
nodes: Record<string, AnyNode>, nodes: Record<string, AnyNode>,
targetElevation?: number, targetElevation?: number,
openingOffsetOverride?: number,
) { ) {
if ((stair.slabOpeningMode ?? 'none') !== 'destination') { if ((stair.slabOpeningMode ?? 'none') !== 'destination') {
return [] return []
} }
const openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0)
if (stair.stairType === 'curved') { if (stair.stairType === 'curved') {
return [ return [
getCurvedOpeningPolygon( getCurvedOpeningPolygon(stair, Math.max(openingOffset - STAIR_SLAB_OPENING_TIGHTENING, 0)),
stair,
Math.max((stair.openingOffset ?? 0) - STAIR_SLAB_OPENING_TIGHTENING, 0.15),
),
] ]
} }
if (stair.stairType === 'spiral') { if (stair.stairType === 'spiral') {
const offset = Math.max((stair.openingOffset ?? 0) - STAIR_SLAB_OPENING_TIGHTENING, 0.15) const offset = Math.max(openingOffset - STAIR_SLAB_OPENING_TIGHTENING, 0)
const polygons = [getSpiralOpeningPolygon(stair, offset)] const polygons = [getSpiralOpeningPolygon(stair, offset)]
if (stair.topLandingMode === 'integrated') { if (stair.topLandingMode === 'integrated') {
polygons.push(getSpiralLandingPolygon(stair, offset)) polygons.push(getSpiralLandingPolygon(stair, offset))
@@ -517,16 +583,41 @@ function getStairOpeningPolygons(
} }
if (typeof targetElevation === 'number') { if (typeof targetElevation === 'number') {
return getStraightOpeningPolygonsForSurface(stair, nodes, targetElevation) return getStraightOpeningPolygonsForSurface(stair, nodes, targetElevation, openingOffset)
} }
return getStraightOpeningPolygonsForSurface( return getStraightOpeningPolygonsForSurface(
stair, stair,
nodes, nodes,
Math.max(...getStraightStairLayouts(stair, nodes).map((layout) => layout.topElevation), 0), Math.max(...getStraightStairLayouts(stair, nodes).map((layout) => layout.topElevation), 0),
openingOffset,
) )
} }
function getApplicableStairOpeningPolygons(
stair: StairNode,
nodes: Record<string, AnyNode>,
targetElevation: number,
surfacePolygon: Point2D[],
) {
const configuredOffset = Math.max(stair.openingOffset ?? 0, 0)
const polygons = getStairOpeningPolygons(stair, nodes, targetElevation, configuredOffset)
const overlappingPolygons = polygons.filter((polygon) => polygonsOverlap(surfacePolygon, polygon))
if (overlappingPolygons.length === polygons.length || configuredOffset <= 1e-6) {
return overlappingPolygons
}
const fallbackPolygons = getStairOpeningPolygons(stair, nodes, targetElevation, 0)
const overlappingFallbackPolygons = fallbackPolygons.filter((polygon) =>
polygonsOverlap(surfacePolygon, polygon),
)
return overlappingFallbackPolygons.length === fallbackPolygons.length
? overlappingFallbackPolygons
: overlappingPolygons
}
function getTargetSlabElevationForStair( function getTargetSlabElevationForStair(
stair: StairNode, stair: StairNode,
slab: SlabNode, slab: SlabNode,
@@ -579,6 +670,8 @@ function shouldApplyStairToSlab(
const toLevel = getLevelNumber(toLevelId, nodes) const toLevel = getLevelNumber(toLevelId, nodes)
const slabLevel = getLevelNumber(slabLevelId, nodes) const slabLevel = getLevelNumber(slabLevelId, nodes)
if (!isInStairBuildingScope(stair, slabLevelId, nodes)) return false
if (slabLevel === undefined) { if (slabLevel === undefined) {
return toLevelId === slabLevelId return toLevelId === slabLevelId
} }
@@ -602,6 +695,8 @@ function shouldApplyStairToCeiling(
const toLevel = getLevelNumber(toLevelId, nodes) const toLevel = getLevelNumber(toLevelId, nodes)
const ceilingLevel = getLevelNumber(ceilingLevelId, nodes) const ceilingLevel = getLevelNumber(ceilingLevelId, nodes)
if (!isInStairBuildingScope(stair, ceilingLevelId, nodes)) return false
if (ceilingLevel === undefined) { if (ceilingLevel === undefined) {
return fromLevelId === ceilingLevelId return fromLevelId === ceilingLevelId
} }
@@ -637,10 +732,11 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
const stairHoles = stairs const stairHoles = stairs
.filter((stair) => shouldApplyStairToSlab(stair, slabLevelId, nodes)) .filter((stair) => shouldApplyStairToSlab(stair, slabLevelId, nodes))
.flatMap((stair) => .flatMap((stair) =>
getStairOpeningPolygons( getApplicableStairOpeningPolygons(
stair, stair,
nodes, nodes,
getTargetSlabElevationForStair(stair, slab, slabLevelId, nodes), getTargetSlabElevationForStair(stair, slab, slabLevelId, nodes),
slab.polygon,
).map((polygon) => ({ ).map((polygon) => ({
polygon, polygon,
metadata: { metadata: {
@@ -649,7 +745,6 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
}, },
})), })),
) )
.filter((hole) => polygonContainsPolygon(slab.polygon, hole.polygon))
.filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon))
const nextHoles = [ const nextHoles = [
@@ -686,10 +781,11 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
const stairHoles = stairs const stairHoles = stairs
.filter((stair) => shouldApplyStairToCeiling(stair, ceilingLevelId, nodes)) .filter((stair) => shouldApplyStairToCeiling(stair, ceilingLevelId, nodes))
.flatMap((stair) => .flatMap((stair) =>
getStairOpeningPolygons( getApplicableStairOpeningPolygons(
stair, stair,
nodes, nodes,
getTargetCeilingElevationForStair(stair, ceiling, ceilingLevelId, nodes), getTargetCeilingElevationForStair(stair, ceiling, ceilingLevelId, nodes),
ceiling.polygon,
).map((polygon) => ({ ).map((polygon) => ({
polygon, polygon,
metadata: { metadata: {
@@ -698,7 +794,6 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
}, },
})), })),
) )
.filter((hole) => polygonContainsPolygon(ceiling.polygon, hole.polygon))
.filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon))
const nextHoles = [ const nextHoles = [
@@ -2,7 +2,15 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import type { AnyNode } from '../../schema' import type { AnyNode } from '../../schema'
import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control'
import useLiveNodeOverrides from '../../store/use-live-node-overrides'
import useLiveTransforms from '../../store/use-live-transforms'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import {
createSurfaceOpeningPreviewController,
getNodesWithLiveStairOpeningInputs,
hasLiveStairOpeningInputs,
} from './stair-opening-preview'
import { syncAutoStairOpenings } from './stair-opening-sync' import { syncAutoStairOpenings } from './stair-opening-sync'
function isOpeningRelevantNode(node: AnyNode | undefined) { function isOpeningRelevantNode(node: AnyNode | undefined) {
@@ -35,24 +43,90 @@ function hasOpeningRelevantNodeChange(
export const StairOpeningSystem = () => { export const StairOpeningSystem = () => {
const syncingAutoOpeningsRef = useRef(false) const syncingAutoOpeningsRef = useRef(false)
const syncingPreviewOpeningsRef = useRef(false)
const previewControllerRef = useRef(createSurfaceOpeningPreviewController())
useEffect(() => { useEffect(() => {
const applyUpdates = (updates: ReturnType<typeof syncAutoStairOpenings>) => { const applyUpdates = (updates: ReturnType<typeof syncAutoStairOpenings>) => {
if (updates.length === 0) return if (updates.length === 0) return
syncingAutoOpeningsRef.current = true syncingAutoOpeningsRef.current = true
useScene.getState().updateNodes(updates) pauseSceneHistory(useScene)
try {
useScene.getState().updateNodes(updates)
} finally {
resumeSceneHistory(useScene)
}
queueMicrotask(() => { queueMicrotask(() => {
syncingAutoOpeningsRef.current = false syncingAutoOpeningsRef.current = false
}) })
} }
applyUpdates(syncAutoStairOpenings(useScene.getState().nodes)) const applyPreviewUpdates = (updates: ReturnType<typeof syncAutoStairOpenings>) => {
syncingPreviewOpeningsRef.current = true
previewControllerRef.current.apply(updates)
queueMicrotask(() => {
syncingPreviewOpeningsRef.current = false
})
}
return useScene.subscribe((state, prevState) => { const clearPreviewUpdates = () => {
if (previewControllerRef.current.previewSurfaceIds.size === 0) return
syncingPreviewOpeningsRef.current = true
previewControllerRef.current.clear()
queueMicrotask(() => {
syncingPreviewOpeningsRef.current = false
})
}
const refreshLivePreview = () => {
if (syncingPreviewOpeningsRef.current) return
const nodes = useScene.getState().nodes
const liveTransforms = useLiveTransforms.getState().transforms
const liveOverrides = useLiveNodeOverrides.getState().overrides
const previewSurfaceIds = previewControllerRef.current.previewSurfaceIds
if (!hasLiveStairOpeningInputs(nodes, liveTransforms, liveOverrides, previewSurfaceIds)) {
clearPreviewUpdates()
return
}
applyPreviewUpdates(
syncAutoStairOpenings(
getNodesWithLiveStairOpeningInputs(
nodes,
liveTransforms,
liveOverrides,
previewSurfaceIds,
),
),
)
}
applyUpdates(syncAutoStairOpenings(useScene.getState().nodes))
refreshLivePreview()
const unsubscribeScene = useScene.subscribe((state, prevState) => {
if (syncingAutoOpeningsRef.current) return if (syncingAutoOpeningsRef.current) return
if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return
applyUpdates(syncAutoStairOpenings(state.nodes)) applyUpdates(syncAutoStairOpenings(state.nodes))
refreshLivePreview()
}) })
const unsubscribeLiveTransforms = useLiveTransforms.subscribe(() => {
refreshLivePreview()
})
const unsubscribeLiveOverrides = useLiveNodeOverrides.subscribe(() => {
refreshLivePreview()
})
return () => {
unsubscribeScene()
unsubscribeLiveTransforms()
unsubscribeLiveOverrides()
previewControllerRef.current.clear()
}
}, []) }, [])
return null return null
@@ -16,7 +16,6 @@ import { useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent } from '@react-three/fiber' import { createPortal, type ThreeEvent } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { import {
BoxGeometry,
BufferGeometry, BufferGeometry,
DoubleSide, DoubleSide,
Float32BufferAttribute, Float32BufferAttribute,
@@ -31,7 +30,6 @@ import { swallowNextClick } from './handles/use-handle-drag'
const ACCENT = 0x83_81_ed const ACCENT = 0x83_81_ed
const SURFACE_OFFSET = 0.01 const SURFACE_OFFSET = 0.01
const HIT_PADDING = 0.08
const MIN_HIT_HEIGHT = 0.16 const MIN_HIT_HEIGHT = 0.16
const NO_RAYCAST = () => null const NO_RAYCAST = () => null
@@ -104,24 +102,34 @@ function makeOutlineGeometry(hole: HolePolygon, y: number): BufferGeometry {
} }
function makeHitGeometry(hole: HolePolygon, centerY: number, height: number): BufferGeometry { function makeHitGeometry(hole: HolePolygon, centerY: number, height: number): BufferGeometry {
let minX = Number.POSITIVE_INFINITY const topY = centerY + height / 2
let maxX = Number.NEGATIVE_INFINITY const bottomY = centerY - height / 2
let minZ = Number.POSITIVE_INFINITY const positions: number[] = []
let maxZ = Number.NEGATIVE_INFINITY const indices: number[] = []
for (const [x, z] of hole) { for (const [x, z] of hole) positions.push(x, topY, z)
minX = Math.min(minX, x) for (const [x, z] of hole) positions.push(x, bottomY, z)
maxX = Math.max(maxX, x)
minZ = Math.min(minZ, z) const triangles = ShapeUtils.triangulateShape(
maxZ = Math.max(maxZ, z) hole.map(([x, z]) => new Vector2(x, z)),
[],
)
const bottomOffset = hole.length
for (const tri of triangles) {
indices.push(tri[0]!, tri[2]!, tri[1]!)
indices.push(bottomOffset + tri[0]!, bottomOffset + tri[1]!, bottomOffset + tri[2]!)
} }
const width = Math.max(maxX - minX + HIT_PADDING * 2, HIT_PADDING * 2) for (let index = 0; index < hole.length; index += 1) {
const depth = Math.max(maxZ - minZ + HIT_PADDING * 2, HIT_PADDING * 2) const nextIndex = (index + 1) % hole.length
const centerX = (minX + maxX) / 2 indices.push(index, nextIndex, bottomOffset + nextIndex)
const centerZ = (minZ + maxZ) / 2 indices.push(index, bottomOffset + nextIndex, bottomOffset + index)
const geometry = new BoxGeometry(width, height, depth) }
geometry.translate(centerX, centerY, centerZ)
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.setIndex(indices)
geometry.computeVertexNormals()
geometry.computeBoundingSphere() geometry.computeBoundingSphere()
return geometry return geometry
} }
@@ -1,11 +1,16 @@
import { import {
type AnyNode,
collectAlignmentAnchors, collectAlignmentAnchors,
createSurfaceOpeningPreviewController,
type EventSuffix,
emitter, emitter,
type GridEvent, type GridEvent,
type LevelNode, type LevelNode,
type NodeEvent,
resolveAlignment, resolveAlignment,
StairNode, StairNode,
StairSegmentNode, StairSegmentNode,
syncAutoStairOpenings,
useAlignmentGuides, useAlignmentGuides,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -13,6 +18,10 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import {
resolveStairDestinationLevel,
resolveStairPlacementLevelId,
} from '../../../lib/stair-levels'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import { import {
@@ -37,6 +46,21 @@ import {
const GRID_OFFSET = 0.02 const GRID_OFFSET = 0.02
/** Figma-style alignment-snap threshold (meters), matching the move tools. */ /** Figma-style alignment-snap threshold (meters), matching the move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08 const ALIGNMENT_THRESHOLD_M = 0.08
type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
const CLICK_TRIGGER_KINDS = [
'shelf',
'item',
'slab',
'ceiling',
'wall',
'fence',
'column',
'roof',
'roof-segment',
'stair',
'stair-segment',
] as const
/** /**
* Generates the step-profile geometry for the ghost preview. * Generates the step-profile geometry for the ghost preview.
@@ -140,28 +164,42 @@ function commitStairPlacement(
rotation: number, rotation: number,
): void { ): void {
const { createNodes, nodes } = useScene.getState() const { createNodes, nodes } = useScene.getState()
const placementLevelId = resolveStairPlacementLevelId(
nodes,
levelId,
useViewer.getState().selection.buildingId,
)
if (!placementLevelId) return
const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length
const name = `Staircase ${stairCount + 1}` const name = `Staircase ${stairCount + 1}`
const segment = createDefaultStairSegment() const segment = createDefaultStairSegment()
const sortedLevels = Object.values(nodes) const destinationPlan = resolveStairDestinationLevel({
.filter((node): node is LevelNode => node.type === 'level') createMissing: true,
.sort((left, right) => left.level - right.level) fromLevelId: placementLevelId,
const currentLevelIndex = sortedLevels.findIndex((level) => level.id === levelId) nodes,
const nextLevelId = sortedLevels[currentLevelIndex + 1]?.id ?? levelId })
const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId
const stair = createDefaultStairNode({ const stair = createDefaultStairNode({
name, name,
levelId, levelId: placementLevelId,
nextLevelId, nextLevelId,
position, position,
rotation, rotation,
segmentId: segment.id, segmentId: segment.id,
}) })
const createdLevel = destinationPlan?.createdLevel
const levelCreateOps =
createdLevel && destinationPlan.buildingId
? [{ node: createdLevel, parentId: destinationPlan.buildingId }]
: []
createNodes([ createNodes([
{ node: stair, parentId: levelId }, ...levelCreateOps,
{ node: stair, parentId: placementLevelId },
{ node: segment, parentId: stair.id }, { node: segment, parentId: stair.id },
]) ])
@@ -181,39 +219,60 @@ export const StairTool: React.FC = () => {
useEffect(() => { useEffect(() => {
if (!currentLevelId) return if (!currentLevelId) return
const openingPreview = createSurfaceOpeningPreviewController()
// Reset rotation when tool activates // Reset rotation when tool activates
rotationRef.current = 0 rotationRef.current = 0
if (previewRef.current) previewRef.current.rotation.y = 0 if (previewRef.current) previewRef.current.rotation.y = 0
lastCanonicalPositionRef.current = null lastCanonicalPositionRef.current = null
const getPreviewPosition = ( const buildPreviewScene = (position: [number, number, number], rotation: number) => {
position: [number, number, number], const nodes = useScene.getState().nodes
rotation: number, const placementLevelId = resolveStairPlacementLevelId(
): [number, number, number] => { nodes,
currentLevelId,
useViewer.getState().selection.buildingId,
)
if (!placementLevelId) return null
const destinationPlan = resolveStairDestinationLevel({
createMissing: true,
fromLevelId: placementLevelId,
nodes,
})
const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId
const segment = createDefaultStairSegment() const segment = createDefaultStairSegment()
const stair = createDefaultStairNode({ const stair = createDefaultStairNode({
name: 'Staircase Preview', name: 'Staircase Preview',
levelId: currentLevelId, levelId: placementLevelId,
nextLevelId: currentLevelId, nextLevelId,
position, position,
rotation, rotation,
segmentId: segment.id, segmentId: segment.id,
}) })
return getFloorStackPreviewPosition({ const previewNodes = {
node: stair, ...nodes,
position, ...(destinationPlan?.createdLevel
rotation, ? { [destinationPlan.createdLevel.id]: destinationPlan.createdLevel }
levelId: currentLevelId, : {}),
nodes: { [stair.id]: { ...stair, parentId: placementLevelId },
...useScene.getState().nodes, [segment.id]: { ...segment, parentId: stair.id },
[stair.id]: stair, } as Record<string, AnyNode>
[segment.id]: segment,
}, return { placementLevelId, previewNodes, stair }
})
} }
const applyPreview = (position: [number, number, number], rotation: number) => { const applyDraftPreview = (position: [number, number, number], rotation: number) => {
const visualPosition = getPreviewPosition(position, rotation) const preview = buildPreviewScene(position, rotation)
const visualPosition = preview
? getFloorStackPreviewPosition({
node: preview.stair,
position,
rotation,
levelId: preview.placementLevelId,
nodes: preview.previewNodes,
})
: position
if (cursorRef.current) { if (cursorRef.current) {
cursorRef.current.position.set( cursorRef.current.position.set(
visualPosition[0], visualPosition[0],
@@ -226,6 +285,13 @@ export const StairTool: React.FC = () => {
previewRef.current.position.set(...visualPosition) previewRef.current.position.set(...visualPosition)
previewRef.current.rotation.y = rotation previewRef.current.rotation.y = rotation
} }
if (!preview) {
openingPreview.clear()
return
}
openingPreview.apply(syncAutoStairOpenings(preview.previewNodes))
} }
// Alignment candidates — anchors of every alignable object; refreshed // Alignment candidates — anchors of every alignable object; refreshed
@@ -277,7 +343,7 @@ export const StairTool: React.FC = () => {
) )
const position: [number, number, number] = [gridX, 0, gridZ] const position: [number, number, number] = [gridX, 0, gridZ]
lastCanonicalPositionRef.current = position lastCanonicalPositionRef.current = position
applyPreview(position, rotationRef.current) applyDraftPreview(position, rotationRef.current)
if ( if (
previousGridPosRef.current && previousGridPosRef.current &&
@@ -289,9 +355,7 @@ export const StairTool: React.FC = () => {
previousGridPosRef.current = [gridX, gridZ] previousGridPosRef.current = [gridX, gridZ]
} }
const onGridClick = (event: GridEvent) => { const getAlignedGridPosition = (event: GridEvent): [number, number, number] => {
if (!currentLevelId) return
const [gridX, gridZ] = alignPoint( const [gridX, gridZ] = alignPoint(
Math.round(event.localPosition[0] * 2) / 2, Math.round(event.localPosition[0] * 2) / 2,
Math.round(event.localPosition[2] * 2) / 2, Math.round(event.localPosition[2] * 2) / 2,
@@ -299,7 +363,24 @@ export const StairTool: React.FC = () => {
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true, event.nativeEvent?.altKey === true,
) )
commitStairPlacement(currentLevelId, [gridX, 0, gridZ], rotationRef.current) return [gridX, 0, gridZ]
}
const commitAtCursor = (event: ClickTriggerEvent) => {
if (!currentLevelId) return
const nodeEvent = 'node' in event ? (event as NodeEvent<AnyNode>) : null
if (nodeEvent) {
nodeEvent.stopPropagation()
nodeEvent.nativeEvent.stopPropagation()
}
const position = nodeEvent
? lastCanonicalPositionRef.current
: getAlignedGridPosition(event as GridEvent)
if (!position) return
commitStairPlacement(currentLevelId, position, rotationRef.current)
openingPreview.clear()
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId) alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
} }
@@ -319,7 +400,7 @@ export const StairTool: React.FC = () => {
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
rotationRef.current += rotationDelta rotationRef.current += rotationDelta
if (lastCanonicalPositionRef.current) { if (lastCanonicalPositionRef.current) {
applyPreview(lastCanonicalPositionRef.current, rotationRef.current) applyDraftPreview(lastCanonicalPositionRef.current, rotationRef.current)
} else if (previewRef.current) { } else if (previewRef.current) {
previewRef.current.rotation.y = rotationRef.current previewRef.current.rotation.y = rotationRef.current
} }
@@ -327,14 +408,25 @@ export const StairTool: React.FC = () => {
} }
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', commitAtCursor)
type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
for (const kind of CLICK_TRIGGER_KINDS) {
const key = `${kind}:click` as ClickKey
emitter.on(key, commitAtCursor as never)
}
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
return () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', commitAtCursor)
for (const kind of CLICK_TRIGGER_KINDS) {
const key = `${kind}:click` as ClickKey
emitter.off(key, commitAtCursor as never)
}
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
openingPreview.clear()
} }
}, [currentLevelId]) }, [currentLevelId])
+8
View File
@@ -209,6 +209,14 @@ export type { SceneGraph } from './lib/scene'
export { applySceneGraphToEditor } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene'
export { triggerSFX } from './lib/sfx-bus' export { triggerSFX } from './lib/sfx-bus'
export { duplicateStairSubtree } from './lib/stair-duplication' export { duplicateStairSubtree } from './lib/stair-duplication'
export {
getBuildingLevelsForLevel,
getStairLevelOptions,
resolveStairDestinationLevel,
resolveStairFromLevelId,
resolveStairPlacementLevelId,
resolveStairToLevelId,
} from './lib/stair-levels'
// `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/ // `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/
// nodes` so they don't need their own copy / their own tailwind-merge // nodes` so they don't need their own copy / their own tailwind-merge
// dependency. // dependency.
@@ -0,0 +1,146 @@
import { describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
BuildingNode,
LevelNode,
StairNode,
} from '@pascal-app/core/schema'
import {
getBuildingLevelsForLevel,
getStairLevelOptions,
resolveStairDestinationLevel,
resolveStairFromLevelId,
resolveStairPlacementLevelId,
resolveStairToLevelId,
} from './stair-levels'
describe('stair level helpers', () => {
test('creates a missing upper level in the same building', () => {
const ground = LevelNode.parse({ level: 0, children: [] })
const building = BuildingNode.parse({ children: [ground.id] })
const nodes = {
[building.id]: building,
[ground.id]: ground,
} as Record<AnyNodeId, AnyNode>
const plan = resolveStairDestinationLevel({
createMissing: true,
fromLevelId: ground.id,
nodes,
})
expect(plan?.buildingId).toBe(building.id)
expect(plan?.fromLevel.id).toBe(ground.id)
expect(plan?.toLevel.level).toBe(1)
expect(plan?.toLevel.id).toBe(plan?.createdLevel?.id)
expect(plan?.createdLevel?.parentId).toBe(building.id)
})
test('uses the nearest higher sibling level instead of creating one', () => {
const building = BuildingNode.parse({})
const ground = LevelNode.parse({ level: 0, parentId: building.id })
const second = LevelNode.parse({ level: 1, parentId: building.id })
const third = LevelNode.parse({ level: 2, parentId: building.id })
const nodes = {
[building.id]: { ...building, children: [ground.id, third.id, second.id] },
[ground.id]: ground,
[second.id]: second,
[third.id]: third,
} as Record<AnyNodeId, AnyNode>
const plan = resolveStairDestinationLevel({
createMissing: true,
fromLevelId: ground.id,
nodes,
})
expect(plan?.createdLevel).toBeNull()
expect(plan?.toLevel.id).toBe(second.id)
})
test('ignores levels from other buildings', () => {
const buildingA = BuildingNode.parse({})
const buildingB = BuildingNode.parse({})
const groundA = LevelNode.parse({ level: 0, parentId: buildingA.id })
const upperA = LevelNode.parse({ level: 1, parentId: buildingA.id })
const upperB = LevelNode.parse({ level: 1, parentId: buildingB.id })
const nodes = {
[buildingA.id]: { ...buildingA, children: [groundA.id, upperA.id] },
[buildingB.id]: { ...buildingB, children: [upperB.id] },
[groundA.id]: groundA,
[upperA.id]: upperA,
[upperB.id]: upperB,
} as Record<AnyNodeId, AnyNode>
expect(getBuildingLevelsForLevel(nodes, groundA.id).map((level) => level.id)).toEqual([
groundA.id,
upperA.id,
])
expect(
resolveStairDestinationLevel({ createMissing: true, fromLevelId: groundA.id, nodes })?.toLevel
.id,
).toBe(upperA.id)
})
test('includes source and parent-linked sibling levels when building children are stale', () => {
const building = BuildingNode.parse({ children: [] })
const ground = LevelNode.parse({ level: 0, parentId: building.id })
const upper = LevelNode.parse({ level: 1, parentId: building.id })
const nodes = {
[building.id]: building,
[ground.id]: ground,
[upper.id]: upper,
} as Record<AnyNodeId, AnyNode>
const levels = getBuildingLevelsForLevel(nodes, ground.id)
const plan = resolveStairDestinationLevel({
createMissing: true,
fromLevelId: ground.id,
nodes,
})
expect(levels.map((level) => level.id)).toEqual([ground.id, upper.id])
expect(plan?.createdLevel).toBeNull()
expect(plan?.toLevel.id).toBe(upper.id)
})
test('falls back from stale placement level ids to a valid level in the selected building', () => {
const buildingA = BuildingNode.parse({})
const groundA = LevelNode.parse({ level: 0, parentId: buildingA.id })
const buildingB = BuildingNode.parse({})
const groundB = LevelNode.parse({ level: 0, parentId: buildingB.id })
const nodes = {
[buildingA.id]: { ...buildingA, children: [groundA.id] },
[groundA.id]: groundA,
[buildingB.id]: { ...buildingB, children: [groundB.id] },
[groundB.id]: groundB,
} as Record<AnyNodeId, AnyNode>
expect(resolveStairPlacementLevelId(nodes, 'level_missing', buildingB.id)).toBe(groundB.id)
})
test('repairs panel level ids for stairs with stale from-level data', () => {
const building = BuildingNode.parse({})
const ground = LevelNode.parse({ level: 0, parentId: building.id })
const upper = LevelNode.parse({ level: 1, parentId: building.id })
const stair = StairNode.parse({
parentId: ground.id,
fromLevelId: 'default',
toLevelId: upper.id,
})
const nodes = {
[building.id]: { ...building, children: [ground.id, upper.id] },
[ground.id]: ground,
[upper.id]: upper,
[stair.id]: stair,
} as Record<AnyNodeId, AnyNode>
const levels = getStairLevelOptions(nodes, stair)
const fromLevelId = resolveStairFromLevelId(nodes, stair, levels)
expect(levels.map((level) => level.id)).toEqual([ground.id, upper.id])
expect(fromLevelId).toBe(ground.id)
expect(resolveStairToLevelId(nodes, stair, fromLevelId, levels)).toBe(upper.id)
})
})
+177
View File
@@ -0,0 +1,177 @@
import {
type AnyNode,
type AnyNodeId,
LevelNode,
type LevelNode as LevelNodeType,
resolveBuildingForLevel,
type StairNode,
} from '@pascal-app/core'
function sortLevelsByHeight(levels: LevelNodeType[]) {
return [...levels].sort((left, right) => left.level - right.level)
}
function isLevelNode(node: AnyNode | undefined): node is LevelNodeType {
return node?.type === 'level'
}
function getAllSceneLevels(nodes: Record<string, AnyNode>) {
return sortLevelsByHeight(
Object.values(nodes).filter((entry): entry is LevelNodeType => entry?.type === 'level'),
)
}
function getBuildingLevels(
nodes: Record<string, AnyNode>,
buildingId: AnyNodeId | string | null | undefined,
source?: LevelNodeType,
) {
if (!buildingId) return source ? [source] : []
const building = nodes[buildingId as AnyNodeId]
if (building?.type !== 'building') return source ? [source] : []
const levels = new Map<string, LevelNodeType>()
if (source) levels.set(source.id, source)
for (const childId of building.children ?? []) {
const child = nodes[childId as AnyNodeId]
if (isLevelNode(child)) levels.set(child.id, child)
}
for (const candidate of Object.values(nodes)) {
if (isLevelNode(candidate) && candidate.parentId === building.id) {
levels.set(candidate.id, candidate)
}
}
return sortLevelsByHeight(Array.from(levels.values()))
}
export function getBuildingLevelsForLevel(
nodes: Record<string, AnyNode>,
levelId: AnyNodeId | string | null | undefined,
) {
if (!levelId) return []
const source = nodes[levelId as AnyNodeId]
if (!isLevelNode(source)) return []
const buildingId = resolveBuildingForLevel(
source.id as AnyNodeId,
nodes as Record<AnyNodeId, AnyNode>,
)
return getBuildingLevels(nodes, buildingId, source)
}
export function getStairLevelOptions(nodes: Record<string, AnyNode>, stair: StairNode) {
for (const candidateId of [stair.fromLevelId, stair.parentId, stair.toLevelId]) {
if (isLevelNode(nodes[candidateId as AnyNodeId])) {
return getBuildingLevelsForLevel(nodes, candidateId)
}
}
return getAllSceneLevels(nodes)
}
export function resolveStairPlacementLevelId(
nodes: Record<string, AnyNode>,
preferredLevelId: AnyNodeId | string | null | undefined,
preferredBuildingId?: AnyNodeId | string | null,
) {
if (isLevelNode(nodes[preferredLevelId as AnyNodeId])) {
return preferredLevelId as LevelNodeType['id']
}
const buildingLevels = getBuildingLevels(nodes, preferredBuildingId)
return buildingLevels[0]?.id ?? getAllSceneLevels(nodes)[0]?.id ?? null
}
export function resolveStairFromLevelId(
nodes: Record<string, AnyNode>,
stair: StairNode,
levels = getStairLevelOptions(nodes, stair),
) {
const optionIds = new Set<string>(levels.map((level) => level.id))
if (stair.fromLevelId && optionIds.has(stair.fromLevelId)) return stair.fromLevelId
if (stair.parentId && optionIds.has(stair.parentId)) return stair.parentId
const toLevel = stair.toLevelId ? nodes[stair.toLevelId as AnyNodeId] : undefined
if (isLevelNode(toLevel)) {
const lowerLevel = [...levels].reverse().find((level) => level.level < toLevel.level)
if (lowerLevel) return lowerLevel.id
}
return levels[0]?.id ?? null
}
export function resolveStairToLevelId(
nodes: Record<string, AnyNode>,
stair: StairNode,
fromLevelId: AnyNodeId | string | null | undefined,
levels = getStairLevelOptions(nodes, stair),
) {
const optionIds = new Set<string>(levels.map((level) => level.id))
if (stair.toLevelId && stair.toLevelId !== fromLevelId && optionIds.has(stair.toLevelId)) {
return stair.toLevelId
}
const fromLevel = fromLevelId ? nodes[fromLevelId as AnyNodeId] : undefined
if (isLevelNode(fromLevel)) {
return levels.find((level) => level.level > fromLevel.level)?.id ?? fromLevel.id
}
return levels[0]?.id ?? null
}
export function resolveStairDestinationLevel({
createMissing,
fromLevelId,
nodes,
}: {
createMissing?: boolean
fromLevelId: AnyNodeId | string | null | undefined
nodes: Record<string, AnyNode>
}) {
if (!fromLevelId) return null
const fromLevel = nodes[fromLevelId as AnyNodeId]
if (!isLevelNode(fromLevel)) return null
const buildingId = resolveBuildingForLevel(
fromLevel.id as AnyNodeId,
nodes as Record<AnyNodeId, AnyNode>,
)
const levels = getBuildingLevelsForLevel(nodes, fromLevel.id)
const nextExistingLevel = levels.find((level) => level.level > fromLevel.level) ?? null
if (nextExistingLevel) {
return {
buildingId,
createdLevel: null,
fromLevel,
levels,
toLevel: nextExistingLevel,
}
}
if (createMissing && buildingId) {
const createdLevel = LevelNode.parse({
children: [],
level: fromLevel.level + 1,
parentId: buildingId,
})
return {
buildingId,
createdLevel,
fromLevel,
levels: sortLevelsByHeight([...levels, createdLevel]),
toLevel: createdLevel,
}
}
return {
buildingId,
createdLevel: null,
fromLevel,
levels,
toLevel: fromLevel,
}
}
@@ -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 { buildSlabFloorplan } from './floorplan'
import { import {
slabAddVertexAffordance, slabAddVertexAffordance,
@@ -13,8 +18,7 @@ import { SlabNode } from './schema'
const HEIGHT_HANDLE_OFFSET = 0.22 const HEIGHT_HANDLE_OFFSET = 0.22
const MIN_SLAB_ELEVATION = 0.02 const MIN_SLAB_ELEVATION = 0.02
function slabPolygonCenter(n: SlabNodeType): [number, number] { function polygonVertexAverage(polygon: SlabNodeType['polygon']): [number, number] {
const polygon = n.polygon ?? []
if (polygon.length === 0) return [0, 0] if (polygon.length === 0) return [0, 0]
let cx = 0 let cx = 0
let cz = 0 let cz = 0
@@ -25,11 +29,68 @@ function slabPolygonCenter(n: SlabNodeType): [number, number] {
return [cx / polygon.length, cz / polygon.length] return [cx / polygon.length, cz / polygon.length]
} }
// Slab height arrow — vertical chevron at the polygon centroid, just function pointIsOnSolidSlab(point: [number, number], slab: SlabNodeType) {
// above the slab's top face. Drags elevation (the extrusion thickness) if (!pointInPolygon2D(point, slab.polygon, { includeBoundary: false })) return false
// with `anchor: 'min'` so the bottom stays at world Y=0 and the top return !(slab.holes ?? []).some(
// follows the pointer. Same registry-handle pipeline as the column (hole) => hole.length >= 3 && pointInPolygon2D(point, hole, { includeBoundary: true }),
// height arrow, so live override + commit-on-release come for free. )
}
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> { function slabHeightHandle(): HandleDescriptor<SlabNodeType> {
return { return {
kind: 'linear-resize', kind: 'linear-resize',
@@ -40,7 +101,7 @@ function slabHeightHandle(): HandleDescriptor<SlabNodeType> {
apply: (_n, newValue) => ({ elevation: newValue }), apply: (_n, newValue) => ({ elevation: newValue }),
placement: { placement: {
position: (n) => { position: (n) => {
const [cx, cz] = slabPolygonCenter(n) const [cx, cz] = slabHandleAnchor(n)
const elevation = n.elevation ?? 0.05 const elevation = n.elevation ?? 0.05
return [cx, elevation + HEIGHT_HANDLE_OFFSET, cz] return [cx, elevation + HEIGHT_HANDLE_OFFSET, cz]
}, },
+49 -16
View File
@@ -18,9 +18,13 @@ import {
ActionGroup, ActionGroup,
DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE, DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE,
duplicateStairSubtree, duplicateStairSubtree,
getStairLevelOptions,
MetricControl, MetricControl,
PanelSection, PanelSection,
PanelWrapper, PanelWrapper,
resolveStairDestinationLevel,
resolveStairFromLevelId,
resolveStairToLevelId,
SegmentedControl, SegmentedControl,
SliderControl, SliderControl,
ToggleControl, ToggleControl,
@@ -29,7 +33,7 @@ import {
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { useCallback, useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [ const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
@@ -62,16 +66,14 @@ export default function StairPanel() {
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode) const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const nodes = useScene((s) => s.nodes)
const node = useScene((s) => const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
) )
const levels = useScene( const levels = useMemo<LevelNode[]>(
useShallow((s) => () => (node?.type === 'stair' ? getStairLevelOptions(nodes, node) : []),
Object.values(s.nodes) [node, nodes],
.filter((entry): entry is LevelNode => entry.type === 'level')
.sort((left, right) => left.level - right.level),
),
) )
const segments = useScene( const segments = useScene(
useShallow((s) => { useShallow((s) => {
@@ -96,6 +98,41 @@ export default function StairPanel() {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [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(() => { const getLastSegmentFillDefaults = useCallback(() => {
if (!node) return { fillToFloor: true } if (!node) return { fillToFloor: true }
const children = node.children ?? [] const children = node.children ?? []
@@ -184,8 +221,8 @@ export default function StairPanel() {
if (!(node && node.type === 'stair' && selectedId && selectedCount === 1)) return null if (!(node && node.type === 'stair' && selectedId && selectedCount === 1)) return null
const resolvedFromLevelId = node.fromLevelId ?? node.parentId ?? levels[0]?.id ?? null const resolvedFromLevelId = resolveStairFromLevelId(nodes, node, levels)
const resolvedToLevelId = node.toLevelId ?? resolvedFromLevelId const resolvedToLevelId = resolveStairToLevelId(nodes, node, resolvedFromLevelId, levels)
return ( return (
<PanelWrapper <PanelWrapper
@@ -217,11 +254,7 @@ export default function StairPanel() {
<ToggleControl <ToggleControl
checked={(node.slabOpeningMode ?? 'none') === 'destination'} checked={(node.slabOpeningMode ?? 'none') === 'destination'}
label="Auto Cutout" label="Auto Cutout"
onChange={(checked) => onChange={handleAutoCutoutChange}
handleUpdate({
slabOpeningMode: checked ? 'destination' : 'none',
})
}
/> />
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -230,7 +263,7 @@ export default function StairPanel() {
</div> </div>
<select <select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm" 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 ?? ''} value={resolvedFromLevelId ?? ''}
> >
{levels.map((level) => ( {levels.map((level) => (
@@ -259,7 +292,7 @@ export default function StairPanel() {
</div> </div>
<SegmentedControl <SegmentedControl
onChange={(value) => handleUpdate({ slabOpeningMode: value as StairSlabOpeningMode })} onChange={(value) => handleAutoCutoutChange(value === 'destination')}
options={STAIR_SLAB_OPENING_OPTIONS} options={STAIR_SLAB_OPENING_OPTIONS}
value={node.slabOpeningMode ?? 'none'} value={node.slabOpeningMode ?? 'none'}
/> />
+45 -1
View File
@@ -1,7 +1,7 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not // @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
// depend on @types/bun so the import type is unresolved at compile time. // depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { type Point2D, unionPolygons } from './polygon-union' import { type Point2D, subtractPolygonsFromPolygon, unionPolygons } from './polygon-union'
function polygonArea(points: Point2D[]) { function polygonArea(points: Point2D[]) {
let area = 0 let area = 0
@@ -75,3 +75,47 @@ describe('unionPolygons', () => {
expect(result.map(polygonArea)).toEqual([1, 1]) expect(result.map(polygonArea)).toEqual([1, 1])
}) })
}) })
describe('subtractPolygonsFromPolygon', () => {
test('turns a boundary-overlapping cutter into an indentation', () => {
const slab: Point2D[] = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
const cutout: Point2D[] = [
[1, -0.5],
[3, -0.5],
[3, 1],
[1, 1],
]
const result = subtractPolygonsFromPolygon(slab, [cutout])
expect(result).toHaveLength(1)
expect(result[0]).toContainEqual([1, 1])
expect(result[0]).toContainEqual([3, 1])
expect(polygonArea(result[0]!)).toBeCloseTo(10)
})
test('returns separate contours when a cutter splits the subject', () => {
const slab: Point2D[] = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
const cutout: Point2D[] = [
[1.5, -1],
[2.5, -1],
[2.5, 4],
[1.5, 4],
]
const result = subtractPolygonsFromPolygon(slab, [cutout])
expect(result).toHaveLength(2)
expect(result.map(polygonArea).sort((a, b) => a - b)).toEqual([4.5, 4.5])
})
})
+74
View File
@@ -53,6 +53,18 @@ function pointOnSegment(point: Point2D, start: Point2D, end: Point2D) {
return dot <= EPSILON return dot <= EPSILON
} }
function pointInPolygonOrOnBoundary(point: Point2D, polygon: Point2D[]) {
if (
polygon.some((start, index) =>
pointOnSegment(point, start, polygon[(index + 1) % polygon.length]!),
)
) {
return true
}
return pointInPolygon(point, polygon)
}
function pointInPolygon(point: Point2D, polygon: Point2D[]) { function pointInPolygon(point: Point2D, polygon: Point2D[]) {
let inside = false let inside = false
@@ -292,3 +304,65 @@ export function unionPolygons(polygons: Point2D[][]): Point2D[][] {
return rings.length > 0 ? rings : validPolygons return rings.length > 0 ? rings : validPolygons
} }
function buildDifferenceBoundarySegments(edges: Edge[], polygons: Point2D[][]) {
const subject = polygons[0]
const cutters = polygons.slice(1)
if (!subject) return []
const segments: Segment[] = []
for (const edge of edges) {
const splits = [...edge.splits].sort((a, b) => a - b)
for (let i = 0; i < splits.length - 1; i++) {
const startT = splits[i]!
const endT = splits[i + 1]!
if (endT - startT <= EPSILON) continue
const start = interpolate(edge.start, edge.end, startT)
const end = interpolate(edge.start, edge.end, endT)
const mid = interpolate(edge.start, edge.end, (startT + endT) / 2)
if (edge.polygonIndex === 0) {
const insideCutter = cutters.some((cutter) => pointInPolygonOrOnBoundary(mid, cutter))
if (!insideCutter) {
segments.push({ start, end, used: false })
}
continue
}
const insideSubject = pointInPolygon(mid, subject)
const insideAnotherCutter = cutters.some(
(cutter, cutterIndex) =>
cutterIndex !== edge.polygonIndex - 1 && pointInPolygonOrOnBoundary(mid, cutter),
)
if (insideSubject && !insideAnotherCutter) {
segments.push({ start: end, end: start, used: false })
}
}
}
return removeDuplicateInteriorSegments(segments)
}
export function subtractPolygonsFromPolygon(subject: Point2D[], cutters: Point2D[][]): Point2D[][] {
const validSubject = normalizeRing(subject)
if (validSubject.length < 3) return []
const validCutters = cutters.map(normalizeRing).filter((polygon) => polygon.length >= 3)
if (validCutters.length === 0) return [validSubject]
const polygons = [validSubject, ...validCutters]
const edges = buildEdges(polygons)
const segments = buildDifferenceBoundarySegments(edges, polygons)
const rings = assembleRings(segments)
if (rings.length > 0) return rings
const fullyCovered = validSubject.every((point) =>
validCutters.some((cutter) => pointInPolygonOrOnBoundary(point, cutter)),
)
return fullyCovered ? [] : [validSubject]
}
@@ -0,0 +1,70 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import { SlabNode } from '@pascal-app/core'
import type * as THREE from 'three'
import { generateSlabGeometry } from './slab-system'
function hasVertexAt(geometry: THREE.BufferGeometry, x: number, z: number) {
const positions = geometry.getAttribute('position')
for (let index = 0; index < positions.count; index += 1) {
if (Math.abs(positions.getX(index) - x) < 1e-6 && Math.abs(positions.getZ(index) - z) < 1e-6) {
return true
}
}
return false
}
describe('generateSlabGeometry', () => {
test('renders a boundary-overlapping hole as an open indentation', () => {
const slab = SlabNode.parse({
elevation: 0.05,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
holes: [
[
[1, -0.5],
[3, -0.5],
[3, 1],
[1, 1],
],
],
})
const geometry = generateSlabGeometry(slab)
expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0)
expect(hasVertexAt(geometry, 1, 1)).toBe(true)
expect(hasVertexAt(geometry, 3, 1)).toBe(true)
})
test('renders a boundary-overlapping hole as an open indentation on recessed slabs', () => {
const slab = SlabNode.parse({
elevation: -0.2,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
holes: [
[
[1, -0.5],
[3, -0.5],
[3, 1],
[1, 1],
],
],
})
const geometry = generateSlabGeometry(slab)
expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0)
expect(hasVertexAt(geometry, 1, 1)).toBe(true)
expect(hasVertexAt(geometry, 3, 1)).toBe(true)
})
})
+113 -66
View File
@@ -1,6 +1,10 @@
import { import {
type AnyNodeId, type AnyNodeId,
getEffectiveNode,
getRenderableSlabPolygon, getRenderableSlabPolygon,
type PolygonPoint2D,
pointInPolygon2D,
polygonsIntersect,
type SlabNode, type SlabNode,
sceneRegistry, sceneRegistry,
useScene, useScene,
@@ -8,6 +12,7 @@ import {
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect } from 'react' import { useEffect } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { subtractPolygonsFromPolygon } from '../../lib/polygon-union'
import { mergeSurfaceHolePolygons } from '../surface-hole-geometry' import { mergeSurfaceHolePolygons } from '../surface-hole-geometry'
function ensureUv2Attribute(geometry: THREE.BufferGeometry) { function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
@@ -47,7 +52,7 @@ export const SlabSystem = () => {
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
if (mesh) { if (mesh) {
updateSlabGeometry(node as SlabNode, mesh) updateSlabGeometry(getEffectiveNode(node as SlabNode), mesh)
clearDirty(id as AnyNodeId) clearDirty(id as AnyNodeId)
} }
// If mesh not found, keep it dirty for next frame // If mesh not found, keep it dirty for next frame
@@ -95,6 +100,40 @@ function ensureCounterClockwisePolygon(polygon: Array<[number, number]>): Array<
return area2 < 0 ? [...polygon].reverse() : polygon return area2 < 0 ? [...polygon].reverse() : polygon
} }
function isStrictInteriorHole(contour: PolygonPoint2D[], hole: PolygonPoint2D[]) {
return (
hole.every((point) => pointInPolygon2D(point, contour, { includeBoundary: false })) &&
!polygonsIntersect(contour, hole)
)
}
function affectsContour(contour: PolygonPoint2D[], hole: PolygonPoint2D[]) {
return (
polygonsIntersect(contour, hole) ||
hole.some((point) => pointInPolygon2D(point, contour, { includeBoundary: false })) ||
contour.some((point) => pointInPolygon2D(point, hole, { includeBoundary: false }))
)
}
function buildSlabRegions(contour: PolygonPoint2D[], holes: PolygonPoint2D[][]) {
const containedHoles: PolygonPoint2D[][] = []
const edgeCutouts: PolygonPoint2D[][] = []
for (const hole of holes) {
if (hole.length < 3) continue
if (isStrictInteriorHole(contour, hole)) containedHoles.push(hole)
else if (affectsContour(contour, hole)) edgeCutouts.push(hole)
}
const contours =
edgeCutouts.length > 0 ? subtractPolygonsFromPolygon(contour, edgeCutouts) : [contour]
return contours.map((regionContour) => ({
contour: regionContour,
holes: containedHoles.filter((hole) => isStrictInteriorHole(regionContour, hole)),
}))
}
/** /**
* Standard slab: flat extrusion upward from Y=0 by elevation thickness. * Standard slab: flat extrusion upward from Y=0 by elevation thickness.
* *
@@ -118,35 +157,6 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
const uvs: number[] = [] const uvs: number[] = []
const indices: number[] = [] const indices: number[] = []
const contour2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!))
const holes2d = holePolygons
.filter((h) => h.length >= 3)
.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
// --- Top & bottom caps ---
// capPoints order (contour then holes) matches triangulateShape's index space.
// UVs reproduce ExtrudeGeometry's WorldUVGenerator mapping (shape-space x,-z)
// so textured slabs keep the same floor projection.
const capPoints = [...contour2d, ...holes2d.flat()]
const topBase = positions.length / 3
for (const p of capPoints) {
positions.push(p.x, elevation, p.y)
uvs.push(p.x, -p.y)
}
const bottomBase = positions.length / 3
for (const p of capPoints) {
positions.push(p.x, 0, p.y)
uvs.push(p.x, -p.y)
}
const capTris = THREE.ShapeUtils.triangulateShape(contour2d, holes2d)
for (const tri of capTris) {
const [a, b, c] = [tri[0]!, tri[1]!, tri[2]!]
// Reversed winding → +Y normal on top; standard winding → -Y on bottom.
indices.push(topBase + a, topBase + c, topBase + b)
indices.push(bottomBase + a, bottomBase + b, bottomBase + c)
}
// --- Side walls --- // --- Side walls ---
// Each segment gets its own 4 verts so computeVertexNormals doesn't average // Each segment gets its own 4 verts so computeVertexNormals doesn't average
// across faces. Outer walls are single-sided with outward normals; hole walls // across faces. Outer walls are single-sided with outward normals; hole walls
@@ -171,15 +181,49 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
} }
} }
for (let i = 0; i < contour2d.length; i++) { for (const region of buildSlabRegions(polygon, holePolygons)) {
addWall(contour2d[i]!, contour2d[(i + 1) % contour2d.length]!, false) const contour2d = ensureCounterClockwisePolygon(region.contour).map(
} ([x, z]) => new THREE.Vector2(x!, z!),
for (const hole of holes2d) { )
for (let i = 0; i < hole.length; i++) { const holes2d = region.holes
const a = hole[i]! .filter((h) => h.length >= 3)
const b = hole[(i + 1) % hole.length]! .map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
addWall(a, b, false)
addWall(a, b, true) // --- Top & bottom caps ---
// capPoints order (contour then holes) matches triangulateShape's index space.
// UVs reproduce ExtrudeGeometry's WorldUVGenerator mapping (shape-space x,-z)
// so textured slabs keep the same floor projection.
const capPoints = [...contour2d, ...holes2d.flat()]
const topBase = positions.length / 3
for (const p of capPoints) {
positions.push(p.x, elevation, p.y)
uvs.push(p.x, -p.y)
}
const bottomBase = positions.length / 3
for (const p of capPoints) {
positions.push(p.x, 0, p.y)
uvs.push(p.x, -p.y)
}
const capTris = THREE.ShapeUtils.triangulateShape(contour2d, holes2d)
for (const tri of capTris) {
const [a, b, c] = [tri[0]!, tri[1]!, tri[2]!]
// Reversed winding → +Y normal on top; standard winding → -Y on bottom.
indices.push(topBase + a, topBase + c, topBase + b)
indices.push(bottomBase + a, bottomBase + b, bottomBase + c)
}
for (let i = 0; i < contour2d.length; i++) {
addWall(contour2d[i]!, contour2d[(i + 1) % contour2d.length]!, false)
}
for (const hole of holes2d) {
for (let i = 0; i < hole.length; i++) {
const a = hole[i]!
const b = hole[(i + 1) % hole.length]!
addWall(a, b, false)
addWall(a, b, true)
}
} }
} }
@@ -210,7 +254,6 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const positions: number[] = [] const positions: number[] = []
const uvs: number[] = [] const uvs: number[] = []
const indices: number[] = [] const indices: number[] = []
const n = polygon.length
const bounds = new THREE.Box2() const bounds = new THREE.Box2()
for (const [x, z] of polygon) { for (const [x, z] of polygon) {
@@ -235,37 +278,41 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
uvs.push(u, v) uvs.push(u, v)
} }
// --- Floor at Y=0 --- for (const region of buildSlabRegions(polygon, holePolygons)) {
for (const [x, z] of polygon) pushFloorVertex(x!, 0, z!) const contour = ensureCounterClockwisePolygon(region.contour)
const floorBase = positions.length / 3
const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!)) // --- Floor at Y=0 ---
const holesPts2d = holePolygons.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!))) for (const [x, z] of contour) pushFloorVertex(x!, 0, z!)
for (const hole of holePolygons) { const pts2d = contour.map(([x, z]) => new THREE.Vector2(x!, z!))
for (const [x, z] of hole) pushFloorVertex(x!, 0, z!) const holesPts2d = region.holes.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
} for (const hole of region.holes) {
for (const [x, z] of hole) pushFloorVertex(x!, 0, z!)
}
const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d) const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d)
for (const tri of floorTris) { for (const tri of floorTris) {
// Reversed winding → normals point +Y (upward) in XZ plane // Reversed winding → normals point +Y (upward) in XZ plane
indices.push(tri[0]!, tri[2]!, tri[1]!) indices.push(floorBase + tri[0]!, floorBase + tri[2]!, floorBase + tri[1]!)
} }
// --- Inner walls (no top cap at Y=depth) --- // --- Inner walls (no top cap at Y=depth) ---
// Standard winding on a CCW polygon in XZ gives inward-facing normals. // Standard winding on a CCW polygon in XZ gives inward-facing normals.
for (let i = 0; i < n; i++) { for (let i = 0; i < contour.length; i++) {
const j = (i + 1) % n const j = (i + 1) % contour.length
const [x0, z0] = polygon[i]! const [x0, z0] = contour[i]!
const [x1, z1] = polygon[j]! const [x1, z1] = contour[j]!
const vBase = positions.length / 3 const vBase = positions.length / 3
const segmentLength = Math.max(Math.hypot(x1 - x0, z1 - z0), 0.001) const segmentLength = Math.max(Math.hypot(x1 - x0, z1 - z0), 0.001)
pushWallVertex(x0!, 0, z0!, 0, 0) // v0 — floor level pushWallVertex(x0!, 0, z0!, 0, 0) // v0 — floor level
pushWallVertex(x1!, 0, z1!, segmentLength, 0) // v1 — floor level pushWallVertex(x1!, 0, z1!, segmentLength, 0) // v1 — floor level
pushWallVertex(x1!, depth, z1!, segmentLength, depth) // v2 — ground level pushWallVertex(x1!, depth, z1!, segmentLength, depth) // v2 — ground level
pushWallVertex(x0!, depth, z0!, 0, depth) // v3 — ground level pushWallVertex(x0!, depth, z0!, 0, depth) // v3 — ground level
indices.push(vBase, vBase + 1, vBase + 2) indices.push(vBase, vBase + 1, vBase + 2)
indices.push(vBase, vBase + 2, vBase + 3) indices.push(vBase, vBase + 2, vBase + 3)
}
} }
const geo = new THREE.BufferGeometry() const geo = new THREE.BufferGeometry()