Merge pull request #380 from pascalorg/bug-fixes-and-improvements

Fix stair openings and editor placement polish
This commit is contained in:
Aymeric Rabot
2026-06-07 01:18:07 -04:00
committed by GitHub
47 changed files with 3610 additions and 960 deletions
+12
View File
@@ -59,8 +59,18 @@ 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,
type AutoCeilingSyncPlan,
type AutoSlabSyncPlan,
detectSpacesForLevel,
@@ -69,6 +79,7 @@ export {
pauseSpaceDetection,
planAutoCeilingsForLevel,
planAutoSlabsForLevel,
projectAutoSlabsForPlan,
resumeSpaceDetection,
type Space,
wallTouchesOthers,
@@ -159,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 {
+102
View File
@@ -0,0 +1,102 @@
export type Point2D = [number, number]
export function pointOnSegment(point: Point2D, start: Point2D, end: Point2D, tolerance = 1e-6) {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const cross = (point[0] - start[0]) * dz - (point[1] - start[1]) * dx
if (Math.abs(cross) > tolerance) return false
const dot =
(point[0] - start[0]) * (point[0] - end[0]) + (point[1] - start[1]) * (point[1] - end[1])
return dot <= tolerance
}
export function pointInPolygon(
point: Point2D,
polygon: Point2D[],
options?: { includeBoundary?: boolean },
) {
if (polygon.length < 3) return false
const includeBoundary = options?.includeBoundary ?? true
if (
polygon.some((start, index) =>
pointOnSegment(point, start, polygon[(index + 1) % polygon.length]!),
)
) {
return includeBoundary
}
let inside = false
const [x, z] = point
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const current = polygon[i]!
const previous = polygon[j]!
const intersects =
current[1] > z !== previous[1] > z &&
x < ((previous[0] - current[0]) * (z - current[1])) / (previous[1] - current[1]) + current[0]
if (intersects) inside = !inside
}
return inside
}
export function segmentsIntersect(a: Point2D, b: Point2D, c: Point2D, d: Point2D) {
const cross = (ux: number, uz: number, vx: number, vz: number) => ux * vz - uz * vx
const abx = b[0] - a[0]
const abz = b[1] - a[1]
const acx = c[0] - a[0]
const acz = c[1] - a[1]
const adx = d[0] - a[0]
const adz = d[1] - a[1]
const cdx = d[0] - c[0]
const cdz = d[1] - c[1]
const cax = a[0] - c[0]
const caz = a[1] - c[1]
const cbx = b[0] - c[0]
const cbz = b[1] - c[1]
const o1 = cross(abx, abz, acx, acz)
const o2 = cross(abx, abz, adx, adz)
const o3 = cross(cdx, cdz, cax, caz)
const o4 = cross(cdx, cdz, cbx, cbz)
if (Math.sign(o1) !== Math.sign(o2) && Math.sign(o3) !== Math.sign(o4)) return true
return (
pointOnSegment(c, a, b) ||
pointOnSegment(d, a, b) ||
pointOnSegment(a, c, d) ||
pointOnSegment(b, c, d)
)
}
export function polygonsIntersect(left: Point2D[], right: Point2D[]) {
for (let leftIndex = 0; leftIndex < left.length; leftIndex++) {
const leftStart = left[leftIndex]!
const leftEnd = left[(leftIndex + 1) % left.length]!
for (let rightIndex = 0; rightIndex < right.length; rightIndex++) {
if (
segmentsIntersect(
leftStart,
leftEnd,
right[rightIndex]!,
right[(rightIndex + 1) % right.length]!,
)
) {
return true
}
}
}
return false
}
export function polygonContainsPolygon(outer: Point2D[], inner: Point2D[]) {
return inner.every((point) => pointInPolygon(point, outer))
}
export function polygonsOverlap(left: Point2D[], right: Point2D[]) {
return (
polygonsIntersect(left, right) ||
left.some((point) => pointInPolygon(point, right)) ||
right.some((point) => pointInPolygon(point, left))
)
}
@@ -0,0 +1,92 @@
import { describe, expect, test } from 'bun:test'
import { CeilingNode, SlabNode, WallNode } from '../schema'
import { planAutoCeilingsForLevel } from './space-detection'
const square: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
function roomPolygon() {
return square.map(([x, y]) => ({ x, y }))
}
function squareWalls(height = 2.5) {
return [
WallNode.parse({ start: [0, 0], end: [4, 0], height }),
WallNode.parse({ start: [4, 0], end: [4, 3], height }),
WallNode.parse({ start: [4, 3], end: [0, 3], height }),
WallNode.parse({ start: [0, 3], end: [0, 0], height }),
]
}
function slab(elevation: number) {
return SlabNode.parse({
polygon: square,
elevation,
autoFromWalls: true,
})
}
describe('planAutoCeilingsForLevel', () => {
test('creates auto ceilings at the top of the room walls', () => {
const created = planAutoCeilingsForLevel([roomPolygon()], [], {
walls: squareWalls(),
slabs: [slab(0.05)],
}).create[0]
expect(created?.height).toBeCloseTo(2.55)
})
test('updates existing auto ceiling height when the slab elevation changes', () => {
const ceiling = CeilingNode.parse({
polygon: square,
height: 2.55,
autoFromWalls: true,
})
const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], {
walls: squareWalls(),
slabs: [slab(0.4)],
})
expect(plan.update).toHaveLength(1)
expect(plan.update[0]?.id).toBe(ceiling.id)
expect(plan.update[0]?.data.polygon).toBeUndefined()
expect(plan.update[0]?.data.height).toBeCloseTo(2.9)
})
test('updates existing auto ceiling height when wall height changes', () => {
const ceiling = CeilingNode.parse({
polygon: square,
height: 2.55,
autoFromWalls: true,
})
const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], {
walls: squareWalls(3),
slabs: [slab(0.05)],
})
expect(plan.update).toHaveLength(1)
expect(plan.update[0]?.data.height).toBeCloseTo(3.05)
})
test('does not replace a manual ceiling with an auto ceiling', () => {
const manualCeiling = CeilingNode.parse({
polygon: square,
height: 2.5,
autoFromWalls: false,
})
const plan = planAutoCeilingsForLevel([roomPolygon()], [manualCeiling], {
walls: squareWalls(),
slabs: [slab(0.4)],
})
expect(plan.create).toHaveLength(0)
expect(plan.update).toHaveLength(0)
})
})
+209 -33
View File
@@ -41,6 +41,10 @@ type DetectedRoom = {
bbox: ReturnType<typeof bboxOf>
}
type DetectedCeilingRoom = DetectedRoom & {
ceilingHeight: number
}
export type AutoSlabSyncPlan = {
create: SlabNodeType[]
update: Array<{ id: SlabNodeType['id']; data: Partial<SlabNodeType> }>
@@ -55,9 +59,16 @@ export type AutoCeilingSyncPlan = {
const DEFAULT_AUTO_SLAB_ELEVATION = 0.05
const DEFAULT_AUTO_CEILING_HEIGHT = 2.5
const CEILING_HEIGHT_EPSILON = 1e-6
const ROOM_CURVE_TOLERANCE = 0.04
const MAX_CURVE_SUBDIVISION_DEPTH = 6
const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08
const WALL_ROOM_BOUNDARY_TOLERANCE = 0.08
export type AutoCeilingPlanningContext = {
walls?: WallNode[]
slabs?: SlabNodeType[]
}
function pointFromTuple(point: [number, number]): Point2D {
return { x: point[0], y: point[1] }
@@ -186,6 +197,100 @@ function bboxOverlapArea(a: ReturnType<typeof bboxOf>, b: ReturnType<typeof bbox
return ix * iy
}
function pointDistanceToPolygonBoundary(point: Point2D, polygon: Point2D[]) {
let minDistance = Number.POSITIVE_INFINITY
for (let index = 0; index < polygon.length; index += 1) {
const start = polygon[index]
const end = polygon[(index + 1) % polygon.length]
if (!(start && end)) continue
minDistance = Math.min(
minDistance,
distanceToSegment(pointToTuple(point), pointToTuple(start), pointToTuple(end)),
)
}
return minDistance
}
function wallBoundsRoom(wall: WallNode, roomPolygon: Point2D[]) {
const sampled = sampleWallPointsForRoomDetection(wall)
if (sampled.length === 0) return false
const candidates =
sampled.length === 2
? [
sampled[0]!,
{
x: (sampled[0]!.x + sampled[1]!.x) / 2,
y: (sampled[0]!.y + sampled[1]!.y) / 2,
},
sampled[1]!,
]
: sampled
const matchingPoints = candidates.filter(
(point) => pointDistanceToPolygonBoundary(point, roomPolygon) <= WALL_ROOM_BOUNDARY_TOLERANCE,
)
return matchingPoints.length >= 2
}
function pointIsOnSlab(point: Point2D, slab: SlabNodeType) {
if (slab.polygon.length < 3) return false
const slabPolygon = slab.polygon.map(pointFromTuple)
if (!pointInPolygon(point, slabPolygon)) return false
for (const hole of slab.holes ?? []) {
if (hole.length >= 3 && pointInPolygon(point, hole.map(pointFromTuple))) {
return false
}
}
return true
}
function slabSupportsRoom(roomPolygon: Point2D[], slab: SlabNodeType) {
if (slab.polygon.length < 3) return false
if (polygonSignature(slab.polygon.map(pointFromTuple)) === polygonSignature(roomPolygon)) {
return true
}
return pointIsOnSlab(polygonCentroid(roomPolygon), slab)
}
function resolveRoomSlabElevation(roomPolygon: Point2D[], slabs: SlabNodeType[] = []) {
let maxElevation = 0
for (const slab of slabs) {
if (!slabSupportsRoom(roomPolygon, slab)) continue
maxElevation = Math.max(maxElevation, slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION)
}
return maxElevation
}
function resolveRoomWallHeight(roomPolygon: Point2D[], walls: WallNode[] = []) {
let maxHeight = 0
for (const wall of walls) {
if (!wallBoundsRoom(wall, roomPolygon)) continue
const height = wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT
if (Number.isFinite(height)) {
maxHeight = Math.max(maxHeight, height)
}
}
return maxHeight > 0 ? maxHeight : DEFAULT_AUTO_CEILING_HEIGHT
}
function resolveAutoCeilingHeight(
roomPolygon: Point2D[],
context: AutoCeilingPlanningContext = {},
) {
return (
resolveRoomSlabElevation(roomPolygon, context.slabs) +
resolveRoomWallHeight(roomPolygon, context.walls)
)
}
function getWallDirection(wall: Pick<WallNode, 'start' | 'end'>) {
const dx = wall.end[0] - wall.start[0]
const dy = wall.end[1] - wall.start[1]
@@ -481,6 +586,7 @@ function wallGeometrySignature(wall: WallNode) {
wall.end[0].toFixed(4),
wall.end[1].toFixed(4),
(wall.thickness ?? 0.2).toFixed(4),
(wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT).toFixed(4),
getClampedWallCurveOffset(wall).toFixed(4),
].join('|')
}
@@ -489,6 +595,48 @@ function levelWallSnapshot(walls: WallNode[]) {
return walls.map(wallGeometrySignature).sort().join('||')
}
function slabGeometrySignature(slab: SlabNodeType) {
const polygon = slab.polygon
.map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`)
.join(';')
const holes = (slab.holes ?? [])
.map((hole) => hole.map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`).join(';'))
.join('/')
return [slab.id, (slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4), polygon, holes].join(
'|',
)
}
function levelSlabSnapshot(slabs: SlabNodeType[]) {
return slabs.map(slabGeometrySignature).sort().join('||')
}
function levelStructureSnapshots(nodes: Record<string, any>) {
const byLevel = new Map<string, { walls: WallNode[]; slabs: SlabNodeType[] }>()
const getEntry = (levelId: string) => {
const entry = byLevel.get(levelId) ?? { walls: [], slabs: [] }
byLevel.set(levelId, entry)
return entry
}
for (const node of Object.values(nodes)) {
if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue
if ((node as any).type === 'wall') {
getEntry((node as any).parentId).walls.push(node as WallNode)
} else if ((node as any).type === 'slab') {
getEntry((node as any).parentId).slabs.push(SlabNode.parse(node))
}
}
const snapshots = new Map<string, string>()
for (const [levelId, entry] of byLevel.entries()) {
snapshots.set(levelId, `${levelWallSnapshot(entry.walls)}##${levelSlabSnapshot(entry.slabs)}`)
}
return snapshots
}
function buildSpace(levelId: string, polygon: Point2D[]): Space {
const signature = polygonSignature(polygon)
return {
@@ -654,18 +802,44 @@ function syncAutoSlabsForLevel(
if (plan.create.length > 0) {
sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId })))
}
return plan
}
export function projectAutoSlabsForPlan(
existingSlabs: SlabNodeType[],
plan: AutoSlabSyncPlan,
): SlabNodeType[] {
const slabsById = new Map(existingSlabs.map((slab) => [slab.id, slab]))
for (const id of plan.delete) {
slabsById.delete(id)
}
for (const update of plan.update) {
const slab = slabsById.get(update.id)
if (!slab) continue
slabsById.set(update.id, SlabNode.parse({ ...slab, ...update.data }))
}
for (const slab of plan.create) {
slabsById.set(slab.id, slab)
}
return [...slabsById.values()]
}
export function planAutoCeilingsForLevel(
roomPolygons: Point2D[][],
existingCeilings: CeilingNodeType[],
context: AutoCeilingPlanningContext = {},
): AutoCeilingSyncPlan {
const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls)
const manualSignatures = new Set(
manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))),
)
const detected: DetectedRoom[] = roomPolygons
const detected: DetectedCeilingRoom[] = roomPolygons
.map((poly) => ({
poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(
pointFromTuple,
@@ -681,6 +855,7 @@ export function planAutoCeilingsForLevel(
centroid: polygonCentroid(room.poly),
area: Math.abs(polygonArea(room.poly)),
bbox: bboxOf(room.poly),
ceilingHeight: resolveAutoCeilingHeight(room.poly, context),
}))
.filter(({ sig }) => !manualSignatures.has(sig))
@@ -698,7 +873,7 @@ export function planAutoCeilingsForLevel(
const matchedCeilingIds = new Set<string>()
const matchedDetectedIdx = new Set<number>()
const updatesById = new Map<string, [number, number][]>()
const updatesById = new Map<string, { polygon: [number, number][]; height: number }>()
const autoBySignature = new Map<string, (typeof existingAutoMeta)[number]>()
for (const entry of existingAutoMeta) {
@@ -711,7 +886,10 @@ export function planAutoCeilingsForLevel(
matchedDetectedIdx.add(index)
matchedCeilingIds.add(existing.ceiling.id)
updatesById.set(existing.ceiling.id, room.poly.map(pointToTuple))
updatesById.set(existing.ceiling.id, {
polygon: room.poly.map(pointToTuple),
height: room.ceilingHeight,
})
})
const remainingDetected = detected
@@ -746,7 +924,10 @@ export function planAutoCeilingsForLevel(
matchedDetectedIdx.add(index)
matchedCeilingIds.add(bestMatch.entry.ceiling.id)
updatesById.set(bestMatch.entry.ceiling.id, room.poly.map(pointToTuple))
updatesById.set(bestMatch.entry.ceiling.id, {
polygon: room.poly.map(pointToTuple),
height: room.ceilingHeight,
})
}
const ceilingsToDelete = existingAuto
@@ -756,12 +937,21 @@ export function planAutoCeilingsForLevel(
const ceilingsToUpdate = existingAuto
.filter((ceiling) => updatesById.has(ceiling.id))
.flatMap((ceiling) => {
const polygon = updatesById.get(ceiling.id)
if (!polygon) return []
const update = updatesById.get(ceiling.id)
if (!update) return []
return sameTuplePolygon(ceiling.polygon, polygon)
? []
: [{ id: ceiling.id, data: { polygon } }]
const data: Partial<CeilingNodeType> = {}
if (!sameTuplePolygon(ceiling.polygon, update.polygon)) {
data.polygon = update.polygon
}
if (
Math.abs((ceiling.height ?? DEFAULT_AUTO_CEILING_HEIGHT) - update.height) >
CEILING_HEIGHT_EPSILON
) {
data.height = update.height
}
return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }]
})
const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings]
@@ -780,7 +970,7 @@ export function planAutoCeilingsForLevel(
name,
polygon: room.poly.map(pointToTuple),
holes: [],
height: DEFAULT_AUTO_CEILING_HEIGHT,
height: room.ceilingHeight,
autoFromWalls: true,
}),
)
@@ -798,8 +988,9 @@ function syncAutoCeilingsForLevel(
roomPolygons: Point2D[][],
existingCeilings: CeilingNodeType[],
sceneStore: any,
context: AutoCeilingPlanningContext = {},
) {
const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings)
const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings, context)
if (plan.delete.length > 0) {
sceneStore.getState().deleteNodes(plan.delete)
@@ -882,17 +1073,15 @@ function runSpaceDetection(
)
}
syncAutoSlabsForLevel(
levelId,
roomPolygons,
slabs.map((slab: any) => SlabNode.parse(slab)),
sceneStore,
)
const parsedSlabs = slabs.map((slab: any) => SlabNode.parse(slab))
const slabPlan = syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore)
const projectedSlabs = projectAutoSlabsForPlan(parsedSlabs, slabPlan)
syncAutoCeilingsForLevel(
levelId,
roomPolygons,
ceilings.map((ceiling: any) => CeilingNode.parse(ceiling)),
sceneStore,
{ walls, slabs: projectedSlabs },
)
for (const space of spaces) {
@@ -935,21 +1124,7 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
if (getSceneHistoryPauseDepth() > 0) return
const nodes = state.nodes
const wallsByLevel = new Map<string, WallNode[]>()
for (const node of Object.values(nodes)) {
if (node && (node as any).type === 'wall' && (node as any).parentId) {
const levelId = (node as any).parentId as string
const levelWalls = wallsByLevel.get(levelId) ?? []
levelWalls.push(node as WallNode)
wallsByLevel.set(levelId, levelWalls)
}
}
const currentSnapshots = new Map<string, string>()
for (const [levelId, walls] of wallsByLevel.entries()) {
currentSnapshots.set(levelId, levelWallSnapshot(walls))
}
const currentSnapshots = levelStructureSnapshots(nodes)
// Paused: roll the snapshot forward so we don't backfill (and re-duplicate)
// every paused change once detection resumes. Whatever the AI built while
@@ -984,7 +1159,8 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
} finally {
resumeSceneHistory(sceneStore)
previousSnapshots.clear()
for (const [levelId, snapshot] of currentSnapshots.entries()) {
const postRunSnapshots = levelStructureSnapshots(sceneStore.getState().nodes)
for (const [levelId, snapshot] of postRunSnapshots.entries()) {
previousSnapshots.set(levelId, snapshot)
}
isProcessing = false
+5 -1
View File
@@ -130,6 +130,8 @@ export type LinearResizeHandle<N> = {
overrideTarget?: (node: N, sceneApi: SceneApi) => AnyNodeId | undefined
min?: number | ((node: N, sceneApi: SceneApi) => number)
max?: number | ((node: N, sceneApi: SceneApi) => number)
/** Snap the resized scalar to the editor's active grid step before apply. */
gridSnap?: boolean
placement: HandlePlacement<N>
/**
* Dimension this handle steers (e.g. `'height'`). When set, the editor
@@ -316,7 +318,9 @@ export type TapActionHandle<N = any> = {
* the hit into the node's parent-local frame, and reports the new local XZ
* (optionally grid-snapped via `snapExtents`) to `apply`. Press-drag-release
* with the same live-override → commit-on-release flow as the resize / rotate
* handles. Rendered as a 4-way cross of double-headed arrows.
* handles. Rendered as a 4-way cross of double-headed arrows. Pure translation
* does not require geometry dirtying; renderers consume the live position
* override directly.
*/
export type TranslateHandle<N = any> = {
kind: 'translate'
+2
View File
@@ -541,6 +541,8 @@ export type FloorplanAffordance<N> = {
nodes: Record<AnyNodeId, AnyNode>
/** Initial pointer position in plan coordinates. */
initialPlanPoint: FloorplanAffordancePoint
/** Active editor grid step in meters. */
gridSnapStep: number
}): FloorplanAffordanceSession
}
+1
View File
@@ -101,6 +101,7 @@ export {
getActiveRoofHeight,
getEffectiveSegmentSurfaceMaterial,
getPitchFromActiveRoofHeight,
getRoofSegmentSurfaceY,
getSegmentSlopeFrame,
hasSegmentMaterialOverride,
ROOF_SHAPE_DEFAULTS,
+36 -1
View File
@@ -181,7 +181,6 @@ function getPrimarySlopeRun(input: PitchInputs & ShapeRatios): number {
return min * input.mansardSteepWidthRatio
case 'dutch':
return min * input.dutchHipWidthRatio
case 'hip':
default:
return min / 2
}
@@ -253,6 +252,42 @@ export function getActiveRoofHeight(node: Parameters<typeof getSegmentSlopeFrame
return getSegmentSlopeFrame(node).activeRh
}
/** Segment-local surface height used by roof accessory placement and hit disambiguation. */
export function getRoofSegmentSurfaceY(
node: Pick<RoofSegmentNode, 'roofType' | 'width' | 'depth' | 'wallHeight'> &
Parameters<typeof getSegmentSlopeFrame>[0],
localX: number,
localZ: number,
): number {
const activeRh = getActiveRoofHeight(node)
const peakY = node.wallHeight + activeRh
if (activeRh === 0) return node.wallHeight
if (
node.roofType === 'gable' ||
node.roofType === 'gambrel' ||
node.roofType === 'mansard' ||
node.roofType === 'dutch'
) {
const t = node.depth > 0 ? Math.abs(localZ) / (node.depth / 2) : 0
return peakY - t * activeRh
}
if (node.roofType === 'shed') {
const t = (localZ + node.depth / 2) / (node.depth || 1)
return peakY - t * activeRh
}
if (node.roofType === 'hip') {
const fx = node.width > 0 ? Math.abs(localX) / (node.width / 2) : 0
const fz = node.depth > 0 ? Math.abs(localZ) / (node.depth / 2) : 0
return peakY - Math.max(fx, fz) * activeRh
}
const t = node.depth > 0 ? Math.abs(localZ) / (node.depth / 2) : 0
return peakY - t * activeRh
}
/**
* Inverse of `getActiveRoofHeight` — recover the pitch a legacy
* `roofHeight` value would correspond to. Used by the scene migration.
@@ -8,6 +8,7 @@ type LiveNodeOverrideState = {
setMany(entries: ReadonlyArray<readonly [string, LiveNodeOverrides]>): 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<LiveNodeOverrideState>((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() }),
}))
+1 -1
View File
@@ -43,7 +43,7 @@ function getEnumValue<T extends readonly string[]>(
}
function getNullableString(value: unknown) {
return typeof value === 'string' ? value : null
return typeof value === 'string' && value.length > 0 ? value : null
}
function getStringArray(value: unknown) {
@@ -0,0 +1,99 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode } from '../../schema'
import { BuildingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema'
import {
getNodesWithLiveStairOpeningInputs,
hasLiveStairOpeningInputs,
} from './stair-opening-preview'
import { syncAutoStairOpenings } from './stair-opening-sync'
describe('stair opening previews', () => {
test('computes auto openings from live stair transforms', () => {
const building = BuildingNode.parse({ name: 'Building' })
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id })
const slab = SlabNode.parse({
name: 'Upper Slab',
parentId: upper.id,
polygon: [
[0, 0],
[5, 0],
[5, 4],
[0, 4],
],
})
const segment = StairSegmentNode.parse({
parentId: 'stair_live',
width: 1,
length: 3,
height: 2.5,
stepCount: 12,
})
const stair = StairNode.parse({
id: 'stair_live',
name: 'Live Stair',
parentId: ground.id,
position: [1, 0, 0.2],
stairType: 'straight',
fromLevelId: ground.id,
toLevelId: upper.id,
slabOpeningMode: 'destination',
children: [segment.id],
})
const nodes = Object.fromEntries(
[building, ground, upper, slab, stair, { ...segment, parentId: stair.id }].map((node) => [
node.id,
node,
]),
) as Record<string, AnyNode>
const liveTransforms = new Map([
[stair.id, { position: [3, 0, 0.2] as [number, number, number], rotation: 0 }],
])
const liveOverrides = new Map<string, Record<string, unknown>>()
expect(hasLiveStairOpeningInputs(nodes, liveTransforms, liveOverrides, new Set())).toBe(true)
const previewNodes = getNodesWithLiveStairOpeningInputs(
nodes,
liveTransforms,
liveOverrides,
new Set(),
)
const updates = syncAutoStairOpenings(previewNodes)
const hole = updates.find((update) => update.id === slab.id)?.data.holes?.[0]
expect(hole).toBeDefined()
expect(Math.max(...hole!.map(([x]) => x))).toBeGreaterThan(3.4)
})
test('ignores its own live surface overrides as preview inputs', () => {
const slab = SlabNode.parse({
polygon: [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
],
})
const nodes = { [slab.id]: slab } as Record<string, AnyNode>
const liveOverrides = new Map<string, Record<string, unknown>>([
[
slab.id,
{
holes: [
[
[1, 1],
[2, 1],
[2, 2],
[1, 2],
],
],
},
],
])
expect(hasLiveStairOpeningInputs(nodes, new Map(), liveOverrides, new Set([slab.id]))).toBe(
false,
)
})
})
@@ -0,0 +1,120 @@
import type { AnyNode, AnyNodeId, CeilingNode, SlabNode } from '../../schema'
import useLiveNodeOverrides, { type LiveNodeOverrides } from '../../store/use-live-node-overrides'
import type { LiveTransform } from '../../store/use-live-transforms'
import useScene from '../../store/use-scene'
type SurfaceOpeningUpdate = {
id: AnyNodeId
data: Partial<SlabNode | CeilingNode>
}
const SURFACE_OPENING_FIELDS = ['holes', 'holeMetadata'] as const
function isSurface(node: AnyNode | undefined): node is SlabNode | CeilingNode {
return node?.type === 'slab' || node?.type === 'ceiling'
}
function isStairOpeningInputNode(node: AnyNode | undefined) {
return node?.type === 'stair' || node?.type === 'stair-segment'
}
function omitPreviewSurfaceFields(override: LiveNodeOverrides) {
const next = { ...override }
for (const field of SURFACE_OPENING_FIELDS) {
delete next[field]
}
return next
}
export function hasLiveStairOpeningInputs(
nodes: Record<string, AnyNode>,
liveTransforms: ReadonlyMap<string, LiveTransform>,
liveOverrides: ReadonlyMap<string, LiveNodeOverrides>,
previewSurfaceIds: ReadonlySet<string>,
) {
for (const nodeId of liveTransforms.keys()) {
if (nodes[nodeId]?.type === 'stair') return true
}
for (const [nodeId, override] of liveOverrides) {
if (previewSurfaceIds.has(nodeId)) continue
if (Object.keys(override).length > 0 && isStairOpeningInputNode(nodes[nodeId])) return true
}
return false
}
export function getNodesWithLiveStairOpeningInputs(
nodes: Record<string, AnyNode>,
liveTransforms: ReadonlyMap<string, LiveTransform>,
liveOverrides: ReadonlyMap<string, LiveNodeOverrides>,
previewSurfaceIds: ReadonlySet<string>,
) {
const nextNodes: Record<string, AnyNode> = { ...nodes }
for (const [nodeId, override] of liveOverrides) {
const node = nextNodes[nodeId]
if (!node) continue
const values = previewSurfaceIds.has(nodeId) ? omitPreviewSurfaceFields(override) : override
if (Object.keys(values).length === 0) continue
nextNodes[nodeId] = { ...node, ...values } as AnyNode
}
for (const [nodeId, transform] of liveTransforms) {
const node = nextNodes[nodeId]
if (node?.type !== 'stair') continue
nextNodes[nodeId] = {
...node,
position: transform.position,
rotation: transform.rotation,
}
}
return nextNodes
}
export function createSurfaceOpeningPreviewController() {
const previewSurfaceIds = new Set<AnyNodeId>()
const clearSurface = (id: AnyNodeId) => {
useLiveNodeOverrides.getState().clearFields(id, SURFACE_OPENING_FIELDS)
useScene.getState().markDirty(id)
}
return {
previewSurfaceIds,
apply(updates: SurfaceOpeningUpdate[]) {
const scene = useScene.getState()
const nextSurfaceIds = new Set<AnyNodeId>()
for (const update of updates) {
const node = scene.nodes[update.id]
if (!isSurface(node)) continue
if (!('holes' in update.data || 'holeMetadata' in update.data)) continue
nextSurfaceIds.add(update.id)
useLiveNodeOverrides.getState().set(update.id, {
holes: update.data.holes ?? [],
holeMetadata: update.data.holeMetadata ?? [],
})
scene.markDirty(update.id)
}
for (const id of previewSurfaceIds) {
if (!nextSurfaceIds.has(id)) clearSurface(id)
}
previewSurfaceIds.clear()
for (const id of nextSurfaceIds) {
previewSurfaceIds.add(id)
}
},
clear() {
for (const id of previewSurfaceIds) {
clearSurface(id)
}
previewSurfaceIds.clear()
},
}
}
@@ -11,7 +11,7 @@ import {
import { syncAutoStairOpenings } from './stair-opening-sync'
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
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
@@ -1,7 +1,9 @@
'use client'
import { useAlignmentGuides } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { memo } from 'react'
import { formatMeasurement } from '../editor/measurement-pill'
import { useFloorplanRender } from './floorplan-render-context'
/**
@@ -23,6 +25,7 @@ import { useFloorplanRender } from './floorplan-render-context'
*/
export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() {
const guides = useAlignmentGuides((s) => s.guides)
const unit = useViewer((s) => s.unit)
const ctx = useFloorplanRender()
if (guides.length === 0) return null
@@ -56,7 +59,7 @@ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuid
// offset along X.
const pillX = axis === 'x' ? midX + pillOffset : midX
const pillZ = axis === 'z' ? midZ + pillOffset : midZ
const distLabel = formatMeters(distMeters)
const distLabel = formatMeasurement(distMeters, unit)
const charWidth = pillFontSize * 0.55
const pillWidth = distLabel.length * charWidth + pillPadX * 2
const pillHeight = pillFontSize + pillPadY * 2
@@ -135,10 +138,3 @@ function XCap({
</g>
)
}
function formatMeters(meters: number): string {
// Sub-centimetre = "0". Otherwise show with up to 2 decimals, trimmed.
if (meters < 0.005) return '0'
const fixed = meters.toFixed(2)
return `${fixed.replace(/\.?0+$/, '')}m`
}
@@ -471,6 +471,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
payload,
nodes: sceneNodes,
initialPlanPoint,
gridSnapStep: useEditor.getState().gridSnapStep,
})
const snapshots: NodeSnapshot[] = []
@@ -8,6 +8,7 @@ import { memo, useMemo, useRef } from 'react'
import { BoxGeometry, CircleGeometry, type Group } from 'three'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants'
import { formatMeasurement } from './measurement-pill'
/**
* Figma-style alignment guides for the 3D editor — the spatial twin of
@@ -53,6 +54,7 @@ type Vec3 = [number, number, number]
export const Alignment3DGuideLayer = memo(function Alignment3DGuideLayer() {
const guides = useAlignmentGuides((s) => s.guides)
const levelId = useViewer((s) => s.selection.levelId)
const unit = useViewer((s) => s.unit)
const groupRef = useRef<Group>(null)
// Guides carry only XZ (building-local plan coords); their Y has to track
@@ -71,16 +73,16 @@ export const Alignment3DGuideLayer = memo(function Alignment3DGuideLayer() {
return (
<group ref={groupRef}>
{guides.map((guide, i) => (
<GuideLine guide={guide} key={i} />
<GuideLine guide={guide} key={i} unit={unit} />
))}
</group>
)
})
function GuideLine({ guide }: { guide: AlignmentGuide }) {
function GuideLine({ guide, unit }: { guide: AlignmentGuide; unit: 'metric' | 'imperial' }) {
const { x: fx, z: fz } = guide.from
const { x: tx, z: tz } = guide.to
const distLabel = formatMeters(guide.distance)
const distLabel = formatMeasurement(guide.distance, unit)
// Lay out the dash centres along the from→to direction. The ribbon
// stretches the dash period up if the line is long enough to exceed the
@@ -152,11 +154,3 @@ function Dot({ position }: { position: Vec3 }) {
/>
)
}
function formatMeters(meters: number): string {
// Sub-centimetre = "0"; otherwise up to 2 decimals, trimmed. Matches the
// 2D floor-plan guide layer's pill formatting.
if (meters < 0.005) return '0'
const fixed = meters.toFixed(2)
return `${fixed.replace(/\.?0+$/, '')}m`
}
@@ -44,6 +44,7 @@ export type HandleDragMoveContext = {
type HandleDragSession = {
move: (context: HandleDragMoveContext) => Partial<AnyNode> | null
markDirty?: boolean
onBegin?: () => void
onEnd?: () => void
overrideId?: AnyNodeId
@@ -122,6 +123,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
if (!session) return
const overrideId = session.overrideId ?? nodeId
const markDirty = session.markDirty !== false
document.body.style.cursor = cursor
sfxEmitter.emit('sfx:item-pick')
useViewer.getState().setInputDragging(true)
@@ -137,8 +139,10 @@ export function useHandleDrag(args: UseHandleDragArgs) {
if (!patch) return
lastPatch = patch
useLiveNodeOverrides.getState().set(overrideId, patch as Record<string, unknown>)
if (markDirty) {
useScene.getState().markDirty(overrideId)
}
}
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
@@ -157,8 +161,10 @@ export function useHandleDrag(args: UseHandleDragArgs) {
const clearOverride = () => {
useLiveNodeOverrides.getState().clear(overrideId)
if (markDirty) {
useScene.getState().markDirty(overrideId)
}
}
const onUp = () => {
swallowNextClick()
@@ -12,6 +12,7 @@ import {
nodeRegistry,
type RadialResizeHandle,
sceneRegistry,
snapScalar,
type TapActionHandle,
type TranslateHandle,
useLiveNodeOverrides,
@@ -581,6 +582,10 @@ function LinearArrow({
descriptor.axis === 'x' ? hitLocal.x : descriptor.axis === 'y' ? hitLocal.y : hitLocal.z
const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi)
const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi)
const gridSnapStep =
descriptor.kind === 'linear-resize' && descriptor.gridSnap
? useEditor.getState().gridSnapStep
: null
const factor =
descriptor.kind === 'radial-resize'
? 1
@@ -615,7 +620,10 @@ function LinearArrow({
? intersectionLocal.y
: intersectionLocal.z
const delta = currentPointer - initialPointer
const next = Math.min(maxBound, Math.max(minBound, initialValue + delta * factor))
const rawNext = initialValue + delta * factor
const snappedNext =
gridSnapStep && gridSnapStep > 0 ? snapScalar(rawNext, gridSnapStep) : rawNext
const next = Math.min(maxBound, Math.max(minBound, snappedNext))
return descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
},
}
@@ -1185,6 +1193,7 @@ function TranslateArrow({
.position ?? [0, 0, 0]
return {
markDirty: false,
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
const hit = new Vector3()
if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null
@@ -9,6 +9,7 @@ import {
getEffectiveRoofSurfaceMaterial,
getEffectiveSegmentSurfaceMaterial,
getMaterialPresetByRef,
getRoofSegmentSurfaceY,
getSelectableKinds,
type ItemNode,
isRegistrySelectable,
@@ -40,7 +41,7 @@ import {
useViewer,
} from '@pascal-app/viewer'
import { useCallback, useEffect, useRef } from 'react'
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three'
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three'
import {
type ActivePaintMaterial,
buildRoofSegmentSurfaceMaterialPatch,
@@ -56,7 +57,7 @@ import useEditor, {
type Phase,
type StructureLayer,
} from './../../store/use-editor'
import { boxSelectHandled } from '../tools/select/box-select-tool'
import { boxSelectHandled } from '../tools/select/box-select-state'
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
// Elevators are building-scoped, so they stay selectable across level filters.
@@ -205,6 +206,59 @@ function getRegisteredMesh(nodeId: string): Mesh | null {
return object && (object as Mesh).isMesh ? (object as Mesh) : null
}
const roofSelectionWorldPoint = new Vector3()
function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null {
const roof = event.node
if (roof.type !== 'roof') return null
roofSelectionWorldPoint.set(...event.position)
const nodes = useScene.getState().nodes
let firstSegment: RoofSegmentNode | null = null
let bestSegment: { node: RoofSegmentNode; score: number } | null = null
for (const childId of roof.children ?? []) {
const segment = nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
if (segment?.type !== 'roof-segment') continue
const object = getRegisteredNodeObject(segment.id)
if (!object) continue
if (!firstSegment) firstSegment = segment
object.updateWorldMatrix(true, false)
const local = object.worldToLocal(roofSelectionWorldPoint.clone())
const overhang = segment.overhang ?? 0
const halfWidth = segment.width / 2 + overhang
const halfDepth = segment.depth / 2 + overhang
if (Math.abs(local.x) > halfWidth || Math.abs(local.z) > halfDepth) {
continue
}
const score = Math.abs(local.y - getRoofSegmentSurfaceY(segment, local.x, local.z))
if (!bestSegment || score < bestSegment.score) {
bestSegment = { node: segment, score }
}
}
return bestSegment?.node ?? firstSegment
}
function isInActiveRoofContext(
segment: RoofSegmentNode,
selectedIds: readonly string[],
nodes: Record<string, AnyNode>,
): boolean {
if (!segment.parentId) return false
if (selectedIds.includes(segment.id) || selectedIds.includes(segment.parentId)) return true
return selectedIds.some((selectedId) => {
const selectedNode = nodes[selectedId]
return selectedNode?.type === 'roof-segment' && selectedNode.parentId === segment.parentId
})
}
function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup {
const previousMaterial = mesh.material
mesh.material = material
@@ -1251,8 +1305,14 @@ export const SelectionManager = () => {
let nodeToSelect = node
if (node.type === 'roof-segment' && node.parentId) {
const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
if (parentNode && parentNode.type === 'roof') {
const nodes = useScene.getState().nodes
const parentNode = nodes[node.parentId as AnyNodeId]
const selectedIds = useViewer.getState().selection.selectedIds
if (
parentNode &&
parentNode.type === 'roof' &&
!isInActiveRoofContext(node, selectedIds, nodes)
) {
nodeToSelect = parentNode
}
}
@@ -1439,7 +1499,11 @@ export const SelectionManager = () => {
}
const onDoubleClick = (event: NodeEvent) => {
const node = event.node
let node = event.node
if (node.type === 'roof') {
node = resolveRoofSegmentSelectionTarget(event) ?? node
}
const currentPhase = useEditor.getState().phase
let targetPhase: 'site' | 'structure' | 'furnish' | null = null
@@ -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
}
@@ -9,6 +9,7 @@ import {
RoofSegmentNode,
resolveAlignment,
sceneRegistry,
snapScalar,
useAlignmentGuides,
useScene,
} from '@pascal-app/core'
@@ -28,6 +29,10 @@ const GRID_OFFSET = 0.02
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
function snapToActiveGrid(value: number): number {
return snapScalar(value, useEditor.getState().gridSnapStep)
}
/**
* Creates a roof group with one default gable segment
*/
@@ -233,8 +238,8 @@ export const RoofTool: React.FC = () => {
if (!cursorRef.current) return
const [gridX, gridZ] = alignPoint(
Math.round(event.localPosition[0] * 2) / 2,
Math.round(event.localPosition[2] * 2) / 2,
snapToActiveGrid(event.localPosition[0]),
snapToActiveGrid(event.localPosition[2]),
event.localPosition[0],
event.localPosition[2],
event.nativeEvent?.altKey === true,
@@ -271,8 +276,8 @@ export const RoofTool: React.FC = () => {
if (!currentLevelId) return
const [gridX, gridZ] = alignPoint(
Math.round(event.localPosition[0] * 2) / 2,
Math.round(event.localPosition[2] * 2) / 2,
snapToActiveGrid(event.localPosition[0]),
snapToActiveGrid(event.localPosition[2]),
event.localPosition[0],
event.localPosition[2],
event.nativeEvent?.altKey === true,
@@ -0,0 +1,22 @@
export let boxSelectHandled = false
let resetTimeout: ReturnType<typeof setTimeout> | null = null
export function markBoxSelectHandled() {
boxSelectHandled = true
if (resetTimeout) {
clearTimeout(resetTimeout)
}
resetTimeout = setTimeout(() => {
boxSelectHandled = false
resetTimeout = null
}, 50)
}
export function clearBoxSelectHandled() {
if (resetTimeout) {
clearTimeout(resetTimeout)
resetTimeout = null
}
boxSelectHandled = false
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,566 @@
import '../../../three-types'
import { Icon } from '@iconify/react'
import {
type AnyNodeId,
emitter,
type GridEvent,
sceneRegistry,
useScene,
type ZoneNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import type { ThreeElements } from '@react-three/fiber'
import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react'
import {
Box3,
BufferAttribute,
BufferGeometry,
DoubleSide,
type Group,
LineBasicMaterial,
LineSegments,
type Mesh,
Plane,
Raycaster,
Vector2,
Vector3,
} from 'three'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { markBoxSelectHandled } from './box-select-state'
import { collectSelectableCandidateIds } from './select-candidates'
declare module 'react/jsx-runtime' {
namespace JSX {
interface IntrinsicElements extends ThreeElements {}
}
}
type Bounds = { minX: number; maxX: number; minZ: number; maxZ: number }
const BOX_SELECT_ACCENT_COLOR = '#818cf8'
const DRAG_THRESHOLD_PX = 4
const tempVec = new Vector3()
const tempBox = new Box3()
function pointInBounds(x: number, z: number, b: Bounds): boolean {
return x >= b.minX && x <= b.maxX && z >= b.minZ && z <= b.maxZ
}
function segmentsIntersect(
ax1: number,
az1: number,
ax2: number,
az2: number,
bx1: number,
bz1: number,
bx2: number,
bz2: number,
): boolean {
const d1 = cross(bx1, bz1, bx2, bz2, ax1, az1)
const d2 = cross(bx1, bz1, bx2, bz2, ax2, az2)
const d3 = cross(ax1, az1, ax2, az2, bx1, bz1)
const d4 = cross(ax1, az1, ax2, az2, bx2, bz2)
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
return true
}
if (d1 === 0 && onSegment(bx1, bz1, bx2, bz2, ax1, az1)) return true
if (d2 === 0 && onSegment(bx1, bz1, bx2, bz2, ax2, az2)) return true
if (d3 === 0 && onSegment(ax1, az1, ax2, az2, bx1, bz1)) return true
if (d4 === 0 && onSegment(ax1, az1, ax2, az2, bx2, bz2)) return true
return false
}
function cross(ax: number, az: number, bx: number, bz: number, cx: number, cz: number): number {
return (bx - ax) * (cz - az) - (bz - az) * (cx - ax)
}
function onSegment(
ax: number,
az: number,
bx: number,
bz: number,
cx: number,
cz: number,
): boolean {
return (
Math.min(ax, bx) <= cx &&
cx <= Math.max(ax, bx) &&
Math.min(az, bz) <= cz &&
cz <= Math.max(az, bz)
)
}
function segmentIntersectsBounds(
x1: number,
z1: number,
x2: number,
z2: number,
b: Bounds,
): boolean {
if (pointInBounds(x1, z1, b) || pointInBounds(x2, z2, b)) return true
const edges: [number, number, number, number][] = [
[b.minX, b.minZ, b.maxX, b.minZ],
[b.maxX, b.minZ, b.maxX, b.maxZ],
[b.maxX, b.maxZ, b.minX, b.maxZ],
[b.minX, b.maxZ, b.minX, b.minZ],
]
for (const [ex1, ez1, ex2, ez2] of edges) {
if (segmentsIntersect(x1, z1, x2, z2, ex1, ez1, ex2, ez2)) return true
}
return false
}
function polygonIntersectsBounds(polygon: [number, number][], b: Bounds): boolean {
if (polygon.some(([x, z]) => pointInBounds(x, z, b))) return true
const corners: [number, number][] = [
[b.minX, b.minZ],
[b.maxX, b.minZ],
[b.maxX, b.maxZ],
[b.minX, b.maxZ],
]
if (corners.some(([cx, cz]) => pointInPolygon(cx, cz, polygon))) return true
const edges: [number, number, number, number][] = [
[b.minX, b.minZ, b.maxX, b.minZ],
[b.maxX, b.minZ, b.maxX, b.maxZ],
[b.maxX, b.maxZ, b.minX, b.maxZ],
[b.minX, b.maxZ, b.minX, b.minZ],
]
for (let i = 0; i < polygon.length; i++) {
const [px1, pz1] = polygon[i]!
const [px2, pz2] = polygon[(i + 1) % polygon.length]!
for (const [ex1, ez1, ex2, ez2] of edges) {
if (segmentsIntersect(px1, pz1, px2, pz2, ex1, ez1, ex2, ez2)) return true
}
}
return false
}
function pointInPolygon(x: number, z: number, polygon: [number, number][]): boolean {
let inside = false
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const [xi, zi] = polygon[i]!
const [xj, zj] = polygon[j]!
if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) {
inside = !inside
}
}
return inside
}
function getNodeWorldXZ(nodeId: string): [number, number] | null {
const obj = sceneRegistry.nodes.get(nodeId)
if (!obj) return null
obj.getWorldPosition(tempVec)
return [tempVec.x, tempVec.z]
}
function objectBoundsIntersectsBounds(nodeId: string, bounds: Bounds): boolean {
const obj = sceneRegistry.nodes.get(nodeId)
if (!obj) return false
obj.updateWorldMatrix(true, true)
tempBox.setFromObject(obj)
if (tempBox.isEmpty()) {
const xz = getNodeWorldXZ(nodeId)
return Boolean(xz && pointInBounds(xz[0], xz[1], bounds))
}
return !(
tempBox.max.x < bounds.minX ||
tempBox.min.x > bounds.maxX ||
tempBox.max.z < bounds.minZ ||
tempBox.min.z > bounds.maxZ
)
}
function collectNodeIdsInPlaneBounds(bounds: Bounds | null): string[] {
const candidateIds = collectSelectableCandidateIds()
if (!bounds) return candidateIds
const { nodes } = useScene.getState()
return candidateIds.filter((id) => {
const node = nodes[id as AnyNodeId]
if (!node) return false
if (node.type === 'wall' || node.type === 'fence') {
return segmentIntersectsBounds(node.start[0], node.start[1], node.end[0], node.end[1], bounds)
}
if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
return polygonIntersectsBounds(node.polygon, bounds)
}
return objectBoundsIntersectsBounds(id, bounds)
})
}
function haveSameIds(currentIds: string[], nextIds: string[]): boolean {
return (
currentIds.length === nextIds.length &&
currentIds.every((currentId, index) => currentId === nextIds[index])
)
}
function updateRectVisuals(
fillMesh: Mesh,
outline: LineSegments,
start: Vector3,
end: Vector3,
y: number,
) {
const cx = (start.x + end.x) / 2
const cz = (start.z + end.z) / 2
const w = Math.abs(end.x - start.x)
const h = Math.abs(end.z - start.z)
if (w < 0.01 && h < 0.01) {
fillMesh.visible = false
outline.visible = false
return
}
fillMesh.visible = true
fillMesh.position.set(cx, y + 0.02, cz)
fillMesh.scale.set(w, h, 1)
outline.visible = true
const oy = y + 0.03
const x0 = cx - w / 2
const x1 = cx + w / 2
const z0 = cz - h / 2
const z1 = cz + h / 2
const pos = outline.geometry.attributes.position as BufferAttribute
pos.setXYZ(0, x0, oy, z0)
pos.setXYZ(1, x1, oy, z0)
pos.setXYZ(2, x1, oy, z0)
pos.setXYZ(3, x1, oy, z1)
pos.setXYZ(4, x1, oy, z1)
pos.setXYZ(5, x0, oy, z1)
pos.setXYZ(6, x0, oy, z1)
pos.setXYZ(7, x0, oy, z0)
pos.needsUpdate = true
}
function createOutlineSegments(): LineSegments {
const geometry = new BufferGeometry()
geometry.setAttribute('position', new BufferAttribute(new Float32Array(8 * 3), 3))
const material = new LineBasicMaterial({
color: BOX_SELECT_ACCENT_COLOR,
depthTest: false,
depthWrite: false,
transparent: true,
opacity: 0.85,
})
const segments = new LineSegments(geometry, material)
segments.layers.set(EDITOR_LAYER)
segments.renderOrder = 2
segments.visible = false
segments.frustumCulled = false
return segments
}
function getSnappedGridPosition(x: number, z: number): [number, number] {
return [Math.round(x * 2) / 2, Math.round(z * 2) / 2]
}
function setSnappedPoint(target: Vector3, x: number, y: number, z: number) {
const [snappedX, snappedZ] = getSnappedGridPosition(x, z)
target.set(snappedX, y, snappedZ)
}
const BOX_SELECT_TOOLTIP = (
<Icon
color="currentColor"
height={24}
icon="mdi:select-drag"
style={{ filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))' }}
width={24}
/>
)
export const PlaneBoxSelectTool: React.FC = () => {
const { camera, gl } = useThree()
const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
const cursorRef = useRef<Group>(null)
const rectFillRef = useRef<Mesh>(null!)
const outlineRef = useRef(createOutlineSegments())
const startPoint = useRef(new Vector3())
const currentPoint = useRef(new Vector3())
const pointerDown = useRef(false)
const isDragging = useRef(false)
const startClientX = useRef(0)
const startClientY = useRef(0)
const gridY = useRef(0)
const previousGridPosition = useRef<[number, number] | null>(null)
const previewSelectedIdsRef = useRef<string[]>([])
const spaceDownRef = useRef(false)
const raycasterRef = useRef(new Raycaster())
const pointerNDC = useRef(new Vector2())
const groundPlane = useRef(new Plane(new Vector3(0, 1, 0), 0))
const hitPoint = useRef(new Vector3())
const syncPreviewSelectedIds = useCallback(
(nextIds: string[]) => {
if (haveSameIds(previewSelectedIdsRef.current, nextIds)) return
previewSelectedIdsRef.current = nextIds
setPreviewSelectedIds(nextIds)
},
[setPreviewSelectedIds],
)
const resetDrag = useCallback(() => {
pointerDown.current = false
isDragging.current = false
rectFillRef.current.visible = false
outlineRef.current.visible = false
syncPreviewSelectedIds([])
}, [syncPreviewSelectedIds])
const raycastToGround = useCallback(
(event: PointerEvent): Vector3 | null => {
const rect = gl.domElement.getBoundingClientRect()
pointerNDC.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
pointerNDC.current.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
raycasterRef.current.setFromCamera(pointerNDC.current, camera)
if (raycasterRef.current.ray.intersectPlane(groundPlane.current, hitPoint.current)) {
return hitPoint.current
}
return null
},
[camera, gl],
)
useEffect(() => {
const outline = outlineRef.current
return () => {
previewSelectedIdsRef.current = []
setPreviewSelectedIds([])
outline.geometry.dispose()
;(outline.material as LineBasicMaterial).dispose()
}
}, [setPreviewSelectedIds])
useEffect(() => {
const unsubscribe = useViewer.subscribe((state) => {
const levelId = state.selection.levelId
if (!levelId) return
const obj = sceneRegistry.nodes.get(levelId)
if (obj) groundPlane.current.constant = -obj.position.y
})
const levelId = useViewer.getState().selection.levelId
if (levelId) {
const obj = sceneRegistry.nodes.get(levelId)
if (obj) groundPlane.current.constant = -obj.position.y
}
return unsubscribe
}, [])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.code !== 'Space') return
spaceDownRef.current = true
if (pointerDown.current) {
resetDrag()
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.code !== 'Space') return
spaceDownRef.current = false
}
const onBlur = () => {
spaceDownRef.current = false
resetDrag()
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onBlur)
return () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onBlur)
}
}, [resetDrag])
useEffect(() => {
const canvas = gl.domElement
const onCanvasPointerDown = (event: PointerEvent) => {
if (event.button !== 0) return
if (spaceDownRef.current) return
if (useViewer.getState().cameraDragging) return
if (useViewer.getState().inputDragging) return
const point = raycastToGround(event)
if (!point) return
setSnappedPoint(startPoint.current, point.x, point.y, point.z)
setSnappedPoint(currentPoint.current, point.x, point.y, point.z)
gridY.current = point.y
pointerDown.current = true
isDragging.current = false
previousGridPosition.current = getSnappedGridPosition(point.x, point.z)
startClientX.current = event.clientX
startClientY.current = event.clientY
syncPreviewSelectedIds([])
}
const onCanvasPointerUp = (event: PointerEvent) => {
if (event.button !== 0) return
if (useViewer.getState().inputDragging) {
resetDrag()
return
}
if (!pointerDown.current) return
if (isDragging.current) {
const point = raycastToGround(event)
if (point) setSnappedPoint(currentPoint.current, point.x, point.y, point.z)
const bounds: Bounds = {
minX: Math.min(startPoint.current.x, currentPoint.current.x),
maxX: Math.max(startPoint.current.x, currentPoint.current.x),
minZ: Math.min(startPoint.current.z, currentPoint.current.z),
maxZ: Math.max(startPoint.current.z, currentPoint.current.z),
}
const ids = collectNodeIdsInPlaneBounds(bounds)
const shouldAppend = event.metaKey || event.ctrlKey
const { phase, structureLayer } = useEditor.getState()
if (phase === 'structure' && structureLayer === 'zones') {
if (ids.length > 0) {
useViewer.getState().setSelection({ zoneId: ids[0] as ZoneNode['id'] })
} else if (!shouldAppend) {
useViewer.getState().setSelection({ zoneId: null })
}
} else if (shouldAppend) {
const currentIds = useViewer.getState().selection.selectedIds
useViewer.getState().setSelection({
selectedIds: Array.from(new Set([...currentIds, ...ids])),
})
} else {
const allOnLevel = collectNodeIdsInPlaneBounds(null)
const { buildingId } = useViewer.getState().selection
const selectedEntireLevel = allOnLevel.length > 0 && ids.length === allOnLevel.length
if (selectedEntireLevel && buildingId) {
useViewer.getState().setSelection({ buildingId })
} else {
useViewer.getState().setSelection({ selectedIds: ids })
}
}
markBoxSelectHandled()
}
resetDrag()
}
canvas.addEventListener('pointerdown', onCanvasPointerDown)
canvas.addEventListener('pointerup', onCanvasPointerUp)
return () => {
canvas.removeEventListener('pointerdown', onCanvasPointerDown)
canvas.removeEventListener('pointerup', onCanvasPointerUp)
}
}, [gl, raycastToGround, resetDrag, syncPreviewSelectedIds])
useEffect(() => {
const onMove = (event: GridEvent) => {
const [snappedX, snappedZ] = getSnappedGridPosition(event.position[0], event.position[2])
if (cursorRef.current) {
cursorRef.current.position.set(snappedX, event.position[1], snappedZ)
}
if (!pointerDown.current) return
if (spaceDownRef.current || useViewer.getState().inputDragging) return
currentPoint.current.set(snappedX, event.position[1], snappedZ)
const nativeEvent = event.nativeEvent as unknown as PointerEvent
const dx = nativeEvent.clientX - startClientX.current
const dy = nativeEvent.clientY - startClientY.current
if (!isDragging.current && Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX) {
isDragging.current = true
}
if (isDragging.current && rectFillRef.current && outlineRef.current) {
updateRectVisuals(
rectFillRef.current,
outlineRef.current,
startPoint.current,
currentPoint.current,
gridY.current,
)
const nextGridPosition: [number, number] = [snappedX, snappedZ]
if (
previousGridPosition.current &&
(nextGridPosition[0] !== previousGridPosition.current[0] ||
nextGridPosition[1] !== previousGridPosition.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosition.current = nextGridPosition
const bounds: Bounds = {
minX: Math.min(startPoint.current.x, currentPoint.current.x),
maxX: Math.max(startPoint.current.x, currentPoint.current.x),
minZ: Math.min(startPoint.current.z, currentPoint.current.z),
maxZ: Math.max(startPoint.current.z, currentPoint.current.z),
}
syncPreviewSelectedIds(collectNodeIdsInPlaneBounds(bounds))
}
}
emitter.on('grid:move', onMove)
return () => {
emitter.off('grid:move', onMove)
}
}, [syncPreviewSelectedIds])
return (
<group>
<CursorSphere ref={cursorRef} tooltipContent={BOX_SELECT_TOOLTIP} />
<mesh
layers={EDITOR_LAYER}
ref={rectFillRef}
renderOrder={1}
rotation={[-Math.PI / 2, 0, 0]}
visible={false}
>
<planeGeometry args={[1, 1]} />
<meshBasicMaterial
color={BOX_SELECT_ACCENT_COLOR}
depthTest={false}
depthWrite={false}
opacity={0.14}
side={DoubleSide}
transparent
/>
</mesh>
<primitive object={outlineRef.current} />
</group>
)
}
@@ -0,0 +1,127 @@
import {
type AnyNode,
type AnyNodeId,
isRegistrySelectable,
type LevelNode,
nodeRegistry,
resolveBuildingForLevel,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import useEditor from '../../../store/use-editor'
export function isFurnishSelectableCandidate(node: AnyNode): boolean {
if (node.type === 'item') {
return node.asset.category !== 'door' && node.asset.category !== 'window'
}
const def = nodeRegistry.get(node.type)
return Boolean(def?.category === 'furnish' && def.capabilities.selectable)
}
export function isStructureSelectableCandidate(node: AnyNode): boolean {
if (
node.type === 'wall' ||
node.type === 'fence' ||
node.type === 'column' ||
node.type === 'elevator' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'roof' ||
node.type === 'stair' ||
node.type === 'spawn' ||
node.type === 'window' ||
node.type === 'door'
) {
return true
}
if (node.type === 'item') {
return node.asset.category === 'door' || node.asset.category === 'window'
}
const def = nodeRegistry.get(node.type)
return Boolean(def && def.category !== 'furnish' && def.capabilities.selectable)
}
export function collectSelectableCandidateIds(): string[] {
const { levelId } = useViewer.getState().selection
const { nodes } = useScene.getState()
const { phase, structureLayer } = useEditor.getState()
const result: string[] = []
const seen = new Set<string>()
const addNode = (node: AnyNode | undefined) => {
if (!node || seen.has(node.id)) return
seen.add(node.id)
result.push(node.id)
}
if (phase === 'site') {
for (const node of Object.values(nodes)) {
if (node.type === 'building') addNode(node)
}
return result
}
if (!levelId) return []
const levelNode = nodes[levelId as AnyNodeId] as LevelNode | undefined
if (!levelNode || levelNode.type !== 'level') return []
if (phase === 'structure' && structureLayer === 'zones') {
for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId]
if (node?.type === 'zone') addNode(node)
}
return result
}
for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId]
if (!node) continue
if (phase === 'furnish') {
if (isFurnishSelectableCandidate(node)) addNode(node)
continue
}
if (node.type === 'wall' || node.type === 'fence') {
addNode(node)
const hostedChildren = 'children' in node && Array.isArray(node.children) ? node.children : []
for (const hostedChildId of hostedChildren) {
const child = nodes[hostedChildId as AnyNodeId]
if (!child) continue
if (
child.type === 'window' ||
child.type === 'door' ||
(child.type === 'item' &&
(child.asset.category === 'door' || child.asset.category === 'window'))
) {
addNode(child)
}
}
continue
}
if (isStructureSelectableCandidate(node)) {
addNode(node)
}
}
const buildingId = resolveBuildingForLevel(levelId as AnyNodeId, nodes)
const buildingNode = buildingId ? nodes[buildingId] : undefined
const buildingChildren =
buildingNode && 'children' in buildingNode && Array.isArray(buildingNode.children)
? (buildingNode.children as AnyNodeId[])
: []
for (const childId of buildingChildren) {
const node = nodes[childId]
if (!node || node.type === 'level' || !isRegistrySelectable(node.type)) continue
if (phase === 'furnish') {
if (isFurnishSelectableCandidate(node)) addNode(node)
} else if (isStructureSelectableCandidate(node)) {
addNode(node)
}
}
return result
}
@@ -1,5 +1,5 @@
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
import { SCENE_LAYER } from '@pascal-app/viewer'
import { SCENE_LAYER, useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
@@ -317,6 +317,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const [dragState, setDragState] = useState<DragState | null>(null)
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
const previousInputDraggingRef = useRef(false)
const onPolygonPreviewRef = useRef(onPolygonPreview)
useEffect(() => {
@@ -346,6 +347,20 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const lineRef = useRef<Line>(null!)
const previousPositionRef = useRef<[number, number] | null>(null)
const startDrag = useCallback((nextDragState: DragState) => {
previousInputDraggingRef.current = useViewer.getState().inputDragging
useViewer.getState().setInputDragging(true)
setDragState(nextDragState)
}, [])
useEffect(() => {
if (!dragState?.isDragging) return
return () => {
useViewer.getState().setInputDragging(previousInputDraggingRef.current)
}
}, [dragState?.isDragging])
// Track the last polygon prop to detect external changes (undo/redo) or
// our own post-commit prop update arriving while a preview is still in
// flight. Either way, drop the stale preview/drag.
@@ -687,7 +702,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
if (e.button !== 0) return
e.stopPropagation()
setHoveredEdge(null)
setDragState({
startDrag({
isDragging: true,
mode: 'vertex',
vertexIndex: index,
@@ -721,7 +736,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
if (e.button !== 0) return
e.stopPropagation()
setHoveredEdge(null)
setDragState({
startDrag({
isDragging: true,
mode: 'polygon',
vertexIndex: null,
@@ -750,7 +765,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
if (!edgeNormal) return
setHoveredEdge(null)
setDragState({
startDrag({
isDragging: true,
mode: 'edge',
vertexIndex: null,
@@ -835,7 +850,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
e.stopPropagation()
const insertedVertex = handleAddVertex(index, [x!, z!])
if (insertedVertex.vertexIndex >= 0) {
setDragState({
startDrag({
isDragging: true,
mode: 'vertex',
vertexIndex: insertedVertex.vertexIndex,
@@ -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<AnyNode>
const CLICK_TRIGGER_KINDS = [
'shelf',
'item',
'slab',
'ceiling',
'wall',
'fence',
'column',
'roof',
'roof-segment',
'stair',
'stair-segment',
] as const
/**
* Generates the step-profile geometry for the ghost preview.
@@ -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<string, AnyNode>
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<AnyNode>) : null
if (nodeEvent) {
nodeEvent.stopPropagation()
nodeEvent.nativeEvent.stopPropagation()
}
const position = nodeEvent
? lastCanonicalPositionRef.current
: getAlignedGridPosition(event as GridEvent)
if (!position) return
commitStairPlacement(currentLevelId, position, rotationRef.current)
openingPreview.clear()
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
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 extends string> = `${K}:${EventSuffix}`
type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
for (const kind of CLICK_TRIGGER_KINDS) {
const key = `${kind}:click` as ClickKey
emitter.on(key, commitAtCursor as never)
}
window.addEventListener('keydown', onKeyDown)
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])
+8
View File
@@ -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.
@@ -0,0 +1,146 @@
import { describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
BuildingNode,
LevelNode,
StairNode,
} from '@pascal-app/core/schema'
import {
getBuildingLevelsForLevel,
getStairLevelOptions,
resolveStairDestinationLevel,
resolveStairFromLevelId,
resolveStairPlacementLevelId,
resolveStairToLevelId,
} from './stair-levels'
describe('stair level helpers', () => {
test('creates a missing upper level in the same building', () => {
const ground = LevelNode.parse({ level: 0, children: [] })
const building = BuildingNode.parse({ children: [ground.id] })
const nodes = {
[building.id]: building,
[ground.id]: ground,
} as Record<AnyNodeId, AnyNode>
const plan = resolveStairDestinationLevel({
createMissing: true,
fromLevelId: ground.id,
nodes,
})
expect(plan?.buildingId).toBe(building.id)
expect(plan?.fromLevel.id).toBe(ground.id)
expect(plan?.toLevel.level).toBe(1)
expect(plan?.toLevel.id).toBe(plan?.createdLevel?.id)
expect(plan?.createdLevel?.parentId).toBe(building.id)
})
test('uses the nearest higher sibling level instead of creating one', () => {
const building = BuildingNode.parse({})
const ground = LevelNode.parse({ level: 0, parentId: building.id })
const second = LevelNode.parse({ level: 1, parentId: building.id })
const third = LevelNode.parse({ level: 2, parentId: building.id })
const nodes = {
[building.id]: { ...building, children: [ground.id, third.id, second.id] },
[ground.id]: ground,
[second.id]: second,
[third.id]: third,
} as Record<AnyNodeId, AnyNode>
const plan = resolveStairDestinationLevel({
createMissing: true,
fromLevelId: ground.id,
nodes,
})
expect(plan?.createdLevel).toBeNull()
expect(plan?.toLevel.id).toBe(second.id)
})
test('ignores levels from other buildings', () => {
const buildingA = BuildingNode.parse({})
const buildingB = BuildingNode.parse({})
const groundA = LevelNode.parse({ level: 0, parentId: buildingA.id })
const upperA = LevelNode.parse({ level: 1, parentId: buildingA.id })
const upperB = LevelNode.parse({ level: 1, parentId: buildingB.id })
const nodes = {
[buildingA.id]: { ...buildingA, children: [groundA.id, upperA.id] },
[buildingB.id]: { ...buildingB, children: [upperB.id] },
[groundA.id]: groundA,
[upperA.id]: upperA,
[upperB.id]: upperB,
} as Record<AnyNodeId, AnyNode>
expect(getBuildingLevelsForLevel(nodes, groundA.id).map((level) => level.id)).toEqual([
groundA.id,
upperA.id,
])
expect(
resolveStairDestinationLevel({ createMissing: true, fromLevelId: groundA.id, nodes })?.toLevel
.id,
).toBe(upperA.id)
})
test('includes source and parent-linked sibling levels when building children are stale', () => {
const building = BuildingNode.parse({ children: [] })
const ground = LevelNode.parse({ level: 0, parentId: building.id })
const upper = LevelNode.parse({ level: 1, parentId: building.id })
const nodes = {
[building.id]: building,
[ground.id]: ground,
[upper.id]: upper,
} as Record<AnyNodeId, AnyNode>
const levels = getBuildingLevelsForLevel(nodes, ground.id)
const plan = resolveStairDestinationLevel({
createMissing: true,
fromLevelId: ground.id,
nodes,
})
expect(levels.map((level) => level.id)).toEqual([ground.id, upper.id])
expect(plan?.createdLevel).toBeNull()
expect(plan?.toLevel.id).toBe(upper.id)
})
test('falls back from stale placement level ids to a valid level in the selected building', () => {
const buildingA = BuildingNode.parse({})
const groundA = LevelNode.parse({ level: 0, parentId: buildingA.id })
const buildingB = BuildingNode.parse({})
const groundB = LevelNode.parse({ level: 0, parentId: buildingB.id })
const nodes = {
[buildingA.id]: { ...buildingA, children: [groundA.id] },
[groundA.id]: groundA,
[buildingB.id]: { ...buildingB, children: [groundB.id] },
[groundB.id]: groundB,
} as Record<AnyNodeId, AnyNode>
expect(resolveStairPlacementLevelId(nodes, 'level_missing', buildingB.id)).toBe(groundB.id)
})
test('repairs panel level ids for stairs with stale from-level data', () => {
const building = BuildingNode.parse({})
const ground = LevelNode.parse({ level: 0, parentId: building.id })
const upper = LevelNode.parse({ level: 1, parentId: building.id })
const stair = StairNode.parse({
parentId: ground.id,
fromLevelId: 'default',
toLevelId: upper.id,
})
const nodes = {
[building.id]: { ...building, children: [ground.id, upper.id] },
[ground.id]: ground,
[upper.id]: upper,
[stair.id]: stair,
} as Record<AnyNodeId, AnyNode>
const levels = getStairLevelOptions(nodes, stair)
const fromLevelId = resolveStairFromLevelId(nodes, stair, levels)
expect(levels.map((level) => level.id)).toEqual([ground.id, upper.id])
expect(fromLevelId).toBe(ground.id)
expect(resolveStairToLevelId(nodes, stair, fromLevelId, levels)).toBe(upper.id)
})
})
+177
View File
@@ -0,0 +1,177 @@
import {
type AnyNode,
type AnyNodeId,
LevelNode,
type LevelNode as LevelNodeType,
resolveBuildingForLevel,
type StairNode,
} from '@pascal-app/core'
function sortLevelsByHeight(levels: LevelNodeType[]) {
return [...levels].sort((left, right) => left.level - right.level)
}
function isLevelNode(node: AnyNode | undefined): node is LevelNodeType {
return node?.type === 'level'
}
function getAllSceneLevels(nodes: Record<string, AnyNode>) {
return sortLevelsByHeight(
Object.values(nodes).filter((entry): entry is LevelNodeType => entry?.type === 'level'),
)
}
function getBuildingLevels(
nodes: Record<string, AnyNode>,
buildingId: AnyNodeId | string | null | undefined,
source?: LevelNodeType,
) {
if (!buildingId) return source ? [source] : []
const building = nodes[buildingId as AnyNodeId]
if (building?.type !== 'building') return source ? [source] : []
const levels = new Map<string, LevelNodeType>()
if (source) levels.set(source.id, source)
for (const childId of building.children ?? []) {
const child = nodes[childId as AnyNodeId]
if (isLevelNode(child)) levels.set(child.id, child)
}
for (const candidate of Object.values(nodes)) {
if (isLevelNode(candidate) && candidate.parentId === building.id) {
levels.set(candidate.id, candidate)
}
}
return sortLevelsByHeight(Array.from(levels.values()))
}
export function getBuildingLevelsForLevel(
nodes: Record<string, AnyNode>,
levelId: AnyNodeId | string | null | undefined,
) {
if (!levelId) return []
const source = nodes[levelId as AnyNodeId]
if (!isLevelNode(source)) return []
const buildingId = resolveBuildingForLevel(
source.id as AnyNodeId,
nodes as Record<AnyNodeId, AnyNode>,
)
return getBuildingLevels(nodes, buildingId, source)
}
export function getStairLevelOptions(nodes: Record<string, AnyNode>, stair: StairNode) {
for (const candidateId of [stair.fromLevelId, stair.parentId, stair.toLevelId]) {
if (isLevelNode(nodes[candidateId as AnyNodeId])) {
return getBuildingLevelsForLevel(nodes, candidateId)
}
}
return getAllSceneLevels(nodes)
}
export function resolveStairPlacementLevelId(
nodes: Record<string, AnyNode>,
preferredLevelId: AnyNodeId | string | null | undefined,
preferredBuildingId?: AnyNodeId | string | null,
) {
if (isLevelNode(nodes[preferredLevelId as AnyNodeId])) {
return preferredLevelId as LevelNodeType['id']
}
const buildingLevels = getBuildingLevels(nodes, preferredBuildingId)
return buildingLevels[0]?.id ?? getAllSceneLevels(nodes)[0]?.id ?? null
}
export function resolveStairFromLevelId(
nodes: Record<string, AnyNode>,
stair: StairNode,
levels = getStairLevelOptions(nodes, stair),
) {
const optionIds = new Set<string>(levels.map((level) => level.id))
if (stair.fromLevelId && optionIds.has(stair.fromLevelId)) return stair.fromLevelId
if (stair.parentId && optionIds.has(stair.parentId)) return stair.parentId
const toLevel = stair.toLevelId ? nodes[stair.toLevelId as AnyNodeId] : undefined
if (isLevelNode(toLevel)) {
const lowerLevel = [...levels].reverse().find((level) => level.level < toLevel.level)
if (lowerLevel) return lowerLevel.id
}
return levels[0]?.id ?? null
}
export function resolveStairToLevelId(
nodes: Record<string, AnyNode>,
stair: StairNode,
fromLevelId: AnyNodeId | string | null | undefined,
levels = getStairLevelOptions(nodes, stair),
) {
const optionIds = new Set<string>(levels.map((level) => level.id))
if (stair.toLevelId && stair.toLevelId !== fromLevelId && optionIds.has(stair.toLevelId)) {
return stair.toLevelId
}
const fromLevel = fromLevelId ? nodes[fromLevelId as AnyNodeId] : undefined
if (isLevelNode(fromLevel)) {
return levels.find((level) => level.level > fromLevel.level)?.id ?? fromLevel.id
}
return levels[0]?.id ?? null
}
export function resolveStairDestinationLevel({
createMissing,
fromLevelId,
nodes,
}: {
createMissing?: boolean
fromLevelId: AnyNodeId | string | null | undefined
nodes: Record<string, AnyNode>
}) {
if (!fromLevelId) return null
const fromLevel = nodes[fromLevelId as AnyNodeId]
if (!isLevelNode(fromLevel)) return null
const buildingId = resolveBuildingForLevel(
fromLevel.id as AnyNodeId,
nodes as Record<AnyNodeId, AnyNode>,
)
const levels = getBuildingLevelsForLevel(nodes, fromLevel.id)
const nextExistingLevel = levels.find((level) => level.level > fromLevel.level) ?? null
if (nextExistingLevel) {
return {
buildingId,
createdLevel: null,
fromLevel,
levels,
toLevel: nextExistingLevel,
}
}
if (createMissing && buildingId) {
const createdLevel = LevelNode.parse({
children: [],
level: fromLevel.level + 1,
parentId: buildingId,
})
return {
buildingId,
createdLevel,
fromLevel,
levels: sortLevelsByHeight([...levels, createdLevel]),
toLevel: createdLevel,
}
}
return {
buildingId,
createdLevel: null,
fromLevel,
levels,
toLevel: fromLevel,
}
}
@@ -56,6 +56,7 @@ function roofSegmentWidthHandle(side: 'left' | 'right'): HandleDescriptor<RoofSe
// 'max' = +X edge anchored (left arrow grows the -X edge outward).
anchor: side === 'right' ? 'min' : 'max',
min: MIN_ROOF_DIM,
gridSnap: true,
currentValue: (n) => n.width,
apply: (initial, newWidth) => {
const rotY = initial.rotation ?? 0
@@ -100,6 +101,7 @@ function roofSegmentDepthHandle(side: 'front' | 'back'): HandleDescriptor<RoofSe
axis: 'z',
anchor: side === 'front' ? 'min' : 'max',
min: MIN_ROOF_DIM,
gridSnap: true,
currentValue: (n) => n.depth,
apply: (initial, newDepth) => {
// Recenter so the anchored Z edge stays at the same world point.
@@ -4,6 +4,7 @@ import {
type FloorplanMoveTarget,
type RoofNode,
type RoofSegmentNode,
snapScalar,
useScene,
} from '@pascal-app/core'
@@ -56,7 +57,7 @@ function resolveSegmentFrame(
* the math survives any parent-roof rotation.
*/
export const roofSegmentResizeAffordance: FloorplanAffordance<RoofSegmentNode> = {
start({ node, payload, nodes, initialPlanPoint }) {
start({ node, payload, nodes, initialPlanPoint, gridSnapStep }) {
const { axis, side } = payload as RoofSegmentResizePayload
const segmentId = node.id as AnyNodeId
const initialValue = axis === 'x' ? node.width : node.depth
@@ -79,7 +80,9 @@ export const roofSegmentResizeAffordance: FloorplanAffordance<RoofSegmentNode> =
apply({ planPoint }) {
const currentLocal = projectLocalAxis(planPoint[0], planPoint[1])
const delta = (currentLocal - initialLocal) * side
const newValue = Math.max(MIN_ROOF_DIM, initialValue + 2 * delta)
const rawValue = initialValue + 2 * delta
const snappedValue = gridSnapStep > 0 ? snapScalar(rawValue, gridSnapStep) : rawValue
const newValue = Math.max(MIN_ROOF_DIM, snappedValue)
lastValue = newValue
useScene
.getState()
+78 -1
View File
@@ -1,8 +1,84 @@
import { type NodeDefinition, RoofNode as RoofNodeSchema } from '@pascal-app/core'
import {
type AnyNodeId,
type HandleDescriptor,
type NodeDefinition,
RoofNode as RoofNodeSchema,
type RoofNode as RoofNodeType,
type RoofSegmentNode,
type SceneApi,
} from '@pascal-app/core'
import { buildRoofFloorplan } from './floorplan'
import { roofParametrics } from './parametrics'
import { RoofNode } from './schema'
const MOVE_FRONT_OFFSET = 0.35
const MIN_ROOF_FOOTPRINT = 1
type RoofFootprintBounds = {
maxX: number
maxZ: number
minX: number
minZ: number
}
function getRoofFootprintBounds(node: RoofNodeType, sceneApi: SceneApi): RoofFootprintBounds {
let bounds: RoofFootprintBounds | null = null
for (const childId of node.children ?? []) {
const segment = sceneApi.get<RoofSegmentNode>(childId as AnyNodeId)
if (segment?.type !== 'roof-segment') continue
const halfWidth = Math.max(segment.width, MIN_ROOF_FOOTPRINT) / 2
const halfDepth = Math.max(segment.depth, MIN_ROOF_FOOTPRINT) / 2
const cos = Math.cos(segment.rotation ?? 0)
const sin = Math.sin(segment.rotation ?? 0)
const corners = [
[-halfWidth, -halfDepth],
[halfWidth, -halfDepth],
[halfWidth, halfDepth],
[-halfWidth, halfDepth],
] as const
for (const [x, z] of corners) {
const localX = segment.position[0] + x * cos + z * sin
const localZ = segment.position[2] - x * sin + z * cos
bounds =
bounds === null
? { maxX: localX, maxZ: localZ, minX: localX, minZ: localZ }
: {
maxX: Math.max(bounds.maxX, localX),
maxZ: Math.max(bounds.maxZ, localZ),
minX: Math.min(bounds.minX, localX),
minZ: Math.min(bounds.minZ, localZ),
}
}
}
return bounds ?? { maxX: 0.5, maxZ: 0.5, minX: -0.5, minZ: -0.5 }
}
function roofMoveHandle(): HandleDescriptor<RoofNodeType> {
return {
kind: 'translate',
placement: {
position: (node, sceneApi) => {
const bounds = getRoofFootprintBounds(node, sceneApi)
return [(bounds.minX + bounds.maxX) / 2, 0.02, bounds.maxZ + MOVE_FRONT_OFFSET]
},
},
apply: (_node, position) => ({ position: [position[0], position[1], position[2]] }),
snapExtents: (node, sceneApi) => {
const bounds = getRoofFootprintBounds(node, sceneApi)
const width = Math.max(bounds.maxX - bounds.minX, MIN_ROOF_FOOTPRINT)
const depth = Math.max(bounds.maxZ - bounds.minZ, MIN_ROOF_FOOTPRINT)
const swap = Math.abs(Math.sin(node.rotation ?? 0)) > 0.9
return [swap ? depth : width, swap ? width : depth]
},
}
}
const roofHandles: HandleDescriptor<RoofNodeType>[] = [roofMoveHandle()]
/**
* Roof — Stage A registration. Wrap-exports the legacy `RoofRenderer`
* + `RoofSystem` (geometry generation via `getRoofSegmentBrushes` +
@@ -43,6 +119,7 @@ export const roofDefinition: NodeDefinition<typeof RoofNode> = {
},
parametrics: roofParametrics,
handles: roofHandles,
floorplan: buildRoofFloorplan,
renderer: {
+7 -1
View File
@@ -5,6 +5,7 @@ import {
hasSegmentMaterialOverride,
type RoofNode,
type RoofSegmentNode,
useLiveNodeOverrides,
useRegistry,
useScene,
} from '@pascal-app/core'
@@ -14,8 +15,13 @@ import * as THREE from 'three'
import { useShallow } from 'zustand/react/shallow'
import { getRoofDebugMaterials, getRoofMaterials } from './roof-materials'
export const RoofRenderer = ({ node }: { node: RoofNode }) => {
export const RoofRenderer = ({ node: rawNode }: { node: RoofNode }) => {
const ref = useRef<THREE.Group>(null!)
const liveOverride = useLiveNodeOverrides((s) => s.overrides.get(rawNode.id))
const node = useMemo<RoofNode>(
() => (liveOverride ? ({ ...rawNode, ...liveOverride } as RoofNode) : rawNode),
[rawNode, liveOverride],
)
useRegistry(node.id, 'roof', ref)
useLayoutEffect(() => {
+3 -37
View File
@@ -1,6 +1,6 @@
import {
type AnyNodeId,
getActiveRoofHeight,
getRoofSegmentSurfaceY,
type RoofNode,
type RoofSegmentNode,
sceneRegistry,
@@ -17,40 +17,6 @@ export type RoofSegmentHit = {
localZ: number
}
/**
* Analytical surface Y for `seg` at segment-local (lx, lz). Mirrors
* the per-roof-type slope math in `shared/roof-surface.ts` so the
* disambiguator below stays free of cross-kind imports. Returns the
* roof's local surface height; the value is only used to compare
* candidates, never written to the scene.
*/
function analyticalSurfaceY(seg: RoofSegmentNode, lx: number, lz: number): number {
const rh = getActiveRoofHeight(seg)
const peakY = seg.wallHeight + rh
if (rh === 0) return seg.wallHeight
if (
seg.roofType === 'gable' ||
seg.roofType === 'gambrel' ||
seg.roofType === 'mansard' ||
seg.roofType === 'dutch'
) {
const t = seg.depth > 0 ? Math.abs(lz) / (seg.depth / 2) : 0
return peakY - t * rh
}
if (seg.roofType === 'shed') {
const t = (lz + seg.depth / 2) / (seg.depth || 1)
return peakY - t * rh
}
if (seg.roofType === 'hip') {
const fx = seg.width > 0 ? Math.abs(lx) / (seg.width / 2) : 0
const fz = seg.depth > 0 ? Math.abs(lz) / (seg.depth / 2) : 0
return peakY - Math.max(fx, fz) * rh
}
const t = seg.depth > 0 ? Math.abs(lz) / (seg.depth / 2) : 0
return peakY - t * rh
}
/**
* Resolve which roof-segment the user clicked. Used by every placement
* tool that drops a new node onto a roof (box-vent, ridge-vent,
@@ -61,7 +27,7 @@ function analyticalSurfaceY(seg: RoofSegmentNode, lx: number, lz: number): numbe
* point's (x, z) lies inside *every* segment's axis-aligned half-
* extents, so a naive first-match returns the wrong slope (typically
* segments[0]). We instead score each candidate by
* `|localY analyticalSurfaceY(localX, localZ)|` and pick the
* `|localY getRoofSegmentSurfaceY(localX, localZ)|` and pick the
* smallest — the slope the user actually clicked is the one whose
* sloped surface passes through the hit point.
*
@@ -101,7 +67,7 @@ export function resolveRoofSegmentHit(
const halfW = seg.width / 2 + overhang
const halfD = seg.depth / 2 + overhang
if (Math.abs(local.x) <= halfW && Math.abs(local.z) <= halfD) {
const surfaceY = analyticalSurfaceY(seg, local.x, local.z)
const surfaceY = getRoofSegmentSurfaceY(seg, local.x, local.z)
const score = Math.abs(local.y - surfaceY)
if (!best || score < best.score) {
best = {
+2 -21
View File
@@ -1,5 +1,5 @@
import {
getActiveRoofHeight,
getRoofSegmentSurfaceY,
getSegmentSlopeFrame,
ROOF_SHAPE_DEFAULTS,
type RoofSegmentNode,
@@ -13,26 +13,7 @@ import * as THREE from 'three'
// accessories don't reach across into a sibling kind for it.
export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): number {
const { roofType, wallHeight, depth, width } = seg
const rh = getActiveRoofHeight(seg)
const peakY = wallHeight + rh
if (rh === 0) return wallHeight
if (roofType === 'gable') {
const t = depth > 0 ? Math.abs(lz) / (depth / 2) : 0
return peakY - t * rh
}
if (roofType === 'shed') {
const t = (lz + depth / 2) / (depth || 1)
return peakY - t * rh
}
if (roofType === 'hip') {
const fx = width > 0 ? Math.abs(lx) / (width / 2) : 0
const fz = depth > 0 ? Math.abs(lz) / (depth / 2) : 0
return peakY - Math.max(fx, fz) * rh
}
const t = depth > 0 ? Math.abs(lz) / (depth / 2) : 0
return peakY - t * rh
return getRoofSegmentSurfaceY(seg, lx, lz)
}
// Outward normal for a roof surface tilting at angle θ in the horizontal
@@ -0,0 +1,43 @@
import { describe, expect, test } from 'bun:test'
import { pointInPolygon2D, SlabNode } from '@pascal-app/core'
import { slabDefinition } from '../definition'
function getHeightHandlePosition(slab: SlabNode) {
const handles =
typeof slabDefinition.handles === 'function'
? slabDefinition.handles(slab)
: (slabDefinition.handles ?? [])
const heightHandle = handles.find(
(handle) => handle.kind === 'linear-resize' && handle.axis === 'y',
)
if (!(heightHandle && heightHandle.kind === 'linear-resize')) {
throw new Error('Missing slab height handle')
}
return heightHandle.placement.position(slab, {} as never)
}
describe('slabDefinition handles', () => {
test('keeps the height handle over solid slab area when the center is a hole', () => {
const slab = SlabNode.parse({
polygon: [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
],
holes: [
[
[1, 1],
[3, 1],
[3, 3],
[1, 3],
],
],
})
const [x, , z] = getHeightHandlePosition(slab)
expect(pointInPolygon2D([x, z], slab.polygon, { includeBoundary: false })).toBe(true)
expect(pointInPolygon2D([x, z], slab.holes[0]!, { includeBoundary: true })).toBe(false)
})
})
+70 -9
View File
@@ -1,4 +1,9 @@
import type { HandleDescriptor, NodeDefinition, SlabNode as SlabNodeType } from '@pascal-app/core'
import {
type HandleDescriptor,
type NodeDefinition,
pointInPolygon2D,
type SlabNode as SlabNodeType,
} from '@pascal-app/core'
import { buildSlabFloorplan } from './floorplan'
import {
slabAddVertexAffordance,
@@ -13,8 +18,7 @@ import { SlabNode } from './schema'
const HEIGHT_HANDLE_OFFSET = 0.22
const MIN_SLAB_ELEVATION = 0.02
function slabPolygonCenter(n: SlabNodeType): [number, number] {
const polygon = n.polygon ?? []
function polygonVertexAverage(polygon: SlabNodeType['polygon']): [number, number] {
if (polygon.length === 0) return [0, 0]
let cx = 0
let cz = 0
@@ -25,11 +29,68 @@ function slabPolygonCenter(n: SlabNodeType): [number, number] {
return [cx / polygon.length, cz / polygon.length]
}
// Slab height arrow — vertical chevron at the polygon centroid, just
// above the slab's top face. Drags elevation (the extrusion thickness)
// with `anchor: 'min'` so the bottom stays at world Y=0 and the top
// follows the pointer. Same registry-handle pipeline as the column
// height arrow, so live override + commit-on-release come for free.
function pointIsOnSolidSlab(point: [number, number], slab: SlabNodeType) {
if (!pointInPolygon2D(point, slab.polygon, { includeBoundary: false })) return false
return !(slab.holes ?? []).some(
(hole) => hole.length >= 3 && pointInPolygon2D(point, hole, { includeBoundary: true }),
)
}
function slabHandleAnchor(slab: SlabNodeType): [number, number] {
const polygon = slab.polygon ?? []
const fallback = polygonVertexAverage(polygon)
if (polygon.length < 3) return fallback
if (pointIsOnSolidSlab(fallback, slab)) return fallback
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minZ = Number.POSITIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
for (const [x, z] of polygon) {
minX = Math.min(minX, x)
maxX = Math.max(maxX, x)
minZ = Math.min(minZ, z)
maxZ = Math.max(maxZ, z)
}
const candidates: [number, number][] = []
for (const point of polygon) {
candidates.push([
fallback[0] + (point[0] - fallback[0]) * 0.35,
fallback[1] + (point[1] - fallback[1]) * 0.35,
])
}
const steps = 12
for (let xi = 1; xi < steps; xi += 1) {
const x = minX + ((maxX - minX) * xi) / steps
for (let zi = 1; zi < steps; zi += 1) {
const z = minZ + ((maxZ - minZ) * zi) / steps
candidates.push([x, z])
}
}
let best: [number, number] | null = null
let bestDistance = Number.POSITIVE_INFINITY
for (const candidate of candidates) {
if (!pointIsOnSolidSlab(candidate, slab)) continue
const dx = candidate[0] - fallback[0]
const dz = candidate[1] - fallback[1]
const distance = dx * dx + dz * dz
if (distance < bestDistance) {
best = candidate
bestDistance = distance
}
}
return best ?? fallback
}
// Slab height arrow — vertical chevron on solid slab surface near the
// polygon center. Drags elevation (the extrusion thickness) with
// `anchor: 'min'` so the bottom stays at world Y=0 and the top follows
// the pointer. Same registry-handle pipeline as the column height arrow,
// so live override + commit-on-release come for free.
function slabHeightHandle(): HandleDescriptor<SlabNodeType> {
return {
kind: 'linear-resize',
@@ -40,7 +101,7 @@ function slabHeightHandle(): HandleDescriptor<SlabNodeType> {
apply: (_n, newValue) => ({ elevation: newValue }),
placement: {
position: (n) => {
const [cx, cz] = slabPolygonCenter(n)
const [cx, cz] = slabHandleAnchor(n)
const elevation = n.elevation ?? 0.05
return [cx, elevation + HEIGHT_HANDLE_OFFSET, cz]
},
+49 -16
View File
@@ -18,9 +18,13 @@ import {
ActionGroup,
DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE,
duplicateStairSubtree,
getStairLevelOptions,
MetricControl,
PanelSection,
PanelWrapper,
resolveStairDestinationLevel,
resolveStairFromLevelId,
resolveStairToLevelId,
SegmentedControl,
SliderControl,
ToggleControl,
@@ -29,7 +33,7 @@ import {
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { useCallback, useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
@@ -62,16 +66,14 @@ export default function StairPanel() {
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const nodes = useScene((s) => s.nodes)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
)
const levels = useScene(
useShallow((s) =>
Object.values(s.nodes)
.filter((entry): entry is LevelNode => entry.type === 'level')
.sort((left, right) => left.level - right.level),
),
const levels = useMemo<LevelNode[]>(
() => (node?.type === 'stair' ? getStairLevelOptions(nodes, node) : []),
[node, nodes],
)
const segments = useScene(
useShallow((s) => {
@@ -96,6 +98,41 @@ export default function StairPanel() {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleAutoCutoutChange = useCallback(
(checked: boolean) => {
if (!node) return
const updates: Partial<StairNode> = {
slabOpeningMode: checked ? 'destination' : 'none',
}
const sceneNodes = useScene.getState().nodes
const fromLevelId = resolveStairFromLevelId(sceneNodes, node)
if (checked && fromLevelId) updates.fromLevelId = fromLevelId
if (checked && (!node.toLevelId || node.toLevelId === fromLevelId)) {
const plan = resolveStairDestinationLevel({
fromLevelId,
nodes: sceneNodes,
})
if (plan?.toLevel.id) updates.toLevelId = plan.toLevel.id
}
handleUpdate(updates)
},
[node, handleUpdate],
)
const handleFromLevelChange = useCallback(
(fromLevelId: string) => {
const plan = resolveStairDestinationLevel({
fromLevelId: fromLevelId as AnyNodeId,
nodes: useScene.getState().nodes,
})
handleUpdate({
fromLevelId,
toLevelId: plan?.toLevel.id ?? fromLevelId,
})
},
[handleUpdate],
)
const getLastSegmentFillDefaults = useCallback(() => {
if (!node) return { fillToFloor: true }
const children = node.children ?? []
@@ -184,8 +221,8 @@ export default function StairPanel() {
if (!(node && node.type === 'stair' && selectedId && selectedCount === 1)) return null
const resolvedFromLevelId = node.fromLevelId ?? node.parentId ?? levels[0]?.id ?? null
const resolvedToLevelId = node.toLevelId ?? resolvedFromLevelId
const resolvedFromLevelId = resolveStairFromLevelId(nodes, node, levels)
const resolvedToLevelId = resolveStairToLevelId(nodes, node, resolvedFromLevelId, levels)
return (
<PanelWrapper
@@ -217,11 +254,7 @@ export default function StairPanel() {
<ToggleControl
checked={(node.slabOpeningMode ?? 'none') === 'destination'}
label="Auto Cutout"
onChange={(checked) =>
handleUpdate({
slabOpeningMode: checked ? 'destination' : 'none',
})
}
onChange={handleAutoCutoutChange}
/>
<div className="space-y-1.5">
@@ -230,7 +263,7 @@ export default function StairPanel() {
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm"
onChange={(event) => handleUpdate({ fromLevelId: event.target.value })}
onChange={(event) => handleFromLevelChange(event.target.value)}
value={resolvedFromLevelId ?? ''}
>
{levels.map((level) => (
@@ -259,7 +292,7 @@ export default function StairPanel() {
</div>
<SegmentedControl
onChange={(value) => handleUpdate({ slabOpeningMode: value as StairSlabOpeningMode })}
onChange={(value) => handleAutoCutoutChange(value === 'destination')}
options={STAIR_SLAB_OPENING_OPTIONS}
value={node.slabOpeningMode ?? 'none'}
/>
+9 -3
View File
@@ -14,6 +14,7 @@ import {
planAutoCeilingsForLevel,
planAutoSlabsForLevel,
planWallMoveJunctions,
projectAutoSlabsForPlan,
resumeSceneHistory,
type SlabNode,
useLiveNodeOverrides,
@@ -266,10 +267,15 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
// existing room → existing slab" logic sees stable IDs across
// ticks. Without this anchor, IDs would drift as overrides
// re-flowed through the planner.
const slabPlan = planAutoSlabsForLevel(roomPolygons, getLevelSlabs(levelId, sceneState.nodes))
const existingSlabs = getLevelSlabs(levelId, sceneState.nodes)
const slabPlan = planAutoSlabsForLevel(roomPolygons, existingSlabs)
const ceilingPlan = planAutoCeilingsForLevel(
roomPolygons,
getLevelCeilings(levelId, sceneState.nodes),
{
walls: levelWalls,
slabs: projectAutoSlabsForPlan(existingSlabs, slabPlan),
},
)
latestSurfacePlans = { slabs: slabPlan, ceilings: ceilingPlan }
@@ -281,8 +287,8 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
touchedSlabIds.add(update.id as AnyNodeId)
}
for (const update of ceilingPlan.update) {
if (update.data.polygon === undefined) continue
overrideEntries.push([update.id, { polygon: update.data.polygon }])
if (update.data.polygon === undefined && update.data.height === undefined) continue
overrideEntries.push([update.id, update.data as Record<string, unknown>])
touchedCeilingIds.add(update.id as AnyNodeId)
}
+157
View File
@@ -58,6 +58,18 @@ const DRAFT_ANGLE_ARC_Y_OFFSET = 0.012
const DRAFT_ANGLE_ARC_MIN_RADIUS = 0.32
const DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72
const DRAFT_ANGLE_ARC_SEGMENTS = 24
const DRAFT_AXIS_GUIDE_LENGTH = 2000
const DRAFT_AXIS_GUIDE_WIDTH = 0.035
const DRAFT_AXIS_GUIDE_HEIGHT = 0.004
const DRAFT_AXIS_GUIDE_Y_OFFSET = 0.026
const DRAFT_AXIS_ANGLE_ARC_Y_OFFSET = 0.05
const DRAFT_AXIS_ANGLE_LABEL_Y_OFFSET = 0.16
const DRAFT_AXIS_ANGLE_ARC_MIN_RADIUS = 0.36
const DRAFT_AXIS_ANGLE_ARC_MAX_RADIUS = 0.82
const AXIS_ANGLE_REFERENCES: SegmentAngleReference[] = [
{ vector: [1, 0], orientation: 'axis' },
{ vector: [0, 1], orientation: 'axis' },
]
type DraftAngleLabel = {
id: string
@@ -78,6 +90,21 @@ type DraftMeasurementState = {
angleLabels: DraftAngleLabel[]
} | null
type DraftAxisGuideState = {
origin: WallPlanPoint
y: number
angleLabel: DraftAngleLabel | null
} | null
type AxisAngleCandidate = {
angle: number
arc: {
startAngle: number
endAngle: number
midAngle: number
}
}
type FaceAngleCandidate = {
index: number
point: WallPlanPoint
@@ -122,6 +149,53 @@ function pointMatches(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-5) {
return distanceSquared(a, b) <= tolerance * tolerance
}
function getNearestAxisAngleLabel(
start: WallPlanPoint,
end: WallPlanPoint,
y: number,
): DraftAngleLabel | null {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const length = Math.hypot(dx, dz)
if (length < 0.01) return null
const draftVector: WallPlanPoint = [dx, dz]
const axisCandidates: AxisAngleCandidate[] = []
for (const reference of AXIS_ANGLE_REFERENCES) {
const angle = getAngleToSegmentReference(draftVector, reference)
const arc = getAngleArcToSegmentReference(draftVector, reference)
if (!(angle === null || arc === null)) {
axisCandidates.push({ angle, arc })
}
}
const nearestAxisAngle = axisCandidates.sort((a, b) => a.angle - b.angle)[0]
if (!nearestAxisAngle) return null
const radius = clamp(
length * 0.22,
DRAFT_AXIS_ANGLE_ARC_MIN_RADIUS,
DRAFT_AXIS_ANGLE_ARC_MAX_RADIUS,
)
const { angle, arc } = nearestAxisAngle
return {
id: 'axis',
label: formatAngleRadians(angle),
position: [
start[0] + Math.cos(arc.midAngle) * (radius + 0.16),
y + DRAFT_AXIS_ANGLE_LABEL_Y_OFFSET,
start[1] + Math.sin(arc.midAngle) * (radius + 0.16),
],
arc: {
center: start,
radius,
startAngle: arc.startAngle,
endAngle: arc.endAngle,
y: y + DRAFT_AXIS_ANGLE_ARC_Y_OFFSET,
},
}
}
function toWallPlanPoint(point: Point2D): WallPlanPoint {
return [point.x, point.y]
}
@@ -423,6 +497,7 @@ export const WallTool: React.FC = () => {
const buildingState = useRef(0)
const shiftPressed = useRef(false)
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
const [axisGuide, setAxisGuide] = useState<DraftAxisGuideState>(null)
const measurementColor = isDark ? '#ffffff' : '#111111'
const measurementShadowColor = isDark ? '#111111' : '#ffffff'
@@ -463,6 +538,7 @@ export const WallTool: React.FC = () => {
wallPreviewRef.current.visible = false
}
setDraftMeasurement(null)
setAxisGuide(null)
useAlignmentGuides.getState().clear()
}
@@ -484,6 +560,15 @@ export const WallTool: React.FC = () => {
const snappedLocal = gridPosition
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
cursorRef.current.position.copy(endingPoint.current)
setAxisGuide({
origin: [startingPoint.current.x, startingPoint.current.z],
y: startingPoint.current.y,
angleLabel: getNearestAxisAngleLabel(
[startingPoint.current.x, startingPoint.current.z],
snappedLocal,
startingPoint.current.y,
),
})
const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
if (
@@ -514,6 +599,7 @@ export const WallTool: React.FC = () => {
} else {
cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1])
setDraftMeasurement(null)
setAxisGuide(null)
}
}
@@ -538,6 +624,11 @@ export const WallTool: React.FC = () => {
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
endingPoint.current.copy(startingPoint.current)
buildingState.current = 1
setAxisGuide({
origin: snappedStart,
y: event.localPosition[1],
angleLabel: null,
})
triggerSFX('sfx:structure-build-start')
// Visibility is owned by `updateWallPreview` — it flips
// `mesh.visible` based on segment length. Setting it here
@@ -572,6 +663,11 @@ export const WallTool: React.FC = () => {
endingPoint.current.copy(startingPoint.current)
cursorRef.current?.position.copy(startingPoint.current)
buildingState.current = 1
setAxisGuide({
origin: nextStart,
y: event.localPosition[1],
angleLabel: null,
})
// Hide the preview until the next `onGridMove` writes the
// new segment's geometry. Without this the prior segment's
// BoxGeometry stays visible for a frame on top of the
@@ -615,6 +711,11 @@ export const WallTool: React.FC = () => {
return (
<group>
<WallAxisGuides
guide={axisGuide}
labelColor={measurementColor}
labelShadowColor={measurementShadowColor}
/>
<CursorSphere height={previewHeight} ref={cursorRef} />
<mesh layers={EDITOR_LAYER} ref={wallPreviewRef} renderOrder={1} visible={false}>
<shapeGeometry />
@@ -652,6 +753,62 @@ export const WallTool: React.FC = () => {
)
}
function WallAxisGuides({
guide,
labelColor,
labelShadowColor,
}: {
guide: DraftAxisGuideState
labelColor: string
labelShadowColor: string
}) {
if (!guide) return null
const [x, z] = guide.origin
return (
<>
<group position={[x, guide.y + DRAFT_AXIS_GUIDE_Y_OFFSET, z]}>
<WallAxisGuideLine axis="x" />
<WallAxisGuideLine axis="z" />
</group>
{guide.angleLabel && (
<>
<DraftAngleArc arc={guide.angleLabel.arc} color="#818cf8" />
<DraftMeasurementLabel
color={labelColor}
label={guide.angleLabel.label}
position={guide.angleLabel.position}
shadowColor={labelShadowColor}
/>
</>
)}
</>
)
}
function WallAxisGuideLine({ axis }: { axis: 'x' | 'z' }) {
return (
<mesh
frustumCulled={false}
layers={EDITOR_LAYER}
renderOrder={0}
rotation={[0, axis === 'z' ? Math.PI / 2 : 0, 0]}
>
<boxGeometry
args={[DRAFT_AXIS_GUIDE_LENGTH, DRAFT_AXIS_GUIDE_HEIGHT, DRAFT_AXIS_GUIDE_WIDTH]}
/>
<meshBasicMaterial
color="#818cf8"
depthTest={false}
depthWrite={false}
opacity={0.36}
transparent
/>
</mesh>
)
}
function DraftAngleArc({ arc, color }: { arc: DraftAngleLabel['arc']; color: string }) {
const geometry = useMemo(() => {
const segmentCount = Math.max(
+45 -1
View File
@@ -1,7 +1,7 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import { type Point2D, unionPolygons } from './polygon-union'
import { type Point2D, subtractPolygonsFromPolygon, unionPolygons } from './polygon-union'
function polygonArea(points: Point2D[]) {
let area = 0
@@ -75,3 +75,47 @@ describe('unionPolygons', () => {
expect(result.map(polygonArea)).toEqual([1, 1])
})
})
describe('subtractPolygonsFromPolygon', () => {
test('turns a boundary-overlapping cutter into an indentation', () => {
const slab: Point2D[] = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
const cutout: Point2D[] = [
[1, -0.5],
[3, -0.5],
[3, 1],
[1, 1],
]
const result = subtractPolygonsFromPolygon(slab, [cutout])
expect(result).toHaveLength(1)
expect(result[0]).toContainEqual([1, 1])
expect(result[0]).toContainEqual([3, 1])
expect(polygonArea(result[0]!)).toBeCloseTo(10)
})
test('returns separate contours when a cutter splits the subject', () => {
const slab: Point2D[] = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
const cutout: Point2D[] = [
[1.5, -1],
[2.5, -1],
[2.5, 4],
[1.5, 4],
]
const result = subtractPolygonsFromPolygon(slab, [cutout])
expect(result).toHaveLength(2)
expect(result.map(polygonArea).sort((a, b) => a - b)).toEqual([4.5, 4.5])
})
})
+74
View File
@@ -53,6 +53,18 @@ function pointOnSegment(point: Point2D, start: Point2D, end: Point2D) {
return dot <= EPSILON
}
function pointInPolygonOrOnBoundary(point: Point2D, polygon: Point2D[]) {
if (
polygon.some((start, index) =>
pointOnSegment(point, start, polygon[(index + 1) % polygon.length]!),
)
) {
return true
}
return pointInPolygon(point, polygon)
}
function pointInPolygon(point: Point2D, polygon: Point2D[]) {
let inside = false
@@ -292,3 +304,65 @@ export function unionPolygons(polygons: Point2D[][]): Point2D[][] {
return rings.length > 0 ? rings : validPolygons
}
function buildDifferenceBoundarySegments(edges: Edge[], polygons: Point2D[][]) {
const subject = polygons[0]
const cutters = polygons.slice(1)
if (!subject) return []
const segments: Segment[] = []
for (const edge of edges) {
const splits = [...edge.splits].sort((a, b) => a - b)
for (let i = 0; i < splits.length - 1; i++) {
const startT = splits[i]!
const endT = splits[i + 1]!
if (endT - startT <= EPSILON) continue
const start = interpolate(edge.start, edge.end, startT)
const end = interpolate(edge.start, edge.end, endT)
const mid = interpolate(edge.start, edge.end, (startT + endT) / 2)
if (edge.polygonIndex === 0) {
const insideCutter = cutters.some((cutter) => pointInPolygonOrOnBoundary(mid, cutter))
if (!insideCutter) {
segments.push({ start, end, used: false })
}
continue
}
const insideSubject = pointInPolygon(mid, subject)
const insideAnotherCutter = cutters.some(
(cutter, cutterIndex) =>
cutterIndex !== edge.polygonIndex - 1 && pointInPolygonOrOnBoundary(mid, cutter),
)
if (insideSubject && !insideAnotherCutter) {
segments.push({ start: end, end: start, used: false })
}
}
}
return removeDuplicateInteriorSegments(segments)
}
export function subtractPolygonsFromPolygon(subject: Point2D[], cutters: Point2D[][]): Point2D[][] {
const validSubject = normalizeRing(subject)
if (validSubject.length < 3) return []
const validCutters = cutters.map(normalizeRing).filter((polygon) => polygon.length >= 3)
if (validCutters.length === 0) return [validSubject]
const polygons = [validSubject, ...validCutters]
const edges = buildEdges(polygons)
const segments = buildDifferenceBoundarySegments(edges, polygons)
const rings = assembleRings(segments)
if (rings.length > 0) return rings
const fullyCovered = validSubject.every((point) =>
validCutters.some((cutter) => pointInPolygonOrOnBoundary(point, cutter)),
)
return fullyCovered ? [] : [validSubject]
}
@@ -0,0 +1,70 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import { SlabNode } from '@pascal-app/core'
import type * as THREE from 'three'
import { generateSlabGeometry } from './slab-system'
function hasVertexAt(geometry: THREE.BufferGeometry, x: number, z: number) {
const positions = geometry.getAttribute('position')
for (let index = 0; index < positions.count; index += 1) {
if (Math.abs(positions.getX(index) - x) < 1e-6 && Math.abs(positions.getZ(index) - z) < 1e-6) {
return true
}
}
return false
}
describe('generateSlabGeometry', () => {
test('renders a boundary-overlapping hole as an open indentation', () => {
const slab = SlabNode.parse({
elevation: 0.05,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
holes: [
[
[1, -0.5],
[3, -0.5],
[3, 1],
[1, 1],
],
],
})
const geometry = generateSlabGeometry(slab)
expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0)
expect(hasVertexAt(geometry, 1, 1)).toBe(true)
expect(hasVertexAt(geometry, 3, 1)).toBe(true)
})
test('renders a boundary-overlapping hole as an open indentation on recessed slabs', () => {
const slab = SlabNode.parse({
elevation: -0.2,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
holes: [
[
[1, -0.5],
[3, -0.5],
[3, 1],
[1, 1],
],
],
})
const geometry = generateSlabGeometry(slab)
expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0)
expect(hasVertexAt(geometry, 1, 1)).toBe(true)
expect(hasVertexAt(geometry, 3, 1)).toBe(true)
})
})
@@ -1,6 +1,10 @@
import {
type AnyNodeId,
getEffectiveNode,
getRenderableSlabPolygon,
type PolygonPoint2D,
pointInPolygon2D,
polygonsIntersect,
type SlabNode,
sceneRegistry,
useScene,
@@ -8,6 +12,7 @@ import {
import { useFrame } from '@react-three/fiber'
import { useEffect } from 'react'
import * as THREE from 'three'
import { subtractPolygonsFromPolygon } from '../../lib/polygon-union'
import { mergeSurfaceHolePolygons } from '../surface-hole-geometry'
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
@@ -47,7 +52,7 @@ export const SlabSystem = () => {
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
if (mesh) {
updateSlabGeometry(node as SlabNode, mesh)
updateSlabGeometry(getEffectiveNode(node as SlabNode), mesh)
clearDirty(id as AnyNodeId)
}
// If mesh not found, keep it dirty for next frame
@@ -95,6 +100,40 @@ function ensureCounterClockwisePolygon(polygon: Array<[number, number]>): Array<
return area2 < 0 ? [...polygon].reverse() : polygon
}
function isStrictInteriorHole(contour: PolygonPoint2D[], hole: PolygonPoint2D[]) {
return (
hole.every((point) => pointInPolygon2D(point, contour, { includeBoundary: false })) &&
!polygonsIntersect(contour, hole)
)
}
function affectsContour(contour: PolygonPoint2D[], hole: PolygonPoint2D[]) {
return (
polygonsIntersect(contour, hole) ||
hole.some((point) => pointInPolygon2D(point, contour, { includeBoundary: false })) ||
contour.some((point) => pointInPolygon2D(point, hole, { includeBoundary: false }))
)
}
function buildSlabRegions(contour: PolygonPoint2D[], holes: PolygonPoint2D[][]) {
const containedHoles: PolygonPoint2D[][] = []
const edgeCutouts: PolygonPoint2D[][] = []
for (const hole of holes) {
if (hole.length < 3) continue
if (isStrictInteriorHole(contour, hole)) containedHoles.push(hole)
else if (affectsContour(contour, hole)) edgeCutouts.push(hole)
}
const contours =
edgeCutouts.length > 0 ? subtractPolygonsFromPolygon(contour, edgeCutouts) : [contour]
return contours.map((regionContour) => ({
contour: regionContour,
holes: containedHoles.filter((hole) => isStrictInteriorHole(regionContour, hole)),
}))
}
/**
* Standard slab: flat extrusion upward from Y=0 by elevation thickness.
*
@@ -118,8 +157,35 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
const uvs: number[] = []
const indices: number[] = []
const contour2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!))
const holes2d = holePolygons
// --- Side walls ---
// Each segment gets its own 4 verts so computeVertexNormals doesn't average
// across faces. Outer walls are single-sided with outward normals; hole walls
// emit a second flipped quad (own verts) so they read as double-sided.
const addWall = (a: THREE.Vector2, b: THREE.Vector2, flipped: boolean) => {
const base = positions.length / 3
const len = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 0.001)
positions.push(a.x, 0, a.y)
uvs.push(0, 0)
positions.push(b.x, 0, b.y)
uvs.push(len, 0)
positions.push(b.x, elevation, b.y)
uvs.push(len, elevation)
positions.push(a.x, elevation, a.y)
uvs.push(0, elevation)
// Standard winding on a CCW polygon gives inward-facing normals (see pool
// path), so the unflipped quad faces outward; flipped is its back face.
if (!flipped) {
indices.push(base, base + 2, base + 1, base, base + 3, base + 2)
} else {
indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
}
}
for (const region of buildSlabRegions(polygon, holePolygons)) {
const contour2d = ensureCounterClockwisePolygon(region.contour).map(
([x, z]) => new THREE.Vector2(x!, z!),
)
const holes2d = region.holes
.filter((h) => h.length >= 3)
.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
@@ -147,33 +213,10 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
indices.push(bottomBase + a, bottomBase + b, bottomBase + c)
}
// --- Side walls ---
// Each segment gets its own 4 verts so computeVertexNormals doesn't average
// across faces. Outer walls are single-sided with outward normals; hole walls
// emit a second flipped quad (own verts) so they read as double-sided.
const addWall = (a: THREE.Vector2, b: THREE.Vector2, flipped: boolean) => {
const base = positions.length / 3
const len = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 0.001)
positions.push(a.x, 0, a.y)
uvs.push(0, 0)
positions.push(b.x, 0, b.y)
uvs.push(len, 0)
positions.push(b.x, elevation, b.y)
uvs.push(len, elevation)
positions.push(a.x, elevation, a.y)
uvs.push(0, elevation)
// Standard winding on a CCW polygon gives inward-facing normals (see pool
// path), so the unflipped quad faces outward; flipped is its back face.
if (!flipped) {
indices.push(base, base + 2, base + 1, base, base + 3, base + 2)
} else {
indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
}
}
for (let i = 0; i < contour2d.length; i++) {
addWall(contour2d[i]!, contour2d[(i + 1) % contour2d.length]!, false)
}
for (const hole of holes2d) {
for (let i = 0; i < hole.length; i++) {
const a = hole[i]!
@@ -182,6 +225,7 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
addWall(a, b, true)
}
}
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
@@ -210,7 +254,6 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const positions: number[] = []
const uvs: number[] = []
const indices: number[] = []
const n = polygon.length
const bounds = new THREE.Box2()
for (const [x, z] of polygon) {
@@ -235,27 +278,30 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
uvs.push(u, v)
}
// --- Floor at Y=0 ---
for (const [x, z] of polygon) pushFloorVertex(x!, 0, z!)
for (const region of buildSlabRegions(polygon, holePolygons)) {
const contour = ensureCounterClockwisePolygon(region.contour)
const floorBase = positions.length / 3
const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!))
const holesPts2d = holePolygons.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
for (const hole of holePolygons) {
// --- Floor at Y=0 ---
for (const [x, z] of contour) pushFloorVertex(x!, 0, z!)
const pts2d = contour.map(([x, z]) => new THREE.Vector2(x!, z!))
const holesPts2d = region.holes.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
for (const hole of region.holes) {
for (const [x, z] of hole) pushFloorVertex(x!, 0, z!)
}
const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d)
for (const tri of floorTris) {
// Reversed winding → normals point +Y (upward) in XZ plane
indices.push(tri[0]!, tri[2]!, tri[1]!)
indices.push(floorBase + tri[0]!, floorBase + tri[2]!, floorBase + tri[1]!)
}
// --- Inner walls (no top cap at Y=depth) ---
// Standard winding on a CCW polygon in XZ gives inward-facing normals.
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const [x0, z0] = polygon[i]!
const [x1, z1] = polygon[j]!
for (let i = 0; i < contour.length; i++) {
const j = (i + 1) % contour.length
const [x0, z0] = contour[i]!
const [x1, z1] = contour[j]!
const vBase = positions.length / 3
const segmentLength = Math.max(Math.hypot(x1 - x0, z1 - z0), 0.001)
@@ -267,6 +313,7 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
indices.push(vBase, vBase + 1, vBase + 2)
indices.push(vBase, vBase + 2, vBase + 3)
}
}
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))