Fix stair opening previews and slab cutouts
This commit is contained in:
@@ -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'
|
||||
|
||||
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 ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
|
||||
const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id })
|
||||
@@ -74,6 +74,255 @@ describe('syncAutoStairOpenings', () => {
|
||||
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', () => {
|
||||
const building = BuildingNode.parse({ name: 'Building' })
|
||||
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 {
|
||||
AnyNode,
|
||||
AnyNodeId,
|
||||
@@ -11,8 +12,6 @@ import type {
|
||||
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
|
||||
import { computeSegmentTransforms, rotateXZ } from './stair-footprint'
|
||||
|
||||
type Point2D = [number, number]
|
||||
|
||||
type SegmentTransform = {
|
||||
position: [number, number, number]
|
||||
rotation: number
|
||||
@@ -85,10 +84,106 @@ function getLevelNumber(levelId: string | null, nodes: Record<string, AnyNode>)
|
||||
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>) {
|
||||
const parentLevelId = resolveLevelId(stair, nodes)
|
||||
const fromLevelId = stair.fromLevelId ?? parentLevelId
|
||||
const toLevelId = stair.toLevelId ?? fromLevelId
|
||||
const parentLevelId = normalizeLevelId(resolveLevelId(stair, nodes), nodes)
|
||||
const explicitToLevelId = normalizeLevelId(stair.toLevelId, nodes)
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -192,36 +287,6 @@ function polygonArea(points: Point2D[]) {
|
||||
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[]) {
|
||||
return existingHoles.some((existingHole) => polygonContainsPolygon(existingHole, autoHole))
|
||||
}
|
||||
@@ -409,13 +474,14 @@ function getStraightOpeningPolygonsForSurface(
|
||||
stair: StairNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
targetElevation: number,
|
||||
openingOffsetOverride?: number,
|
||||
) {
|
||||
const layouts = getStraightStairLayouts(stair, nodes)
|
||||
if (layouts.length === 0) return []
|
||||
|
||||
const riserHeight = (stair.totalRise ?? 2.5) / Math.max(stair.stepCount ?? 10, 1)
|
||||
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[] = []
|
||||
|
||||
for (let index = 0; index < layouts.length; index += 1) {
|
||||
@@ -493,22 +559,22 @@ function getStairOpeningPolygons(
|
||||
stair: StairNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
targetElevation?: number,
|
||||
openingOffsetOverride?: number,
|
||||
) {
|
||||
if ((stair.slabOpeningMode ?? 'none') !== 'destination') {
|
||||
return []
|
||||
}
|
||||
|
||||
const openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0)
|
||||
|
||||
if (stair.stairType === 'curved') {
|
||||
return [
|
||||
getCurvedOpeningPolygon(
|
||||
stair,
|
||||
Math.max((stair.openingOffset ?? 0) - STAIR_SLAB_OPENING_TIGHTENING, 0.15),
|
||||
),
|
||||
getCurvedOpeningPolygon(stair, Math.max(openingOffset - STAIR_SLAB_OPENING_TIGHTENING, 0)),
|
||||
]
|
||||
}
|
||||
|
||||
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)]
|
||||
if (stair.topLandingMode === 'integrated') {
|
||||
polygons.push(getSpiralLandingPolygon(stair, offset))
|
||||
@@ -517,16 +583,41 @@ function getStairOpeningPolygons(
|
||||
}
|
||||
|
||||
if (typeof targetElevation === 'number') {
|
||||
return getStraightOpeningPolygonsForSurface(stair, nodes, targetElevation)
|
||||
return getStraightOpeningPolygonsForSurface(stair, nodes, targetElevation, openingOffset)
|
||||
}
|
||||
|
||||
return getStraightOpeningPolygonsForSurface(
|
||||
stair,
|
||||
nodes,
|
||||
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(
|
||||
stair: StairNode,
|
||||
slab: SlabNode,
|
||||
@@ -579,6 +670,8 @@ function shouldApplyStairToSlab(
|
||||
const toLevel = getLevelNumber(toLevelId, nodes)
|
||||
const slabLevel = getLevelNumber(slabLevelId, nodes)
|
||||
|
||||
if (!isInStairBuildingScope(stair, slabLevelId, nodes)) return false
|
||||
|
||||
if (slabLevel === undefined) {
|
||||
return toLevelId === slabLevelId
|
||||
}
|
||||
@@ -602,6 +695,8 @@ function shouldApplyStairToCeiling(
|
||||
const toLevel = getLevelNumber(toLevelId, nodes)
|
||||
const ceilingLevel = getLevelNumber(ceilingLevelId, nodes)
|
||||
|
||||
if (!isInStairBuildingScope(stair, ceilingLevelId, nodes)) return false
|
||||
|
||||
if (ceilingLevel === undefined) {
|
||||
return fromLevelId === ceilingLevelId
|
||||
}
|
||||
@@ -637,10 +732,11 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
||||
const stairHoles = stairs
|
||||
.filter((stair) => shouldApplyStairToSlab(stair, slabLevelId, nodes))
|
||||
.flatMap((stair) =>
|
||||
getStairOpeningPolygons(
|
||||
getApplicableStairOpeningPolygons(
|
||||
stair,
|
||||
nodes,
|
||||
getTargetSlabElevationForStair(stair, slab, slabLevelId, nodes),
|
||||
slab.polygon,
|
||||
).map((polygon) => ({
|
||||
polygon,
|
||||
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))
|
||||
|
||||
const nextHoles = [
|
||||
@@ -686,10 +781,11 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
||||
const stairHoles = stairs
|
||||
.filter((stair) => shouldApplyStairToCeiling(stair, ceilingLevelId, nodes))
|
||||
.flatMap((stair) =>
|
||||
getStairOpeningPolygons(
|
||||
getApplicableStairOpeningPolygons(
|
||||
stair,
|
||||
nodes,
|
||||
getTargetCeilingElevationForStair(stair, ceiling, ceilingLevelId, nodes),
|
||||
ceiling.polygon,
|
||||
).map((polygon) => ({
|
||||
polygon,
|
||||
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))
|
||||
|
||||
const nextHoles = [
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
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 {
|
||||
createSurfaceOpeningPreviewController,
|
||||
getNodesWithLiveStairOpeningInputs,
|
||||
hasLiveStairOpeningInputs,
|
||||
} from './stair-opening-preview'
|
||||
import { syncAutoStairOpenings } from './stair-opening-sync'
|
||||
|
||||
function isOpeningRelevantNode(node: AnyNode | undefined) {
|
||||
@@ -35,24 +43,90 @@ function hasOpeningRelevantNodeChange(
|
||||
|
||||
export const StairOpeningSystem = () => {
|
||||
const syncingAutoOpeningsRef = useRef(false)
|
||||
const syncingPreviewOpeningsRef = useRef(false)
|
||||
const previewControllerRef = useRef(createSurfaceOpeningPreviewController())
|
||||
|
||||
useEffect(() => {
|
||||
const applyUpdates = (updates: ReturnType<typeof syncAutoStairOpenings>) => {
|
||||
if (updates.length === 0) return
|
||||
syncingAutoOpeningsRef.current = true
|
||||
useScene.getState().updateNodes(updates)
|
||||
pauseSceneHistory(useScene)
|
||||
try {
|
||||
useScene.getState().updateNodes(updates)
|
||||
} finally {
|
||||
resumeSceneHistory(useScene)
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
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 (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return
|
||||
applyUpdates(syncAutoStairOpenings(state.nodes))
|
||||
refreshLivePreview()
|
||||
})
|
||||
|
||||
const unsubscribeLiveTransforms = useLiveTransforms.subscribe(() => {
|
||||
refreshLivePreview()
|
||||
})
|
||||
|
||||
const unsubscribeLiveOverrides = useLiveNodeOverrides.subscribe(() => {
|
||||
refreshLivePreview()
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribeScene()
|
||||
unsubscribeLiveTransforms()
|
||||
unsubscribeLiveOverrides()
|
||||
previewControllerRef.current.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
|
||||
Reference in New Issue
Block a user