From 03f57b1f4f1771e084265246f440efa5679ac5e3 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 7 Jun 2026 00:46:51 -0400 Subject: [PATCH] Fix stair opening previews and slab cutouts --- packages/core/src/index.ts | 10 + packages/core/src/lib/polygon-relations.ts | 102 +++++++ .../core/src/store/use-live-node-overrides.ts | 19 ++ packages/core/src/store/use-scene.ts | 2 +- .../stair/stair-opening-preview.test.ts | 99 +++++++ .../systems/stair/stair-opening-preview.ts | 120 +++++++++ .../systems/stair/stair-opening-sync.test.ts | 251 +++++++++++++++++- .../src/systems/stair/stair-opening-sync.ts | 189 +++++++++---- .../systems/stair/stair-opening-system.tsx | 80 +++++- .../editor/slab-hole-highlights.tsx | 42 +-- .../src/components/tools/stair/stair-tool.tsx | 160 ++++++++--- packages/editor/src/index.tsx | 8 + packages/editor/src/lib/stair-levels.test.ts | 146 ++++++++++ packages/editor/src/lib/stair-levels.ts | 177 ++++++++++++ .../src/slab/__tests__/definition.test.ts | 43 +++ packages/nodes/src/slab/definition.ts | 79 +++++- packages/nodes/src/stair/panel.tsx | 65 +++-- packages/viewer/src/lib/polygon-union.test.ts | 46 +++- packages/viewer/src/lib/polygon-union.ts | 74 ++++++ .../src/systems/slab/slab-system.test.ts | 70 +++++ .../viewer/src/systems/slab/slab-system.tsx | 179 ++++++++----- 21 files changed, 1766 insertions(+), 195 deletions(-) create mode 100644 packages/core/src/lib/polygon-relations.ts create mode 100644 packages/core/src/systems/stair/stair-opening-preview.test.ts create mode 100644 packages/core/src/systems/stair/stair-opening-preview.ts create mode 100644 packages/editor/src/lib/stair-levels.test.ts create mode 100644 packages/editor/src/lib/stair-levels.ts create mode 100644 packages/nodes/src/slab/__tests__/definition.test.ts create mode 100644 packages/viewer/src/systems/slab/slab-system.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b9205d7e..86dae30e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -59,6 +59,15 @@ export { isOperationDoorType, SECTIONAL_GARAGE_RENDER_OPEN_SCALE, } 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 { type AutoCeilingPlanningContext, @@ -161,6 +170,7 @@ export { resolveElevatorServiceLevels, } from './systems/elevator/elevator-service' 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 { StairOpeningSystem } from './systems/stair/stair-opening-system' export { diff --git a/packages/core/src/lib/polygon-relations.ts b/packages/core/src/lib/polygon-relations.ts new file mode 100644 index 00000000..72bf2108 --- /dev/null +++ b/packages/core/src/lib/polygon-relations.ts @@ -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)) + ) +} diff --git a/packages/core/src/store/use-live-node-overrides.ts b/packages/core/src/store/use-live-node-overrides.ts index aedb2293..50e6c98b 100644 --- a/packages/core/src/store/use-live-node-overrides.ts +++ b/packages/core/src/store/use-live-node-overrides.ts @@ -8,6 +8,7 @@ type LiveNodeOverrideState = { setMany(entries: ReadonlyArray): void get(nodeId: string): LiveNodeOverrides | undefined clear(nodeId: string): void + clearFields(nodeId: string, keys: readonly string[]): void clearAll(): void } @@ -39,6 +40,24 @@ const useLiveNodeOverrides = create((set, get) => ({ next.delete(nodeId) 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() }), })) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index b253fcaf..90b6efff 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -43,7 +43,7 @@ function getEnumValue( } function getNullableString(value: unknown) { - return typeof value === 'string' ? value : null + return typeof value === 'string' && value.length > 0 ? value : null } function getStringArray(value: unknown) { diff --git a/packages/core/src/systems/stair/stair-opening-preview.test.ts b/packages/core/src/systems/stair/stair-opening-preview.test.ts new file mode 100644 index 00000000..94aa55dd --- /dev/null +++ b/packages/core/src/systems/stair/stair-opening-preview.test.ts @@ -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 + const liveTransforms = new Map([ + [stair.id, { position: [3, 0, 0.2] as [number, number, number], rotation: 0 }], + ]) + const liveOverrides = new Map>() + + 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 + const liveOverrides = new Map>([ + [ + slab.id, + { + holes: [ + [ + [1, 1], + [2, 1], + [2, 2], + [1, 2], + ], + ], + }, + ], + ]) + + expect(hasLiveStairOpeningInputs(nodes, new Map(), liveOverrides, new Set([slab.id]))).toBe( + false, + ) + }) +}) diff --git a/packages/core/src/systems/stair/stair-opening-preview.ts b/packages/core/src/systems/stair/stair-opening-preview.ts new file mode 100644 index 00000000..24b46418 --- /dev/null +++ b/packages/core/src/systems/stair/stair-opening-preview.ts @@ -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 +} + +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, + liveTransforms: ReadonlyMap, + liveOverrides: ReadonlyMap, + previewSurfaceIds: ReadonlySet, +) { + 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, + liveTransforms: ReadonlyMap, + liveOverrides: ReadonlyMap, + previewSurfaceIds: ReadonlySet, +) { + const nextNodes: Record = { ...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() + + 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() + + 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() + }, + } +} diff --git a/packages/core/src/systems/stair/stair-opening-sync.test.ts b/packages/core/src/systems/stair/stair-opening-sync.test.ts index 006a99a7..2ba5169c 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.test.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.test.ts @@ -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 + + 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 + + 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 + + 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 + + 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 + + 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 }) diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index bced29db..def28ce6 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -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) return node?.type === 'level' ? node.level : undefined } +function getLevelBuildingId(levelId: string | null, nodes: Record) { + if (!levelId) return null + return resolveBuildingForLevel(levelId as AnyNodeId, nodes as Record) +} + +function normalizeLevelId(levelId: string | null | undefined, nodes: Record) { + if (!levelId) return null + return nodes[levelId as AnyNodeId]?.type === 'level' ? levelId : null +} + +function getBuildingLevels(buildingId: string | null, nodes: Record) { + const building = buildingId ? nodes[buildingId as AnyNodeId] : null + if (building?.type !== 'building') return [] + + const levels = new Map>() + 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, +) { + 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, +) { + 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, +) { + 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, +) { + 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) { - 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, 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, 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, + 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) { 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) { }, })), ) - .filter((hole) => polygonContainsPolygon(slab.polygon, hole.polygon)) .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) const nextHoles = [ @@ -686,10 +781,11 @@ export function syncAutoStairOpenings(nodes: Record) { 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) { }, })), ) - .filter((hole) => polygonContainsPolygon(ceiling.polygon, hole.polygon)) .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) const nextHoles = [ diff --git a/packages/core/src/systems/stair/stair-opening-system.tsx b/packages/core/src/systems/stair/stair-opening-system.tsx index 2558fcc5..b78224cf 100644 --- a/packages/core/src/systems/stair/stair-opening-system.tsx +++ b/packages/core/src/systems/stair/stair-opening-system.tsx @@ -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) => { 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) => { + 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 diff --git a/packages/editor/src/components/editor/slab-hole-highlights.tsx b/packages/editor/src/components/editor/slab-hole-highlights.tsx index 56468904..4fff543b 100644 --- a/packages/editor/src/components/editor/slab-hole-highlights.tsx +++ b/packages/editor/src/components/editor/slab-hole-highlights.tsx @@ -16,7 +16,6 @@ import { useViewer } from '@pascal-app/viewer' import { createPortal, type ThreeEvent } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useState } from 'react' import { - BoxGeometry, BufferGeometry, DoubleSide, Float32BufferAttribute, @@ -31,7 +30,6 @@ import { swallowNextClick } from './handles/use-handle-drag' const ACCENT = 0x83_81_ed const SURFACE_OFFSET = 0.01 -const HIT_PADDING = 0.08 const MIN_HIT_HEIGHT = 0.16 const NO_RAYCAST = () => null @@ -104,24 +102,34 @@ function makeOutlineGeometry(hole: HolePolygon, y: number): BufferGeometry { } function makeHitGeometry(hole: HolePolygon, centerY: number, height: number): BufferGeometry { - let minX = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let minZ = Number.POSITIVE_INFINITY - let maxZ = Number.NEGATIVE_INFINITY + const topY = centerY + height / 2 + const bottomY = centerY - height / 2 + const positions: number[] = [] + const indices: number[] = [] - for (const [x, z] of hole) { - minX = Math.min(minX, x) - maxX = Math.max(maxX, x) - minZ = Math.min(minZ, z) - maxZ = Math.max(maxZ, z) + for (const [x, z] of hole) positions.push(x, topY, z) + for (const [x, z] of hole) positions.push(x, bottomY, z) + + const triangles = ShapeUtils.triangulateShape( + 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) - const depth = Math.max(maxZ - minZ + HIT_PADDING * 2, HIT_PADDING * 2) - const centerX = (minX + maxX) / 2 - const centerZ = (minZ + maxZ) / 2 - const geometry = new BoxGeometry(width, height, depth) - geometry.translate(centerX, centerY, centerZ) + for (let index = 0; index < hole.length; index += 1) { + const nextIndex = (index + 1) % hole.length + indices.push(index, nextIndex, bottomOffset + nextIndex) + indices.push(index, bottomOffset + nextIndex, bottomOffset + index) + } + + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.setIndex(indices) + geometry.computeVertexNormals() geometry.computeBoundingSphere() return geometry } diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index d152b2b4..f11de2c0 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -1,11 +1,16 @@ import { + type AnyNode, collectAlignmentAnchors, + createSurfaceOpeningPreviewController, + type EventSuffix, emitter, type GridEvent, type LevelNode, + type NodeEvent, resolveAlignment, StairNode, StairSegmentNode, + syncAutoStairOpenings, useAlignmentGuides, useScene, } from '@pascal-app/core' @@ -13,6 +18,10 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { sfxEmitter } from '../../../lib/sfx-bus' +import { + resolveStairDestinationLevel, + resolveStairPlacementLevelId, +} from '../../../lib/stair-levels' import { CursorSphere } from '../shared/cursor-sphere' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { @@ -37,6 +46,21 @@ import { const GRID_OFFSET = 0.02 /** Figma-style alignment-snap threshold (meters), matching the move tools. */ const ALIGNMENT_THRESHOLD_M = 0.08 +type ClickTriggerEvent = GridEvent | NodeEvent + +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. @@ -140,28 +164,42 @@ function commitStairPlacement( rotation: number, ): void { 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 name = `Staircase ${stairCount + 1}` const segment = createDefaultStairSegment() - const sortedLevels = Object.values(nodes) - .filter((node): node is LevelNode => node.type === 'level') - .sort((left, right) => left.level - right.level) - const currentLevelIndex = sortedLevels.findIndex((level) => level.id === levelId) - const nextLevelId = sortedLevels[currentLevelIndex + 1]?.id ?? levelId + const destinationPlan = resolveStairDestinationLevel({ + createMissing: true, + fromLevelId: placementLevelId, + nodes, + }) + const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId const stair = createDefaultStairNode({ name, - levelId, + levelId: placementLevelId, nextLevelId, position, rotation, segmentId: segment.id, }) + const createdLevel = destinationPlan?.createdLevel + const levelCreateOps = + createdLevel && destinationPlan.buildingId + ? [{ node: createdLevel, parentId: destinationPlan.buildingId }] + : [] + createNodes([ - { node: stair, parentId: levelId }, + ...levelCreateOps, + { node: stair, parentId: placementLevelId }, { node: segment, parentId: stair.id }, ]) @@ -181,39 +219,60 @@ export const StairTool: React.FC = () => { useEffect(() => { if (!currentLevelId) return + const openingPreview = createSurfaceOpeningPreviewController() + // Reset rotation when tool activates rotationRef.current = 0 if (previewRef.current) previewRef.current.rotation.y = 0 lastCanonicalPositionRef.current = null - const getPreviewPosition = ( - position: [number, number, number], - rotation: number, - ): [number, number, number] => { + const buildPreviewScene = (position: [number, number, number], rotation: number) => { + const nodes = useScene.getState().nodes + const placementLevelId = resolveStairPlacementLevelId( + 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 stair = createDefaultStairNode({ name: 'Staircase Preview', - levelId: currentLevelId, - nextLevelId: currentLevelId, + levelId: placementLevelId, + nextLevelId, position, rotation, segmentId: segment.id, }) - return getFloorStackPreviewPosition({ - node: stair, - position, - rotation, - levelId: currentLevelId, - nodes: { - ...useScene.getState().nodes, - [stair.id]: stair, - [segment.id]: segment, - }, - }) + const previewNodes = { + ...nodes, + ...(destinationPlan?.createdLevel + ? { [destinationPlan.createdLevel.id]: destinationPlan.createdLevel } + : {}), + [stair.id]: { ...stair, parentId: placementLevelId }, + [segment.id]: { ...segment, parentId: stair.id }, + } as Record + + return { placementLevelId, previewNodes, stair } } - const applyPreview = (position: [number, number, number], rotation: number) => { - const visualPosition = getPreviewPosition(position, rotation) + const applyDraftPreview = (position: [number, number, number], rotation: number) => { + const preview = buildPreviewScene(position, rotation) + const visualPosition = preview + ? getFloorStackPreviewPosition({ + node: preview.stair, + position, + rotation, + levelId: preview.placementLevelId, + nodes: preview.previewNodes, + }) + : position if (cursorRef.current) { cursorRef.current.position.set( visualPosition[0], @@ -226,6 +285,13 @@ export const StairTool: React.FC = () => { previewRef.current.position.set(...visualPosition) previewRef.current.rotation.y = rotation } + + if (!preview) { + openingPreview.clear() + return + } + + openingPreview.apply(syncAutoStairOpenings(preview.previewNodes)) } // 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] lastCanonicalPositionRef.current = position - applyPreview(position, rotationRef.current) + applyDraftPreview(position, rotationRef.current) if ( previousGridPosRef.current && @@ -289,9 +355,7 @@ export const StairTool: React.FC = () => { previousGridPosRef.current = [gridX, gridZ] } - const onGridClick = (event: GridEvent) => { - if (!currentLevelId) return - + const getAlignedGridPosition = (event: GridEvent): [number, number, number] => { const [gridX, gridZ] = alignPoint( Math.round(event.localPosition[0] * 2) / 2, Math.round(event.localPosition[2] * 2) / 2, @@ -299,7 +363,24 @@ export const StairTool: React.FC = () => { event.localPosition[2], 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) : 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) useAlignmentGuides.getState().clear() } @@ -319,7 +400,7 @@ export const StairTool: React.FC = () => { sfxEmitter.emit('sfx:item-rotate') rotationRef.current += rotationDelta if (lastCanonicalPositionRef.current) { - applyPreview(lastCanonicalPositionRef.current, rotationRef.current) + applyDraftPreview(lastCanonicalPositionRef.current, rotationRef.current) } else if (previewRef.current) { previewRef.current.rotation.y = rotationRef.current } @@ -327,14 +408,25 @@ export const StairTool: React.FC = () => { } emitter.on('grid:move', onGridMove) - emitter.on('grid:click', onGridClick) + emitter.on('grid:click', commitAtCursor) + type SuffixedKey = `${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) return () => { 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) useAlignmentGuides.getState().clear() + openingPreview.clear() } }, [currentLevelId]) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index d688a4e1..43a71250 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -209,6 +209,14 @@ export type { SceneGraph } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene' export { triggerSFX } from './lib/sfx-bus' 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/ // nodes` so they don't need their own copy / their own tailwind-merge // dependency. diff --git a/packages/editor/src/lib/stair-levels.test.ts b/packages/editor/src/lib/stair-levels.test.ts new file mode 100644 index 00000000..fa1e9946 --- /dev/null +++ b/packages/editor/src/lib/stair-levels.test.ts @@ -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 + + 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 + + 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 + + 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 + + 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 + + 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 + + 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) + }) +}) diff --git a/packages/editor/src/lib/stair-levels.ts b/packages/editor/src/lib/stair-levels.ts new file mode 100644 index 00000000..d0f1841a --- /dev/null +++ b/packages/editor/src/lib/stair-levels.ts @@ -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) { + return sortLevelsByHeight( + Object.values(nodes).filter((entry): entry is LevelNodeType => entry?.type === 'level'), + ) +} + +function getBuildingLevels( + nodes: Record, + 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() + 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, + 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, + ) + return getBuildingLevels(nodes, buildingId, source) +} + +export function getStairLevelOptions(nodes: Record, 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, + 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, + stair: StairNode, + levels = getStairLevelOptions(nodes, stair), +) { + const optionIds = new Set(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, + stair: StairNode, + fromLevelId: AnyNodeId | string | null | undefined, + levels = getStairLevelOptions(nodes, stair), +) { + const optionIds = new Set(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 +}) { + 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, + ) + 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, + } +} diff --git a/packages/nodes/src/slab/__tests__/definition.test.ts b/packages/nodes/src/slab/__tests__/definition.test.ts new file mode 100644 index 00000000..710400d0 --- /dev/null +++ b/packages/nodes/src/slab/__tests__/definition.test.ts @@ -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) + }) +}) diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index 5f289b89..01d0b971 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -1,4 +1,9 @@ -import type { HandleDescriptor, NodeDefinition, SlabNode as SlabNodeType } from '@pascal-app/core' +import { + type HandleDescriptor, + type NodeDefinition, + pointInPolygon2D, + type SlabNode as SlabNodeType, +} from '@pascal-app/core' import { buildSlabFloorplan } from './floorplan' import { slabAddVertexAffordance, @@ -13,8 +18,7 @@ import { SlabNode } from './schema' const HEIGHT_HANDLE_OFFSET = 0.22 const MIN_SLAB_ELEVATION = 0.02 -function slabPolygonCenter(n: SlabNodeType): [number, number] { - const polygon = n.polygon ?? [] +function polygonVertexAverage(polygon: SlabNodeType['polygon']): [number, number] { if (polygon.length === 0) return [0, 0] let cx = 0 let cz = 0 @@ -25,11 +29,68 @@ function slabPolygonCenter(n: SlabNodeType): [number, number] { return [cx / polygon.length, cz / polygon.length] } -// Slab height arrow — vertical chevron at the polygon centroid, just -// above the slab's top face. Drags elevation (the extrusion thickness) -// with `anchor: 'min'` so the bottom stays at world Y=0 and the top -// follows the pointer. Same registry-handle pipeline as the column -// height arrow, so live override + commit-on-release come for free. +function pointIsOnSolidSlab(point: [number, number], slab: SlabNodeType) { + if (!pointInPolygon2D(point, slab.polygon, { includeBoundary: false })) return false + return !(slab.holes ?? []).some( + (hole) => hole.length >= 3 && pointInPolygon2D(point, hole, { includeBoundary: true }), + ) +} + +function slabHandleAnchor(slab: SlabNodeType): [number, number] { + const polygon = slab.polygon ?? [] + const fallback = polygonVertexAverage(polygon) + if (polygon.length < 3) return fallback + if (pointIsOnSolidSlab(fallback, slab)) return fallback + + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + for (const [x, z] of polygon) { + minX = Math.min(minX, x) + maxX = Math.max(maxX, x) + minZ = Math.min(minZ, z) + maxZ = Math.max(maxZ, z) + } + + const candidates: [number, number][] = [] + for (const point of polygon) { + candidates.push([ + fallback[0] + (point[0] - fallback[0]) * 0.35, + fallback[1] + (point[1] - fallback[1]) * 0.35, + ]) + } + + const steps = 12 + for (let xi = 1; xi < steps; xi += 1) { + const x = minX + ((maxX - minX) * xi) / steps + for (let zi = 1; zi < steps; zi += 1) { + const z = minZ + ((maxZ - minZ) * zi) / steps + candidates.push([x, z]) + } + } + + let best: [number, number] | null = null + let bestDistance = Number.POSITIVE_INFINITY + for (const candidate of candidates) { + if (!pointIsOnSolidSlab(candidate, slab)) continue + const dx = candidate[0] - fallback[0] + const dz = candidate[1] - fallback[1] + const distance = dx * dx + dz * dz + if (distance < bestDistance) { + best = candidate + bestDistance = distance + } + } + + return best ?? fallback +} + +// Slab height arrow — vertical chevron on solid slab surface near the +// polygon center. Drags elevation (the extrusion thickness) with +// `anchor: 'min'` so the bottom stays at world Y=0 and the top follows +// the pointer. Same registry-handle pipeline as the column height arrow, +// so live override + commit-on-release come for free. function slabHeightHandle(): HandleDescriptor { return { kind: 'linear-resize', @@ -40,7 +101,7 @@ function slabHeightHandle(): HandleDescriptor { apply: (_n, newValue) => ({ elevation: newValue }), placement: { position: (n) => { - const [cx, cz] = slabPolygonCenter(n) + const [cx, cz] = slabHandleAnchor(n) const elevation = n.elevation ?? 0.05 return [cx, elevation + HEIGHT_HANDLE_OFFSET, cz] }, diff --git a/packages/nodes/src/stair/panel.tsx b/packages/nodes/src/stair/panel.tsx index 3ecdc505..1b9f2b92 100644 --- a/packages/nodes/src/stair/panel.tsx +++ b/packages/nodes/src/stair/panel.tsx @@ -18,9 +18,13 @@ import { ActionGroup, DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE, duplicateStairSubtree, + getStairLevelOptions, MetricControl, PanelSection, PanelWrapper, + resolveStairDestinationLevel, + resolveStairFromLevelId, + resolveStairToLevelId, SegmentedControl, SliderControl, ToggleControl, @@ -29,7 +33,7 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Plus, Trash2 } from 'lucide-react' -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [ @@ -62,16 +66,14 @@ export default function StairPanel() { const updateNode = useScene((s) => s.updateNode) const createNode = useScene((s) => s.createNode) const setMovingNode = useEditor((s) => s.setMovingNode) + const nodes = useScene((s) => s.nodes) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined, ) - const levels = useScene( - useShallow((s) => - Object.values(s.nodes) - .filter((entry): entry is LevelNode => entry.type === 'level') - .sort((left, right) => left.level - right.level), - ), + const levels = useMemo( + () => (node?.type === 'stair' ? getStairLevelOptions(nodes, node) : []), + [node, nodes], ) const segments = useScene( useShallow((s) => { @@ -96,6 +98,41 @@ export default function StairPanel() { setSelection({ selectedIds: [] }) }, [setSelection]) + const handleAutoCutoutChange = useCallback( + (checked: boolean) => { + if (!node) return + const updates: Partial = { + slabOpeningMode: checked ? 'destination' : 'none', + } + const sceneNodes = useScene.getState().nodes + const fromLevelId = resolveStairFromLevelId(sceneNodes, node) + if (checked && fromLevelId) updates.fromLevelId = fromLevelId + if (checked && (!node.toLevelId || node.toLevelId === fromLevelId)) { + const plan = resolveStairDestinationLevel({ + fromLevelId, + nodes: sceneNodes, + }) + if (plan?.toLevel.id) updates.toLevelId = plan.toLevel.id + } + handleUpdate(updates) + }, + [node, handleUpdate], + ) + + const handleFromLevelChange = useCallback( + (fromLevelId: string) => { + const plan = resolveStairDestinationLevel({ + fromLevelId: fromLevelId as AnyNodeId, + nodes: useScene.getState().nodes, + }) + handleUpdate({ + fromLevelId, + toLevelId: plan?.toLevel.id ?? fromLevelId, + }) + }, + [handleUpdate], + ) + const getLastSegmentFillDefaults = useCallback(() => { if (!node) return { fillToFloor: true } const children = node.children ?? [] @@ -184,8 +221,8 @@ export default function StairPanel() { if (!(node && node.type === 'stair' && selectedId && selectedCount === 1)) return null - const resolvedFromLevelId = node.fromLevelId ?? node.parentId ?? levels[0]?.id ?? null - const resolvedToLevelId = node.toLevelId ?? resolvedFromLevelId + const resolvedFromLevelId = resolveStairFromLevelId(nodes, node, levels) + const resolvedToLevelId = resolveStairToLevelId(nodes, node, resolvedFromLevelId, levels) return ( - handleUpdate({ - slabOpeningMode: checked ? 'destination' : 'none', - }) - } + onChange={handleAutoCutoutChange} />
@@ -230,7 +263,7 @@ export default function StairPanel() {