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, isOperationDoorType,
SECTIONAL_GARAGE_RENDER_OPEN_SCALE, SECTIONAL_GARAGE_RENDER_OPEN_SCALE,
} from './lib/door-operation' } from './lib/door-operation'
export {
type Point2D as PolygonPoint2D,
pointInPolygon as pointInPolygon2D,
pointOnSegment,
polygonContainsPolygon,
polygonsIntersect,
polygonsOverlap,
segmentsIntersect,
} from './lib/polygon-relations'
export { getRenderableSlabPolygon } from './lib/slab-polygon' export { getRenderableSlabPolygon } from './lib/slab-polygon'
export { export {
type AutoCeilingPlanningContext,
type AutoCeilingSyncPlan, type AutoCeilingSyncPlan,
type AutoSlabSyncPlan, type AutoSlabSyncPlan,
detectSpacesForLevel, detectSpacesForLevel,
@@ -69,6 +79,7 @@ export {
pauseSpaceDetection, pauseSpaceDetection,
planAutoCeilingsForLevel, planAutoCeilingsForLevel,
planAutoSlabsForLevel, planAutoSlabsForLevel,
projectAutoSlabsForPlan,
resumeSpaceDetection, resumeSpaceDetection,
type Space, type Space,
wallTouchesOthers, wallTouchesOthers,
@@ -159,6 +170,7 @@ export {
resolveElevatorServiceLevels, resolveElevatorServiceLevels,
} from './systems/elevator/elevator-service' } from './systems/elevator/elevator-service'
export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint' export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint'
export { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview'
export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync' export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync'
export { StairOpeningSystem } from './systems/stair/stair-opening-system' export { StairOpeningSystem } from './systems/stair/stair-opening-system'
export { export {
+102
View File
@@ -0,0 +1,102 @@
export type Point2D = [number, number]
export function pointOnSegment(point: Point2D, start: Point2D, end: Point2D, tolerance = 1e-6) {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const cross = (point[0] - start[0]) * dz - (point[1] - start[1]) * dx
if (Math.abs(cross) > tolerance) return false
const dot =
(point[0] - start[0]) * (point[0] - end[0]) + (point[1] - start[1]) * (point[1] - end[1])
return dot <= tolerance
}
export function pointInPolygon(
point: Point2D,
polygon: Point2D[],
options?: { includeBoundary?: boolean },
) {
if (polygon.length < 3) return false
const includeBoundary = options?.includeBoundary ?? true
if (
polygon.some((start, index) =>
pointOnSegment(point, start, polygon[(index + 1) % polygon.length]!),
)
) {
return includeBoundary
}
let inside = false
const [x, z] = point
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const current = polygon[i]!
const previous = polygon[j]!
const intersects =
current[1] > z !== previous[1] > z &&
x < ((previous[0] - current[0]) * (z - current[1])) / (previous[1] - current[1]) + current[0]
if (intersects) inside = !inside
}
return inside
}
export function segmentsIntersect(a: Point2D, b: Point2D, c: Point2D, d: Point2D) {
const cross = (ux: number, uz: number, vx: number, vz: number) => ux * vz - uz * vx
const abx = b[0] - a[0]
const abz = b[1] - a[1]
const acx = c[0] - a[0]
const acz = c[1] - a[1]
const adx = d[0] - a[0]
const adz = d[1] - a[1]
const cdx = d[0] - c[0]
const cdz = d[1] - c[1]
const cax = a[0] - c[0]
const caz = a[1] - c[1]
const cbx = b[0] - c[0]
const cbz = b[1] - c[1]
const o1 = cross(abx, abz, acx, acz)
const o2 = cross(abx, abz, adx, adz)
const o3 = cross(cdx, cdz, cax, caz)
const o4 = cross(cdx, cdz, cbx, cbz)
if (Math.sign(o1) !== Math.sign(o2) && Math.sign(o3) !== Math.sign(o4)) return true
return (
pointOnSegment(c, a, b) ||
pointOnSegment(d, a, b) ||
pointOnSegment(a, c, d) ||
pointOnSegment(b, c, d)
)
}
export function polygonsIntersect(left: Point2D[], right: Point2D[]) {
for (let leftIndex = 0; leftIndex < left.length; leftIndex++) {
const leftStart = left[leftIndex]!
const leftEnd = left[(leftIndex + 1) % left.length]!
for (let rightIndex = 0; rightIndex < right.length; rightIndex++) {
if (
segmentsIntersect(
leftStart,
leftEnd,
right[rightIndex]!,
right[(rightIndex + 1) % right.length]!,
)
) {
return true
}
}
}
return false
}
export function polygonContainsPolygon(outer: Point2D[], inner: Point2D[]) {
return inner.every((point) => pointInPolygon(point, outer))
}
export function polygonsOverlap(left: Point2D[], right: Point2D[]) {
return (
polygonsIntersect(left, right) ||
left.some((point) => pointInPolygon(point, right)) ||
right.some((point) => pointInPolygon(point, left))
)
}
@@ -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> bbox: ReturnType<typeof bboxOf>
} }
type DetectedCeilingRoom = DetectedRoom & {
ceilingHeight: number
}
export type AutoSlabSyncPlan = { export type AutoSlabSyncPlan = {
create: SlabNodeType[] create: SlabNodeType[]
update: Array<{ id: SlabNodeType['id']; data: Partial<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_SLAB_ELEVATION = 0.05
const DEFAULT_AUTO_CEILING_HEIGHT = 2.5 const DEFAULT_AUTO_CEILING_HEIGHT = 2.5
const CEILING_HEIGHT_EPSILON = 1e-6
const ROOM_CURVE_TOLERANCE = 0.04 const ROOM_CURVE_TOLERANCE = 0.04
const MAX_CURVE_SUBDIVISION_DEPTH = 6 const MAX_CURVE_SUBDIVISION_DEPTH = 6
const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08 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 { function pointFromTuple(point: [number, number]): Point2D {
return { x: point[0], y: point[1] } return { x: point[0], y: point[1] }
@@ -186,6 +197,100 @@ function bboxOverlapArea(a: ReturnType<typeof bboxOf>, b: ReturnType<typeof bbox
return ix * iy 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'>) { function getWallDirection(wall: Pick<WallNode, 'start' | 'end'>) {
const dx = wall.end[0] - wall.start[0] const dx = wall.end[0] - wall.start[0]
const dy = wall.end[1] - wall.start[1] const dy = wall.end[1] - wall.start[1]
@@ -481,6 +586,7 @@ function wallGeometrySignature(wall: WallNode) {
wall.end[0].toFixed(4), wall.end[0].toFixed(4),
wall.end[1].toFixed(4), wall.end[1].toFixed(4),
(wall.thickness ?? 0.2).toFixed(4), (wall.thickness ?? 0.2).toFixed(4),
(wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT).toFixed(4),
getClampedWallCurveOffset(wall).toFixed(4), getClampedWallCurveOffset(wall).toFixed(4),
].join('|') ].join('|')
} }
@@ -489,6 +595,48 @@ function levelWallSnapshot(walls: WallNode[]) {
return walls.map(wallGeometrySignature).sort().join('||') 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 { function buildSpace(levelId: string, polygon: Point2D[]): Space {
const signature = polygonSignature(polygon) const signature = polygonSignature(polygon)
return { return {
@@ -654,18 +802,44 @@ function syncAutoSlabsForLevel(
if (plan.create.length > 0) { if (plan.create.length > 0) {
sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId }))) 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( export function planAutoCeilingsForLevel(
roomPolygons: Point2D[][], roomPolygons: Point2D[][],
existingCeilings: CeilingNodeType[], existingCeilings: CeilingNodeType[],
context: AutoCeilingPlanningContext = {},
): AutoCeilingSyncPlan { ): AutoCeilingSyncPlan {
const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls) const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls)
const manualSignatures = new Set( const manualSignatures = new Set(
manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))), manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))),
) )
const detected: DetectedRoom[] = roomPolygons const detected: DetectedCeilingRoom[] = roomPolygons
.map((poly) => ({ .map((poly) => ({
poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map( poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(
pointFromTuple, pointFromTuple,
@@ -681,6 +855,7 @@ export function planAutoCeilingsForLevel(
centroid: polygonCentroid(room.poly), centroid: polygonCentroid(room.poly),
area: Math.abs(polygonArea(room.poly)), area: Math.abs(polygonArea(room.poly)),
bbox: bboxOf(room.poly), bbox: bboxOf(room.poly),
ceilingHeight: resolveAutoCeilingHeight(room.poly, context),
})) }))
.filter(({ sig }) => !manualSignatures.has(sig)) .filter(({ sig }) => !manualSignatures.has(sig))
@@ -698,7 +873,7 @@ export function planAutoCeilingsForLevel(
const matchedCeilingIds = new Set<string>() const matchedCeilingIds = new Set<string>()
const matchedDetectedIdx = new Set<number>() 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]>() const autoBySignature = new Map<string, (typeof existingAutoMeta)[number]>()
for (const entry of existingAutoMeta) { for (const entry of existingAutoMeta) {
@@ -711,7 +886,10 @@ export function planAutoCeilingsForLevel(
matchedDetectedIdx.add(index) matchedDetectedIdx.add(index)
matchedCeilingIds.add(existing.ceiling.id) 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 const remainingDetected = detected
@@ -746,7 +924,10 @@ export function planAutoCeilingsForLevel(
matchedDetectedIdx.add(index) matchedDetectedIdx.add(index)
matchedCeilingIds.add(bestMatch.entry.ceiling.id) 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 const ceilingsToDelete = existingAuto
@@ -756,12 +937,21 @@ export function planAutoCeilingsForLevel(
const ceilingsToUpdate = existingAuto const ceilingsToUpdate = existingAuto
.filter((ceiling) => updatesById.has(ceiling.id)) .filter((ceiling) => updatesById.has(ceiling.id))
.flatMap((ceiling) => { .flatMap((ceiling) => {
const polygon = updatesById.get(ceiling.id) const update = updatesById.get(ceiling.id)
if (!polygon) return [] if (!update) return []
return sameTuplePolygon(ceiling.polygon, polygon) const data: Partial<CeilingNodeType> = {}
? [] if (!sameTuplePolygon(ceiling.polygon, update.polygon)) {
: [{ id: ceiling.id, data: { 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] const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings]
@@ -780,7 +970,7 @@ export function planAutoCeilingsForLevel(
name, name,
polygon: room.poly.map(pointToTuple), polygon: room.poly.map(pointToTuple),
holes: [], holes: [],
height: DEFAULT_AUTO_CEILING_HEIGHT, height: room.ceilingHeight,
autoFromWalls: true, autoFromWalls: true,
}), }),
) )
@@ -798,8 +988,9 @@ function syncAutoCeilingsForLevel(
roomPolygons: Point2D[][], roomPolygons: Point2D[][],
existingCeilings: CeilingNodeType[], existingCeilings: CeilingNodeType[],
sceneStore: any, sceneStore: any,
context: AutoCeilingPlanningContext = {},
) { ) {
const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings) const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings, context)
if (plan.delete.length > 0) { if (plan.delete.length > 0) {
sceneStore.getState().deleteNodes(plan.delete) sceneStore.getState().deleteNodes(plan.delete)
@@ -882,17 +1073,15 @@ function runSpaceDetection(
) )
} }
syncAutoSlabsForLevel( const parsedSlabs = slabs.map((slab: any) => SlabNode.parse(slab))
levelId, const slabPlan = syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore)
roomPolygons, const projectedSlabs = projectAutoSlabsForPlan(parsedSlabs, slabPlan)
slabs.map((slab: any) => SlabNode.parse(slab)),
sceneStore,
)
syncAutoCeilingsForLevel( syncAutoCeilingsForLevel(
levelId, levelId,
roomPolygons, roomPolygons,
ceilings.map((ceiling: any) => CeilingNode.parse(ceiling)), ceilings.map((ceiling: any) => CeilingNode.parse(ceiling)),
sceneStore, sceneStore,
{ walls, slabs: projectedSlabs },
) )
for (const space of spaces) { for (const space of spaces) {
@@ -935,21 +1124,7 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
if (getSceneHistoryPauseDepth() > 0) return if (getSceneHistoryPauseDepth() > 0) return
const nodes = state.nodes const nodes = state.nodes
const wallsByLevel = new Map<string, WallNode[]>() const currentSnapshots = levelStructureSnapshots(nodes)
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))
}
// Paused: roll the snapshot forward so we don't backfill (and re-duplicate) // Paused: roll the snapshot forward so we don't backfill (and re-duplicate)
// every paused change once detection resumes. Whatever the AI built while // every paused change once detection resumes. Whatever the AI built while
@@ -984,7 +1159,8 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
} finally { } finally {
resumeSceneHistory(sceneStore) resumeSceneHistory(sceneStore)
previousSnapshots.clear() 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) previousSnapshots.set(levelId, snapshot)
} }
isProcessing = false isProcessing = false
+5 -1
View File
@@ -130,6 +130,8 @@ export type LinearResizeHandle<N> = {
overrideTarget?: (node: N, sceneApi: SceneApi) => AnyNodeId | undefined overrideTarget?: (node: N, sceneApi: SceneApi) => AnyNodeId | undefined
min?: number | ((node: N, sceneApi: SceneApi) => number) min?: number | ((node: N, sceneApi: SceneApi) => number)
max?: 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> placement: HandlePlacement<N>
/** /**
* Dimension this handle steers (e.g. `'height'`). When set, the editor * 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 * 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 * (optionally grid-snapped via `snapExtents`) to `apply`. Press-drag-release
* with the same live-override → commit-on-release flow as the resize / rotate * 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> = { export type TranslateHandle<N = any> = {
kind: 'translate' kind: 'translate'
+2
View File
@@ -541,6 +541,8 @@ export type FloorplanAffordance<N> = {
nodes: Record<AnyNodeId, AnyNode> nodes: Record<AnyNodeId, AnyNode>
/** Initial pointer position in plan coordinates. */ /** Initial pointer position in plan coordinates. */
initialPlanPoint: FloorplanAffordancePoint initialPlanPoint: FloorplanAffordancePoint
/** Active editor grid step in meters. */
gridSnapStep: number
}): FloorplanAffordanceSession }): FloorplanAffordanceSession
} }
+1
View File
@@ -101,6 +101,7 @@ export {
getActiveRoofHeight, getActiveRoofHeight,
getEffectiveSegmentSurfaceMaterial, getEffectiveSegmentSurfaceMaterial,
getPitchFromActiveRoofHeight, getPitchFromActiveRoofHeight,
getRoofSegmentSurfaceY,
getSegmentSlopeFrame, getSegmentSlopeFrame,
hasSegmentMaterialOverride, hasSegmentMaterialOverride,
ROOF_SHAPE_DEFAULTS, ROOF_SHAPE_DEFAULTS,
+36 -1
View File
@@ -181,7 +181,6 @@ function getPrimarySlopeRun(input: PitchInputs & ShapeRatios): number {
return min * input.mansardSteepWidthRatio return min * input.mansardSteepWidthRatio
case 'dutch': case 'dutch':
return min * input.dutchHipWidthRatio return min * input.dutchHipWidthRatio
case 'hip':
default: default:
return min / 2 return min / 2
} }
@@ -253,6 +252,42 @@ export function getActiveRoofHeight(node: Parameters<typeof getSegmentSlopeFrame
return getSegmentSlopeFrame(node).activeRh 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 * Inverse of `getActiveRoofHeight` — recover the pitch a legacy
* `roofHeight` value would correspond to. Used by the scene migration. * `roofHeight` value would correspond to. Used by the scene migration.
@@ -8,6 +8,7 @@ type LiveNodeOverrideState = {
setMany(entries: ReadonlyArray<readonly [string, LiveNodeOverrides]>): void setMany(entries: ReadonlyArray<readonly [string, LiveNodeOverrides]>): void
get(nodeId: string): LiveNodeOverrides | undefined get(nodeId: string): LiveNodeOverrides | undefined
clear(nodeId: string): void clear(nodeId: string): void
clearFields(nodeId: string, keys: readonly string[]): void
clearAll(): void clearAll(): void
} }
@@ -39,6 +40,24 @@ const useLiveNodeOverrides = create<LiveNodeOverrideState>((set, get) => ({
next.delete(nodeId) next.delete(nodeId)
return { overrides: next } return { overrides: next }
}), }),
clearFields: (nodeId, keys) =>
set((state) => {
const current = state.overrides.get(nodeId)
if (!current) return state
const nextValues = { ...current }
for (const key of keys) {
delete nextValues[key]
}
const next = new Map(state.overrides)
if (Object.keys(nextValues).length === 0) {
next.delete(nodeId)
} else {
next.set(nodeId, nextValues)
}
return { overrides: next }
}),
clearAll: () => set({ overrides: new Map() }), clearAll: () => set({ overrides: new Map() }),
})) }))
+1 -1
View File
@@ -43,7 +43,7 @@ function getEnumValue<T extends readonly string[]>(
} }
function getNullableString(value: unknown) { function getNullableString(value: unknown) {
return typeof value === 'string' ? value : null return typeof value === 'string' && value.length > 0 ? value : null
} }
function getStringArray(value: unknown) { function getStringArray(value: unknown) {
@@ -0,0 +1,99 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode } from '../../schema'
import { BuildingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema'
import {
getNodesWithLiveStairOpeningInputs,
hasLiveStairOpeningInputs,
} from './stair-opening-preview'
import { syncAutoStairOpenings } from './stair-opening-sync'
describe('stair opening previews', () => {
test('computes auto openings from live stair transforms', () => {
const building = BuildingNode.parse({ name: 'Building' })
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id })
const slab = SlabNode.parse({
name: 'Upper Slab',
parentId: upper.id,
polygon: [
[0, 0],
[5, 0],
[5, 4],
[0, 4],
],
})
const segment = StairSegmentNode.parse({
parentId: 'stair_live',
width: 1,
length: 3,
height: 2.5,
stepCount: 12,
})
const stair = StairNode.parse({
id: 'stair_live',
name: 'Live Stair',
parentId: ground.id,
position: [1, 0, 0.2],
stairType: 'straight',
fromLevelId: ground.id,
toLevelId: upper.id,
slabOpeningMode: 'destination',
children: [segment.id],
})
const nodes = Object.fromEntries(
[building, ground, upper, slab, stair, { ...segment, parentId: stair.id }].map((node) => [
node.id,
node,
]),
) as Record<string, AnyNode>
const liveTransforms = new Map([
[stair.id, { position: [3, 0, 0.2] as [number, number, number], rotation: 0 }],
])
const liveOverrides = new Map<string, Record<string, unknown>>()
expect(hasLiveStairOpeningInputs(nodes, liveTransforms, liveOverrides, new Set())).toBe(true)
const previewNodes = getNodesWithLiveStairOpeningInputs(
nodes,
liveTransforms,
liveOverrides,
new Set(),
)
const updates = syncAutoStairOpenings(previewNodes)
const hole = updates.find((update) => update.id === slab.id)?.data.holes?.[0]
expect(hole).toBeDefined()
expect(Math.max(...hole!.map(([x]) => x))).toBeGreaterThan(3.4)
})
test('ignores its own live surface overrides as preview inputs', () => {
const slab = SlabNode.parse({
polygon: [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
],
})
const nodes = { [slab.id]: slab } as Record<string, AnyNode>
const liveOverrides = new Map<string, Record<string, unknown>>([
[
slab.id,
{
holes: [
[
[1, 1],
[2, 1],
[2, 2],
[1, 2],
],
],
},
],
])
expect(hasLiveStairOpeningInputs(nodes, new Map(), liveOverrides, new Set([slab.id]))).toBe(
false,
)
})
})
@@ -0,0 +1,120 @@
import type { AnyNode, AnyNodeId, CeilingNode, SlabNode } from '../../schema'
import useLiveNodeOverrides, { type LiveNodeOverrides } from '../../store/use-live-node-overrides'
import type { LiveTransform } from '../../store/use-live-transforms'
import useScene from '../../store/use-scene'
type SurfaceOpeningUpdate = {
id: AnyNodeId
data: Partial<SlabNode | CeilingNode>
}
const SURFACE_OPENING_FIELDS = ['holes', 'holeMetadata'] as const
function isSurface(node: AnyNode | undefined): node is SlabNode | CeilingNode {
return node?.type === 'slab' || node?.type === 'ceiling'
}
function isStairOpeningInputNode(node: AnyNode | undefined) {
return node?.type === 'stair' || node?.type === 'stair-segment'
}
function omitPreviewSurfaceFields(override: LiveNodeOverrides) {
const next = { ...override }
for (const field of SURFACE_OPENING_FIELDS) {
delete next[field]
}
return next
}
export function hasLiveStairOpeningInputs(
nodes: Record<string, AnyNode>,
liveTransforms: ReadonlyMap<string, LiveTransform>,
liveOverrides: ReadonlyMap<string, LiveNodeOverrides>,
previewSurfaceIds: ReadonlySet<string>,
) {
for (const nodeId of liveTransforms.keys()) {
if (nodes[nodeId]?.type === 'stair') return true
}
for (const [nodeId, override] of liveOverrides) {
if (previewSurfaceIds.has(nodeId)) continue
if (Object.keys(override).length > 0 && isStairOpeningInputNode(nodes[nodeId])) return true
}
return false
}
export function getNodesWithLiveStairOpeningInputs(
nodes: Record<string, AnyNode>,
liveTransforms: ReadonlyMap<string, LiveTransform>,
liveOverrides: ReadonlyMap<string, LiveNodeOverrides>,
previewSurfaceIds: ReadonlySet<string>,
) {
const nextNodes: Record<string, AnyNode> = { ...nodes }
for (const [nodeId, override] of liveOverrides) {
const node = nextNodes[nodeId]
if (!node) continue
const values = previewSurfaceIds.has(nodeId) ? omitPreviewSurfaceFields(override) : override
if (Object.keys(values).length === 0) continue
nextNodes[nodeId] = { ...node, ...values } as AnyNode
}
for (const [nodeId, transform] of liveTransforms) {
const node = nextNodes[nodeId]
if (node?.type !== 'stair') continue
nextNodes[nodeId] = {
...node,
position: transform.position,
rotation: transform.rotation,
}
}
return nextNodes
}
export function createSurfaceOpeningPreviewController() {
const previewSurfaceIds = new Set<AnyNodeId>()
const clearSurface = (id: AnyNodeId) => {
useLiveNodeOverrides.getState().clearFields(id, SURFACE_OPENING_FIELDS)
useScene.getState().markDirty(id)
}
return {
previewSurfaceIds,
apply(updates: SurfaceOpeningUpdate[]) {
const scene = useScene.getState()
const nextSurfaceIds = new Set<AnyNodeId>()
for (const update of updates) {
const node = scene.nodes[update.id]
if (!isSurface(node)) continue
if (!('holes' in update.data || 'holeMetadata' in update.data)) continue
nextSurfaceIds.add(update.id)
useLiveNodeOverrides.getState().set(update.id, {
holes: update.data.holes ?? [],
holeMetadata: update.data.holeMetadata ?? [],
})
scene.markDirty(update.id)
}
for (const id of previewSurfaceIds) {
if (!nextSurfaceIds.has(id)) clearSurface(id)
}
previewSurfaceIds.clear()
for (const id of nextSurfaceIds) {
previewSurfaceIds.add(id)
}
},
clear() {
for (const id of previewSurfaceIds) {
clearSurface(id)
}
previewSurfaceIds.clear()
},
}
}
@@ -11,7 +11,7 @@ import {
import { syncAutoStairOpenings } from './stair-opening-sync' import { syncAutoStairOpenings } from './stair-opening-sync'
describe('syncAutoStairOpenings', () => { describe('syncAutoStairOpenings', () => {
test('only applies stair holes to destination slabs that contain the opening', () => { test('only applies stair holes to destination slabs that overlap the opening', () => {
const building = BuildingNode.parse({ name: 'Building' }) const building = BuildingNode.parse({ name: 'Building' })
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id })
@@ -74,6 +74,255 @@ describe('syncAutoStairOpenings', () => {
expect(bedroomUpdate).toBeUndefined() expect(bedroomUpdate).toBeUndefined()
}) })
test('applies stair holes to a later destination slab when the configured offset overhangs the slab edge', () => {
const building = BuildingNode.parse({ name: 'Building' })
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id })
const landingSlab = SlabNode.parse({
name: 'Landing Slab',
parentId: upper.id,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
})
const segment = StairSegmentNode.parse({
parentId: 'stair_edge',
width: 1,
length: 2.6,
height: 2.5,
stepCount: 12,
})
const stair = StairNode.parse({
id: 'stair_edge',
name: 'Edge Stair',
parentId: ground.id,
position: [2, 0, 0],
stairType: 'straight',
fromLevelId: ground.id,
toLevelId: upper.id,
slabOpeningMode: 'destination',
openingOffset: 0.08,
children: [segment.id],
})
const nodes = Object.fromEntries(
[building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map(
(node) => [node.id, node],
),
) as Record<string, AnyNode>
const updates = syncAutoStairOpenings(nodes)
const landingUpdate = updates.find((update) => update.id === landingSlab.id)
const hole = landingUpdate?.data.holes?.[0]
expect(hole).toBeDefined()
expect(Math.min(...hole!.map(([, z]) => z))).toBeCloseTo(-0.08)
expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }])
})
test('does not apply stair holes to slabs on another building with a matching level number', () => {
const buildingA = BuildingNode.parse({ name: 'Building A' })
const groundA = LevelNode.parse({ name: 'Ground A', level: 0, parentId: buildingA.id })
const upperA = LevelNode.parse({ name: 'Upper A', level: 1, parentId: buildingA.id })
const buildingB = BuildingNode.parse({ name: 'Building B' })
const upperB = LevelNode.parse({ name: 'Upper B', level: 1, parentId: buildingB.id })
const slabA = SlabNode.parse({
name: 'Upper A Slab',
parentId: upperA.id,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
})
const slabB = SlabNode.parse({
name: 'Upper B Slab',
parentId: upperB.id,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
})
const segment = StairSegmentNode.parse({
parentId: 'stair_scoped',
width: 1,
length: 2.6,
height: 2.5,
stepCount: 12,
})
const stair = StairNode.parse({
id: 'stair_scoped',
name: 'Scoped Stair',
parentId: groundA.id,
position: [2, 0, 0.2],
stairType: 'straight',
fromLevelId: groundA.id,
toLevelId: upperA.id,
slabOpeningMode: 'destination',
children: [segment.id],
})
const nodes = Object.fromEntries(
[
buildingA,
groundA,
upperA,
buildingB,
upperB,
slabA,
slabB,
stair,
{ ...segment, parentId: stair.id },
].map((node) => [node.id, node]),
) as Record<string, AnyNode>
const updates = syncAutoStairOpenings(nodes)
expect(updates.find((update) => update.id === slabA.id)?.data.holes).toHaveLength(1)
expect(updates.find((update) => update.id === slabB.id)).toBeUndefined()
})
test('uses the parent level when a stair has stale from-level data', () => {
const building = BuildingNode.parse({ name: 'Building' })
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id })
const landingSlab = SlabNode.parse({
name: 'Landing Slab',
parentId: upper.id,
polygon: [
[0, 0],
[4, 0],
[4, 8],
[0, 8],
],
})
const segment = StairSegmentNode.parse({
parentId: 'stair_stale_from',
width: 1,
length: 6,
height: 2.5,
stepCount: 12,
})
const stair = StairNode.parse({
id: 'stair_stale_from',
name: 'Stale From Stair',
parentId: ground.id,
position: [2, 0, 0.2],
stairType: 'straight',
fromLevelId: 'default',
toLevelId: upper.id,
slabOpeningMode: 'destination',
children: [segment.id],
})
const nodes = Object.fromEntries(
[building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map(
(node) => [node.id, node],
),
) as Record<string, AnyNode>
const updates = syncAutoStairOpenings(nodes)
const landingUpdate = updates.find((update) => update.id === landingSlab.id)
const hole = landingUpdate?.data.holes?.[0]
expect(hole).toBeDefined()
expect(Math.min(...hole!.map(([, z]) => z))).toBeGreaterThan(0.9)
expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }])
})
test('infers the destination level when a destination stair has blank level fields', () => {
const building = BuildingNode.parse({ name: 'Building' })
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id })
const landingSlab = SlabNode.parse({
name: 'Landing Slab',
parentId: upper.id,
polygon: [
[0, 0],
[4, 0],
[4, 8],
[0, 8],
],
})
const segment = StairSegmentNode.parse({
parentId: 'stair_blank_levels',
width: 1,
length: 6,
height: 2.5,
stepCount: 12,
})
const stair = StairNode.parse({
id: 'stair_blank_levels',
name: 'Blank Level Stair',
parentId: ground.id,
position: [2, 0, 0.2],
stairType: 'straight',
fromLevelId: '',
toLevelId: '',
slabOpeningMode: 'destination',
children: [segment.id],
})
const nodes = Object.fromEntries(
[building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map(
(node) => [node.id, node],
),
) as Record<string, AnyNode>
const updates = syncAutoStairOpenings(nodes)
const landingUpdate = updates.find((update) => update.id === landingSlab.id)
expect(landingUpdate?.data.holes).toHaveLength(1)
expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }])
})
test('infers the destination level when a destination stair targets its source level', () => {
const building = BuildingNode.parse({ name: 'Building' })
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id })
const landingSlab = SlabNode.parse({
name: 'Landing Slab',
parentId: upper.id,
polygon: [
[0, 0],
[4, 0],
[4, 8],
[0, 8],
],
})
const segment = StairSegmentNode.parse({
parentId: 'stair_self_target',
width: 1,
length: 6,
height: 2.5,
stepCount: 12,
})
const stair = StairNode.parse({
id: 'stair_self_target',
name: 'Self Target Stair',
parentId: ground.id,
position: [2, 0, 0.2],
stairType: 'straight',
fromLevelId: ground.id,
toLevelId: ground.id,
slabOpeningMode: 'destination',
children: [segment.id],
})
const nodes = Object.fromEntries(
[building, ground, upper, landingSlab, stair, { ...segment, parentId: stair.id }].map(
(node) => [node.id, node],
),
) as Record<string, AnyNode>
const updates = syncAutoStairOpenings(nodes)
const landingUpdate = updates.find((update) => update.id === landingSlab.id)
expect(landingUpdate?.data.holes).toHaveLength(1)
expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }])
})
test('does not add stair holes when a manual surface hole already covers them', () => { test('does not add stair holes when a manual surface hole already covers them', () => {
const building = BuildingNode.parse({ name: 'Building' }) const building = BuildingNode.parse({ name: 'Building' })
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
@@ -1,4 +1,5 @@
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' import { resolveBuildingForLevel, resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import { type Point2D, polygonContainsPolygon, polygonsOverlap } from '../../lib/polygon-relations'
import type { import type {
AnyNode, AnyNode,
AnyNodeId, AnyNodeId,
@@ -11,8 +12,6 @@ import type {
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint' import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
import { computeSegmentTransforms, rotateXZ } from './stair-footprint' import { computeSegmentTransforms, rotateXZ } from './stair-footprint'
type Point2D = [number, number]
type SegmentTransform = { type SegmentTransform = {
position: [number, number, number] position: [number, number, number]
rotation: number rotation: number
@@ -85,10 +84,106 @@ function getLevelNumber(levelId: string | null, nodes: Record<string, AnyNode>)
return node?.type === 'level' ? node.level : undefined return node?.type === 'level' ? node.level : undefined
} }
function getLevelBuildingId(levelId: string | null, nodes: Record<string, AnyNode>) {
if (!levelId) return null
return resolveBuildingForLevel(levelId as AnyNodeId, nodes as Record<AnyNodeId, AnyNode>)
}
function normalizeLevelId(levelId: string | null | undefined, nodes: Record<string, AnyNode>) {
if (!levelId) return null
return nodes[levelId as AnyNodeId]?.type === 'level' ? levelId : null
}
function getBuildingLevels(buildingId: string | null, nodes: Record<string, AnyNode>) {
const building = buildingId ? nodes[buildingId as AnyNodeId] : null
if (building?.type !== 'building') return []
const levels = new Map<string, Extract<AnyNode, { type: 'level' }>>()
for (const childId of building.children ?? []) {
const child = nodes[childId as AnyNodeId]
if (child?.type === 'level') levels.set(child.id, child)
}
for (const candidate of Object.values(nodes)) {
if (candidate?.type === 'level' && candidate.parentId === building.id) {
levels.set(candidate.id, candidate)
}
}
return Array.from(levels.values()).sort((left, right) => left.level - right.level)
}
function inferSourceLevelForDestination(
destinationLevelId: string | null,
nodes: Record<string, AnyNode>,
) {
if (!destinationLevelId) return null
const destination = nodes[destinationLevelId as AnyNodeId]
if (destination?.type !== 'level') return null
const buildingId = getLevelBuildingId(destinationLevelId, nodes)
return (
getBuildingLevels(buildingId, nodes)
.filter((level) => level.level < destination.level)
.at(-1)?.id ?? null
)
}
function inferDestinationLevelForSource(
sourceLevelId: string | null,
nodes: Record<string, AnyNode>,
) {
if (!sourceLevelId) return null
const source = nodes[sourceLevelId as AnyNodeId]
if (source?.type !== 'level') return null
const buildingId = getLevelBuildingId(sourceLevelId, nodes)
return (
getBuildingLevels(buildingId, nodes).find((level) => level.level > source.level)?.id ?? null
)
}
function levelsShareBuilding(
leftLevelId: string | null,
rightLevelId: string | null,
nodes: Record<string, AnyNode>,
) {
if (!(leftLevelId && rightLevelId)) return true
const leftBuildingId = getLevelBuildingId(leftLevelId, nodes)
const rightBuildingId = getLevelBuildingId(rightLevelId, nodes)
return !(leftBuildingId && rightBuildingId && leftBuildingId !== rightBuildingId)
}
function isInStairBuildingScope(
stair: StairNode,
surfaceLevelId: string,
nodes: Record<string, AnyNode>,
) {
const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes)
const fromBuildingId = getLevelBuildingId(fromLevelId, nodes)
const toBuildingId = getLevelBuildingId(toLevelId, nodes)
const surfaceBuildingId = getLevelBuildingId(surfaceLevelId, nodes)
if (fromBuildingId && toBuildingId && fromBuildingId !== toBuildingId) return false
if (fromBuildingId && surfaceBuildingId && fromBuildingId !== surfaceBuildingId) return false
if (toBuildingId && surfaceBuildingId && toBuildingId !== surfaceBuildingId) return false
return true
}
function getResolvedStairLevelIds(stair: StairNode, nodes: Record<string, AnyNode>) { function getResolvedStairLevelIds(stair: StairNode, nodes: Record<string, AnyNode>) {
const parentLevelId = resolveLevelId(stair, nodes) const parentLevelId = normalizeLevelId(resolveLevelId(stair, nodes), nodes)
const fromLevelId = stair.fromLevelId ?? parentLevelId const explicitToLevelId = normalizeLevelId(stair.toLevelId, nodes)
const toLevelId = stair.toLevelId ?? fromLevelId const fromLevelId =
normalizeLevelId(stair.fromLevelId, nodes) ??
parentLevelId ??
inferSourceLevelForDestination(explicitToLevelId, nodes)
const explicitToLevelIsUsable =
explicitToLevelId &&
explicitToLevelId !== fromLevelId &&
levelsShareBuilding(fromLevelId, explicitToLevelId, nodes)
const toLevelId = explicitToLevelIsUsable
? explicitToLevelId
: inferDestinationLevelForSource(fromLevelId, nodes)
return { fromLevelId, toLevelId } return { fromLevelId, toLevelId }
} }
@@ -192,36 +287,6 @@ function polygonArea(points: Point2D[]) {
return area / 2 return area / 2
} }
function pointOnSegment(point: Point2D, a: Point2D, b: Point2D, tolerance = 1e-6) {
const cross = (point[1] - a[1]) * (b[0] - a[0]) - (point[0] - a[0]) * (b[1] - a[1])
if (Math.abs(cross) > tolerance) return false
const dot = (point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1])
if (dot < -tolerance) return false
const lenSq = (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2
return dot <= lenSq + tolerance
}
function pointInPolygon(point: Point2D, polygon: Point2D[]) {
if (polygon.length < 3) return false
let inside = false
const [x, z] = point
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const a = polygon[i]!
const b = polygon[j]!
if (pointOnSegment(point, a, b)) return true
const intersects =
a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0]
if (intersects) inside = !inside
}
return inside
}
function polygonContainsPolygon(outer: Point2D[], inner: Point2D[]) {
return inner.every((point) => pointInPolygon(point, outer))
}
function isCoveredByExistingHole(existingHoles: Point2D[][], autoHole: Point2D[]) { function isCoveredByExistingHole(existingHoles: Point2D[][], autoHole: Point2D[]) {
return existingHoles.some((existingHole) => polygonContainsPolygon(existingHole, autoHole)) return existingHoles.some((existingHole) => polygonContainsPolygon(existingHole, autoHole))
} }
@@ -409,13 +474,14 @@ function getStraightOpeningPolygonsForSurface(
stair: StairNode, stair: StairNode,
nodes: Record<string, AnyNode>, nodes: Record<string, AnyNode>,
targetElevation: number, targetElevation: number,
openingOffsetOverride?: number,
) { ) {
const layouts = getStraightStairLayouts(stair, nodes) const layouts = getStraightStairLayouts(stair, nodes)
if (layouts.length === 0) return [] if (layouts.length === 0) return []
const riserHeight = (stair.totalRise ?? 2.5) / Math.max(stair.stepCount ?? 10, 1) const riserHeight = (stair.totalRise ?? 2.5) / Math.max(stair.stepCount ?? 10, 1)
const targetThreshold = Math.max(riserHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN) const targetThreshold = Math.max(riserHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN)
const openingOffset = Math.max(stair.openingOffset ?? 0, 0.15) const openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0)
const openingRects: AxisAlignedRect[] = [] const openingRects: AxisAlignedRect[] = []
for (let index = 0; index < layouts.length; index += 1) { for (let index = 0; index < layouts.length; index += 1) {
@@ -493,22 +559,22 @@ function getStairOpeningPolygons(
stair: StairNode, stair: StairNode,
nodes: Record<string, AnyNode>, nodes: Record<string, AnyNode>,
targetElevation?: number, targetElevation?: number,
openingOffsetOverride?: number,
) { ) {
if ((stair.slabOpeningMode ?? 'none') !== 'destination') { if ((stair.slabOpeningMode ?? 'none') !== 'destination') {
return [] return []
} }
const openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0)
if (stair.stairType === 'curved') { if (stair.stairType === 'curved') {
return [ return [
getCurvedOpeningPolygon( getCurvedOpeningPolygon(stair, Math.max(openingOffset - STAIR_SLAB_OPENING_TIGHTENING, 0)),
stair,
Math.max((stair.openingOffset ?? 0) - STAIR_SLAB_OPENING_TIGHTENING, 0.15),
),
] ]
} }
if (stair.stairType === 'spiral') { if (stair.stairType === 'spiral') {
const offset = Math.max((stair.openingOffset ?? 0) - STAIR_SLAB_OPENING_TIGHTENING, 0.15) const offset = Math.max(openingOffset - STAIR_SLAB_OPENING_TIGHTENING, 0)
const polygons = [getSpiralOpeningPolygon(stair, offset)] const polygons = [getSpiralOpeningPolygon(stair, offset)]
if (stair.topLandingMode === 'integrated') { if (stair.topLandingMode === 'integrated') {
polygons.push(getSpiralLandingPolygon(stair, offset)) polygons.push(getSpiralLandingPolygon(stair, offset))
@@ -517,16 +583,41 @@ function getStairOpeningPolygons(
} }
if (typeof targetElevation === 'number') { if (typeof targetElevation === 'number') {
return getStraightOpeningPolygonsForSurface(stair, nodes, targetElevation) return getStraightOpeningPolygonsForSurface(stair, nodes, targetElevation, openingOffset)
} }
return getStraightOpeningPolygonsForSurface( return getStraightOpeningPolygonsForSurface(
stair, stair,
nodes, nodes,
Math.max(...getStraightStairLayouts(stair, nodes).map((layout) => layout.topElevation), 0), Math.max(...getStraightStairLayouts(stair, nodes).map((layout) => layout.topElevation), 0),
openingOffset,
) )
} }
function getApplicableStairOpeningPolygons(
stair: StairNode,
nodes: Record<string, AnyNode>,
targetElevation: number,
surfacePolygon: Point2D[],
) {
const configuredOffset = Math.max(stair.openingOffset ?? 0, 0)
const polygons = getStairOpeningPolygons(stair, nodes, targetElevation, configuredOffset)
const overlappingPolygons = polygons.filter((polygon) => polygonsOverlap(surfacePolygon, polygon))
if (overlappingPolygons.length === polygons.length || configuredOffset <= 1e-6) {
return overlappingPolygons
}
const fallbackPolygons = getStairOpeningPolygons(stair, nodes, targetElevation, 0)
const overlappingFallbackPolygons = fallbackPolygons.filter((polygon) =>
polygonsOverlap(surfacePolygon, polygon),
)
return overlappingFallbackPolygons.length === fallbackPolygons.length
? overlappingFallbackPolygons
: overlappingPolygons
}
function getTargetSlabElevationForStair( function getTargetSlabElevationForStair(
stair: StairNode, stair: StairNode,
slab: SlabNode, slab: SlabNode,
@@ -579,6 +670,8 @@ function shouldApplyStairToSlab(
const toLevel = getLevelNumber(toLevelId, nodes) const toLevel = getLevelNumber(toLevelId, nodes)
const slabLevel = getLevelNumber(slabLevelId, nodes) const slabLevel = getLevelNumber(slabLevelId, nodes)
if (!isInStairBuildingScope(stair, slabLevelId, nodes)) return false
if (slabLevel === undefined) { if (slabLevel === undefined) {
return toLevelId === slabLevelId return toLevelId === slabLevelId
} }
@@ -602,6 +695,8 @@ function shouldApplyStairToCeiling(
const toLevel = getLevelNumber(toLevelId, nodes) const toLevel = getLevelNumber(toLevelId, nodes)
const ceilingLevel = getLevelNumber(ceilingLevelId, nodes) const ceilingLevel = getLevelNumber(ceilingLevelId, nodes)
if (!isInStairBuildingScope(stair, ceilingLevelId, nodes)) return false
if (ceilingLevel === undefined) { if (ceilingLevel === undefined) {
return fromLevelId === ceilingLevelId return fromLevelId === ceilingLevelId
} }
@@ -637,10 +732,11 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
const stairHoles = stairs const stairHoles = stairs
.filter((stair) => shouldApplyStairToSlab(stair, slabLevelId, nodes)) .filter((stair) => shouldApplyStairToSlab(stair, slabLevelId, nodes))
.flatMap((stair) => .flatMap((stair) =>
getStairOpeningPolygons( getApplicableStairOpeningPolygons(
stair, stair,
nodes, nodes,
getTargetSlabElevationForStair(stair, slab, slabLevelId, nodes), getTargetSlabElevationForStair(stair, slab, slabLevelId, nodes),
slab.polygon,
).map((polygon) => ({ ).map((polygon) => ({
polygon, polygon,
metadata: { metadata: {
@@ -649,7 +745,6 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
}, },
})), })),
) )
.filter((hole) => polygonContainsPolygon(slab.polygon, hole.polygon))
.filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon))
const nextHoles = [ const nextHoles = [
@@ -686,10 +781,11 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
const stairHoles = stairs const stairHoles = stairs
.filter((stair) => shouldApplyStairToCeiling(stair, ceilingLevelId, nodes)) .filter((stair) => shouldApplyStairToCeiling(stair, ceilingLevelId, nodes))
.flatMap((stair) => .flatMap((stair) =>
getStairOpeningPolygons( getApplicableStairOpeningPolygons(
stair, stair,
nodes, nodes,
getTargetCeilingElevationForStair(stair, ceiling, ceilingLevelId, nodes), getTargetCeilingElevationForStair(stair, ceiling, ceilingLevelId, nodes),
ceiling.polygon,
).map((polygon) => ({ ).map((polygon) => ({
polygon, polygon,
metadata: { metadata: {
@@ -698,7 +794,6 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
}, },
})), })),
) )
.filter((hole) => polygonContainsPolygon(ceiling.polygon, hole.polygon))
.filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon)) .filter((hole) => !isCoveredByExistingHole(preservedHolePolygons, hole.polygon))
const nextHoles = [ const nextHoles = [
@@ -2,7 +2,15 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import type { AnyNode } from '../../schema' import type { AnyNode } from '../../schema'
import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control'
import useLiveNodeOverrides from '../../store/use-live-node-overrides'
import useLiveTransforms from '../../store/use-live-transforms'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import {
createSurfaceOpeningPreviewController,
getNodesWithLiveStairOpeningInputs,
hasLiveStairOpeningInputs,
} from './stair-opening-preview'
import { syncAutoStairOpenings } from './stair-opening-sync' import { syncAutoStairOpenings } from './stair-opening-sync'
function isOpeningRelevantNode(node: AnyNode | undefined) { function isOpeningRelevantNode(node: AnyNode | undefined) {
@@ -35,24 +43,90 @@ function hasOpeningRelevantNodeChange(
export const StairOpeningSystem = () => { export const StairOpeningSystem = () => {
const syncingAutoOpeningsRef = useRef(false) const syncingAutoOpeningsRef = useRef(false)
const syncingPreviewOpeningsRef = useRef(false)
const previewControllerRef = useRef(createSurfaceOpeningPreviewController())
useEffect(() => { useEffect(() => {
const applyUpdates = (updates: ReturnType<typeof syncAutoStairOpenings>) => { const applyUpdates = (updates: ReturnType<typeof syncAutoStairOpenings>) => {
if (updates.length === 0) return if (updates.length === 0) return
syncingAutoOpeningsRef.current = true syncingAutoOpeningsRef.current = true
useScene.getState().updateNodes(updates) pauseSceneHistory(useScene)
try {
useScene.getState().updateNodes(updates)
} finally {
resumeSceneHistory(useScene)
}
queueMicrotask(() => { queueMicrotask(() => {
syncingAutoOpeningsRef.current = false syncingAutoOpeningsRef.current = false
}) })
} }
applyUpdates(syncAutoStairOpenings(useScene.getState().nodes)) const applyPreviewUpdates = (updates: ReturnType<typeof syncAutoStairOpenings>) => {
syncingPreviewOpeningsRef.current = true
previewControllerRef.current.apply(updates)
queueMicrotask(() => {
syncingPreviewOpeningsRef.current = false
})
}
return useScene.subscribe((state, prevState) => { const clearPreviewUpdates = () => {
if (previewControllerRef.current.previewSurfaceIds.size === 0) return
syncingPreviewOpeningsRef.current = true
previewControllerRef.current.clear()
queueMicrotask(() => {
syncingPreviewOpeningsRef.current = false
})
}
const refreshLivePreview = () => {
if (syncingPreviewOpeningsRef.current) return
const nodes = useScene.getState().nodes
const liveTransforms = useLiveTransforms.getState().transforms
const liveOverrides = useLiveNodeOverrides.getState().overrides
const previewSurfaceIds = previewControllerRef.current.previewSurfaceIds
if (!hasLiveStairOpeningInputs(nodes, liveTransforms, liveOverrides, previewSurfaceIds)) {
clearPreviewUpdates()
return
}
applyPreviewUpdates(
syncAutoStairOpenings(
getNodesWithLiveStairOpeningInputs(
nodes,
liveTransforms,
liveOverrides,
previewSurfaceIds,
),
),
)
}
applyUpdates(syncAutoStairOpenings(useScene.getState().nodes))
refreshLivePreview()
const unsubscribeScene = useScene.subscribe((state, prevState) => {
if (syncingAutoOpeningsRef.current) return if (syncingAutoOpeningsRef.current) return
if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return
applyUpdates(syncAutoStairOpenings(state.nodes)) applyUpdates(syncAutoStairOpenings(state.nodes))
refreshLivePreview()
}) })
const unsubscribeLiveTransforms = useLiveTransforms.subscribe(() => {
refreshLivePreview()
})
const unsubscribeLiveOverrides = useLiveNodeOverrides.subscribe(() => {
refreshLivePreview()
})
return () => {
unsubscribeScene()
unsubscribeLiveTransforms()
unsubscribeLiveOverrides()
previewControllerRef.current.clear()
}
}, []) }, [])
return null return null
@@ -1,7 +1,9 @@
'use client' 'use client'
import { useAlignmentGuides } from '@pascal-app/core' import { useAlignmentGuides } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { memo } from 'react' import { memo } from 'react'
import { formatMeasurement } from '../editor/measurement-pill'
import { useFloorplanRender } from './floorplan-render-context' import { useFloorplanRender } from './floorplan-render-context'
/** /**
@@ -23,6 +25,7 @@ import { useFloorplanRender } from './floorplan-render-context'
*/ */
export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() { export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() {
const guides = useAlignmentGuides((s) => s.guides) const guides = useAlignmentGuides((s) => s.guides)
const unit = useViewer((s) => s.unit)
const ctx = useFloorplanRender() const ctx = useFloorplanRender()
if (guides.length === 0) return null if (guides.length === 0) return null
@@ -56,7 +59,7 @@ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuid
// offset along X. // offset along X.
const pillX = axis === 'x' ? midX + pillOffset : midX const pillX = axis === 'x' ? midX + pillOffset : midX
const pillZ = axis === 'z' ? midZ + pillOffset : midZ const pillZ = axis === 'z' ? midZ + pillOffset : midZ
const distLabel = formatMeters(distMeters) const distLabel = formatMeasurement(distMeters, unit)
const charWidth = pillFontSize * 0.55 const charWidth = pillFontSize * 0.55
const pillWidth = distLabel.length * charWidth + pillPadX * 2 const pillWidth = distLabel.length * charWidth + pillPadX * 2
const pillHeight = pillFontSize + pillPadY * 2 const pillHeight = pillFontSize + pillPadY * 2
@@ -135,10 +138,3 @@ function XCap({
</g> </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, payload,
nodes: sceneNodes, nodes: sceneNodes,
initialPlanPoint, initialPlanPoint,
gridSnapStep: useEditor.getState().gridSnapStep,
}) })
const snapshots: NodeSnapshot[] = [] const snapshots: NodeSnapshot[] = []
@@ -8,6 +8,7 @@ import { memo, useMemo, useRef } from 'react'
import { BoxGeometry, CircleGeometry, type Group } from 'three' import { BoxGeometry, CircleGeometry, type Group } from 'three'
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants' import { EDITOR_LAYER } from '../../lib/constants'
import { formatMeasurement } from './measurement-pill'
/** /**
* Figma-style alignment guides for the 3D editor — the spatial twin of * 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() { export const Alignment3DGuideLayer = memo(function Alignment3DGuideLayer() {
const guides = useAlignmentGuides((s) => s.guides) const guides = useAlignmentGuides((s) => s.guides)
const levelId = useViewer((s) => s.selection.levelId) const levelId = useViewer((s) => s.selection.levelId)
const unit = useViewer((s) => s.unit)
const groupRef = useRef<Group>(null) const groupRef = useRef<Group>(null)
// Guides carry only XZ (building-local plan coords); their Y has to track // Guides carry only XZ (building-local plan coords); their Y has to track
@@ -71,16 +73,16 @@ export const Alignment3DGuideLayer = memo(function Alignment3DGuideLayer() {
return ( return (
<group ref={groupRef}> <group ref={groupRef}>
{guides.map((guide, i) => ( {guides.map((guide, i) => (
<GuideLine guide={guide} key={i} /> <GuideLine guide={guide} key={i} unit={unit} />
))} ))}
</group> </group>
) )
}) })
function GuideLine({ guide }: { guide: AlignmentGuide }) { function GuideLine({ guide, unit }: { guide: AlignmentGuide; unit: 'metric' | 'imperial' }) {
const { x: fx, z: fz } = guide.from const { x: fx, z: fz } = guide.from
const { x: tx, z: tz } = guide.to 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 // 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 // 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 = { type HandleDragSession = {
move: (context: HandleDragMoveContext) => Partial<AnyNode> | null move: (context: HandleDragMoveContext) => Partial<AnyNode> | null
markDirty?: boolean
onBegin?: () => void onBegin?: () => void
onEnd?: () => void onEnd?: () => void
overrideId?: AnyNodeId overrideId?: AnyNodeId
@@ -122,6 +123,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
if (!session) return if (!session) return
const overrideId = session.overrideId ?? nodeId const overrideId = session.overrideId ?? nodeId
const markDirty = session.markDirty !== false
document.body.style.cursor = cursor document.body.style.cursor = cursor
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
useViewer.getState().setInputDragging(true) useViewer.getState().setInputDragging(true)
@@ -137,7 +139,9 @@ export function useHandleDrag(args: UseHandleDragArgs) {
if (!patch) return if (!patch) return
lastPatch = patch lastPatch = patch
useLiveNodeOverrides.getState().set(overrideId, patch as Record<string, unknown>) useLiveNodeOverrides.getState().set(overrideId, patch as Record<string, unknown>)
useScene.getState().markDirty(overrideId) if (markDirty) {
useScene.getState().markDirty(overrideId)
}
} }
const cleanup = () => { const cleanup = () => {
@@ -157,7 +161,9 @@ export function useHandleDrag(args: UseHandleDragArgs) {
const clearOverride = () => { const clearOverride = () => {
useLiveNodeOverrides.getState().clear(overrideId) useLiveNodeOverrides.getState().clear(overrideId)
useScene.getState().markDirty(overrideId) if (markDirty) {
useScene.getState().markDirty(overrideId)
}
} }
const onUp = () => { const onUp = () => {
@@ -12,6 +12,7 @@ import {
nodeRegistry, nodeRegistry,
type RadialResizeHandle, type RadialResizeHandle,
sceneRegistry, sceneRegistry,
snapScalar,
type TapActionHandle, type TapActionHandle,
type TranslateHandle, type TranslateHandle,
useLiveNodeOverrides, useLiveNodeOverrides,
@@ -581,6 +582,10 @@ function LinearArrow({
descriptor.axis === 'x' ? hitLocal.x : descriptor.axis === 'y' ? hitLocal.y : hitLocal.z descriptor.axis === 'x' ? hitLocal.x : descriptor.axis === 'y' ? hitLocal.y : hitLocal.z
const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi) const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi)
const maxBound = resolveBound(descriptor.max, Number.POSITIVE_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 = const factor =
descriptor.kind === 'radial-resize' descriptor.kind === 'radial-resize'
? 1 ? 1
@@ -615,7 +620,10 @@ function LinearArrow({
? intersectionLocal.y ? intersectionLocal.y
: intersectionLocal.z : intersectionLocal.z
const delta = currentPointer - initialPointer 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> return descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
}, },
} }
@@ -1185,6 +1193,7 @@ function TranslateArrow({
.position ?? [0, 0, 0] .position ?? [0, 0, 0]
return { return {
markDirty: false,
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => { move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
const hit = new Vector3() const hit = new Vector3()
if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null
@@ -9,6 +9,7 @@ import {
getEffectiveRoofSurfaceMaterial, getEffectiveRoofSurfaceMaterial,
getEffectiveSegmentSurfaceMaterial, getEffectiveSegmentSurfaceMaterial,
getMaterialPresetByRef, getMaterialPresetByRef,
getRoofSegmentSurfaceY,
getSelectableKinds, getSelectableKinds,
type ItemNode, type ItemNode,
isRegistrySelectable, isRegistrySelectable,
@@ -40,7 +41,7 @@ import {
useViewer, useViewer,
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef } from 'react' 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 { import {
type ActivePaintMaterial, type ActivePaintMaterial,
buildRoofSegmentSurfaceMaterialPatch, buildRoofSegmentSurfaceMaterialPatch,
@@ -56,7 +57,7 @@ import useEditor, {
type Phase, type Phase,
type StructureLayer, type StructureLayer,
} from './../../store/use-editor' } 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 => { const isNodeInCurrentLevel = (node: AnyNode): boolean => {
// Elevators are building-scoped, so they stay selectable across level filters. // 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 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 { function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup {
const previousMaterial = mesh.material const previousMaterial = mesh.material
mesh.material = material mesh.material = material
@@ -1251,8 +1305,14 @@ export const SelectionManager = () => {
let nodeToSelect = node let nodeToSelect = node
if (node.type === 'roof-segment' && node.parentId) { if (node.type === 'roof-segment' && node.parentId) {
const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId] const nodes = useScene.getState().nodes
if (parentNode && parentNode.type === 'roof') { const parentNode = nodes[node.parentId as AnyNodeId]
const selectedIds = useViewer.getState().selection.selectedIds
if (
parentNode &&
parentNode.type === 'roof' &&
!isInActiveRoofContext(node, selectedIds, nodes)
) {
nodeToSelect = parentNode nodeToSelect = parentNode
} }
} }
@@ -1439,7 +1499,11 @@ export const SelectionManager = () => {
} }
const onDoubleClick = (event: NodeEvent) => { 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 const currentPhase = useEditor.getState().phase
let targetPhase: 'site' | 'structure' | 'furnish' | null = null 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 { createPortal, type ThreeEvent } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { import {
BoxGeometry,
BufferGeometry, BufferGeometry,
DoubleSide, DoubleSide,
Float32BufferAttribute, Float32BufferAttribute,
@@ -31,7 +30,6 @@ import { swallowNextClick } from './handles/use-handle-drag'
const ACCENT = 0x83_81_ed const ACCENT = 0x83_81_ed
const SURFACE_OFFSET = 0.01 const SURFACE_OFFSET = 0.01
const HIT_PADDING = 0.08
const MIN_HIT_HEIGHT = 0.16 const MIN_HIT_HEIGHT = 0.16
const NO_RAYCAST = () => null const NO_RAYCAST = () => null
@@ -104,24 +102,34 @@ function makeOutlineGeometry(hole: HolePolygon, y: number): BufferGeometry {
} }
function makeHitGeometry(hole: HolePolygon, centerY: number, height: number): BufferGeometry { function makeHitGeometry(hole: HolePolygon, centerY: number, height: number): BufferGeometry {
let minX = Number.POSITIVE_INFINITY const topY = centerY + height / 2
let maxX = Number.NEGATIVE_INFINITY const bottomY = centerY - height / 2
let minZ = Number.POSITIVE_INFINITY const positions: number[] = []
let maxZ = Number.NEGATIVE_INFINITY const indices: number[] = []
for (const [x, z] of hole) { for (const [x, z] of hole) positions.push(x, topY, z)
minX = Math.min(minX, x) for (const [x, z] of hole) positions.push(x, bottomY, z)
maxX = Math.max(maxX, x)
minZ = Math.min(minZ, z) const triangles = ShapeUtils.triangulateShape(
maxZ = Math.max(maxZ, z) hole.map(([x, z]) => new Vector2(x, z)),
[],
)
const bottomOffset = hole.length
for (const tri of triangles) {
indices.push(tri[0]!, tri[2]!, tri[1]!)
indices.push(bottomOffset + tri[0]!, bottomOffset + tri[1]!, bottomOffset + tri[2]!)
} }
const width = Math.max(maxX - minX + HIT_PADDING * 2, HIT_PADDING * 2) for (let index = 0; index < hole.length; index += 1) {
const depth = Math.max(maxZ - minZ + HIT_PADDING * 2, HIT_PADDING * 2) const nextIndex = (index + 1) % hole.length
const centerX = (minX + maxX) / 2 indices.push(index, nextIndex, bottomOffset + nextIndex)
const centerZ = (minZ + maxZ) / 2 indices.push(index, bottomOffset + nextIndex, bottomOffset + index)
const geometry = new BoxGeometry(width, height, depth) }
geometry.translate(centerX, centerY, centerZ)
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.setIndex(indices)
geometry.computeVertexNormals()
geometry.computeBoundingSphere() geometry.computeBoundingSphere()
return geometry return geometry
} }
@@ -9,6 +9,7 @@ import {
RoofSegmentNode, RoofSegmentNode,
resolveAlignment, resolveAlignment,
sceneRegistry, sceneRegistry,
snapScalar,
useAlignmentGuides, useAlignmentGuides,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -28,6 +29,10 @@ const GRID_OFFSET = 0.02
/** Figma-style alignment-snap threshold (meters), matching the move tools. */ /** Figma-style alignment-snap threshold (meters), matching the move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08 const ALIGNMENT_THRESHOLD_M = 0.08
function snapToActiveGrid(value: number): number {
return snapScalar(value, useEditor.getState().gridSnapStep)
}
/** /**
* Creates a roof group with one default gable segment * Creates a roof group with one default gable segment
*/ */
@@ -233,8 +238,8 @@ export const RoofTool: React.FC = () => {
if (!cursorRef.current) return if (!cursorRef.current) return
const [gridX, gridZ] = alignPoint( const [gridX, gridZ] = alignPoint(
Math.round(event.localPosition[0] * 2) / 2, snapToActiveGrid(event.localPosition[0]),
Math.round(event.localPosition[2] * 2) / 2, snapToActiveGrid(event.localPosition[2]),
event.localPosition[0], event.localPosition[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true, event.nativeEvent?.altKey === true,
@@ -271,8 +276,8 @@ export const RoofTool: React.FC = () => {
if (!currentLevelId) return if (!currentLevelId) return
const [gridX, gridZ] = alignPoint( const [gridX, gridZ] = alignPoint(
Math.round(event.localPosition[0] * 2) / 2, snapToActiveGrid(event.localPosition[0]),
Math.round(event.localPosition[2] * 2) / 2, snapToActiveGrid(event.localPosition[2]),
event.localPosition[0], event.localPosition[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true, 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 { 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 { createPortal, type ThreeEvent } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
@@ -317,6 +317,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const [dragState, setDragState] = useState<DragState | null>(null) const [dragState, setDragState] = useState<DragState | null>(null)
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null) const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
const previewPolygonRef = useRef<Array<[number, number]> | null>(null) const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
const previousInputDraggingRef = useRef(false)
const onPolygonPreviewRef = useRef(onPolygonPreview) const onPolygonPreviewRef = useRef(onPolygonPreview)
useEffect(() => { useEffect(() => {
@@ -346,6 +347,20 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const lineRef = useRef<Line>(null!) const lineRef = useRef<Line>(null!)
const previousPositionRef = useRef<[number, number] | null>(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 // 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 // our own post-commit prop update arriving while a preview is still in
// flight. Either way, drop the stale preview/drag. // flight. Either way, drop the stale preview/drag.
@@ -687,7 +702,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
if (e.button !== 0) return if (e.button !== 0) return
e.stopPropagation() e.stopPropagation()
setHoveredEdge(null) setHoveredEdge(null)
setDragState({ startDrag({
isDragging: true, isDragging: true,
mode: 'vertex', mode: 'vertex',
vertexIndex: index, vertexIndex: index,
@@ -721,7 +736,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
if (e.button !== 0) return if (e.button !== 0) return
e.stopPropagation() e.stopPropagation()
setHoveredEdge(null) setHoveredEdge(null)
setDragState({ startDrag({
isDragging: true, isDragging: true,
mode: 'polygon', mode: 'polygon',
vertexIndex: null, vertexIndex: null,
@@ -750,7 +765,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
if (!edgeNormal) return if (!edgeNormal) return
setHoveredEdge(null) setHoveredEdge(null)
setDragState({ startDrag({
isDragging: true, isDragging: true,
mode: 'edge', mode: 'edge',
vertexIndex: null, vertexIndex: null,
@@ -835,7 +850,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
e.stopPropagation() e.stopPropagation()
const insertedVertex = handleAddVertex(index, [x!, z!]) const insertedVertex = handleAddVertex(index, [x!, z!])
if (insertedVertex.vertexIndex >= 0) { if (insertedVertex.vertexIndex >= 0) {
setDragState({ startDrag({
isDragging: true, isDragging: true,
mode: 'vertex', mode: 'vertex',
vertexIndex: insertedVertex.vertexIndex, vertexIndex: insertedVertex.vertexIndex,
@@ -1,11 +1,16 @@
import { import {
type AnyNode,
collectAlignmentAnchors, collectAlignmentAnchors,
createSurfaceOpeningPreviewController,
type EventSuffix,
emitter, emitter,
type GridEvent, type GridEvent,
type LevelNode, type LevelNode,
type NodeEvent,
resolveAlignment, resolveAlignment,
StairNode, StairNode,
StairSegmentNode, StairSegmentNode,
syncAutoStairOpenings,
useAlignmentGuides, useAlignmentGuides,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -13,6 +18,10 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import {
resolveStairDestinationLevel,
resolveStairPlacementLevelId,
} from '../../../lib/stair-levels'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import { import {
@@ -37,6 +46,21 @@ import {
const GRID_OFFSET = 0.02 const GRID_OFFSET = 0.02
/** Figma-style alignment-snap threshold (meters), matching the move tools. */ /** Figma-style alignment-snap threshold (meters), matching the move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08 const ALIGNMENT_THRESHOLD_M = 0.08
type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
const CLICK_TRIGGER_KINDS = [
'shelf',
'item',
'slab',
'ceiling',
'wall',
'fence',
'column',
'roof',
'roof-segment',
'stair',
'stair-segment',
] as const
/** /**
* Generates the step-profile geometry for the ghost preview. * Generates the step-profile geometry for the ghost preview.
@@ -140,28 +164,42 @@ function commitStairPlacement(
rotation: number, rotation: number,
): void { ): void {
const { createNodes, nodes } = useScene.getState() const { createNodes, nodes } = useScene.getState()
const placementLevelId = resolveStairPlacementLevelId(
nodes,
levelId,
useViewer.getState().selection.buildingId,
)
if (!placementLevelId) return
const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length
const name = `Staircase ${stairCount + 1}` const name = `Staircase ${stairCount + 1}`
const segment = createDefaultStairSegment() const segment = createDefaultStairSegment()
const sortedLevels = Object.values(nodes) const destinationPlan = resolveStairDestinationLevel({
.filter((node): node is LevelNode => node.type === 'level') createMissing: true,
.sort((left, right) => left.level - right.level) fromLevelId: placementLevelId,
const currentLevelIndex = sortedLevels.findIndex((level) => level.id === levelId) nodes,
const nextLevelId = sortedLevels[currentLevelIndex + 1]?.id ?? levelId })
const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId
const stair = createDefaultStairNode({ const stair = createDefaultStairNode({
name, name,
levelId, levelId: placementLevelId,
nextLevelId, nextLevelId,
position, position,
rotation, rotation,
segmentId: segment.id, segmentId: segment.id,
}) })
const createdLevel = destinationPlan?.createdLevel
const levelCreateOps =
createdLevel && destinationPlan.buildingId
? [{ node: createdLevel, parentId: destinationPlan.buildingId }]
: []
createNodes([ createNodes([
{ node: stair, parentId: levelId }, ...levelCreateOps,
{ node: stair, parentId: placementLevelId },
{ node: segment, parentId: stair.id }, { node: segment, parentId: stair.id },
]) ])
@@ -181,39 +219,60 @@ export const StairTool: React.FC = () => {
useEffect(() => { useEffect(() => {
if (!currentLevelId) return if (!currentLevelId) return
const openingPreview = createSurfaceOpeningPreviewController()
// Reset rotation when tool activates // Reset rotation when tool activates
rotationRef.current = 0 rotationRef.current = 0
if (previewRef.current) previewRef.current.rotation.y = 0 if (previewRef.current) previewRef.current.rotation.y = 0
lastCanonicalPositionRef.current = null lastCanonicalPositionRef.current = null
const getPreviewPosition = ( const buildPreviewScene = (position: [number, number, number], rotation: number) => {
position: [number, number, number], const nodes = useScene.getState().nodes
rotation: number, const placementLevelId = resolveStairPlacementLevelId(
): [number, number, number] => { nodes,
currentLevelId,
useViewer.getState().selection.buildingId,
)
if (!placementLevelId) return null
const destinationPlan = resolveStairDestinationLevel({
createMissing: true,
fromLevelId: placementLevelId,
nodes,
})
const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId
const segment = createDefaultStairSegment() const segment = createDefaultStairSegment()
const stair = createDefaultStairNode({ const stair = createDefaultStairNode({
name: 'Staircase Preview', name: 'Staircase Preview',
levelId: currentLevelId, levelId: placementLevelId,
nextLevelId: currentLevelId, nextLevelId,
position, position,
rotation, rotation,
segmentId: segment.id, segmentId: segment.id,
}) })
return getFloorStackPreviewPosition({ const previewNodes = {
node: stair, ...nodes,
position, ...(destinationPlan?.createdLevel
rotation, ? { [destinationPlan.createdLevel.id]: destinationPlan.createdLevel }
levelId: currentLevelId, : {}),
nodes: { [stair.id]: { ...stair, parentId: placementLevelId },
...useScene.getState().nodes, [segment.id]: { ...segment, parentId: stair.id },
[stair.id]: stair, } as Record<string, AnyNode>
[segment.id]: segment,
}, return { placementLevelId, previewNodes, stair }
})
} }
const applyPreview = (position: [number, number, number], rotation: number) => { const applyDraftPreview = (position: [number, number, number], rotation: number) => {
const visualPosition = getPreviewPosition(position, rotation) const preview = buildPreviewScene(position, rotation)
const visualPosition = preview
? getFloorStackPreviewPosition({
node: preview.stair,
position,
rotation,
levelId: preview.placementLevelId,
nodes: preview.previewNodes,
})
: position
if (cursorRef.current) { if (cursorRef.current) {
cursorRef.current.position.set( cursorRef.current.position.set(
visualPosition[0], visualPosition[0],
@@ -226,6 +285,13 @@ export const StairTool: React.FC = () => {
previewRef.current.position.set(...visualPosition) previewRef.current.position.set(...visualPosition)
previewRef.current.rotation.y = rotation previewRef.current.rotation.y = rotation
} }
if (!preview) {
openingPreview.clear()
return
}
openingPreview.apply(syncAutoStairOpenings(preview.previewNodes))
} }
// Alignment candidates — anchors of every alignable object; refreshed // Alignment candidates — anchors of every alignable object; refreshed
@@ -277,7 +343,7 @@ export const StairTool: React.FC = () => {
) )
const position: [number, number, number] = [gridX, 0, gridZ] const position: [number, number, number] = [gridX, 0, gridZ]
lastCanonicalPositionRef.current = position lastCanonicalPositionRef.current = position
applyPreview(position, rotationRef.current) applyDraftPreview(position, rotationRef.current)
if ( if (
previousGridPosRef.current && previousGridPosRef.current &&
@@ -289,9 +355,7 @@ export const StairTool: React.FC = () => {
previousGridPosRef.current = [gridX, gridZ] previousGridPosRef.current = [gridX, gridZ]
} }
const onGridClick = (event: GridEvent) => { const getAlignedGridPosition = (event: GridEvent): [number, number, number] => {
if (!currentLevelId) return
const [gridX, gridZ] = alignPoint( const [gridX, gridZ] = alignPoint(
Math.round(event.localPosition[0] * 2) / 2, Math.round(event.localPosition[0] * 2) / 2,
Math.round(event.localPosition[2] * 2) / 2, Math.round(event.localPosition[2] * 2) / 2,
@@ -299,7 +363,24 @@ export const StairTool: React.FC = () => {
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true, event.nativeEvent?.altKey === true,
) )
commitStairPlacement(currentLevelId, [gridX, 0, gridZ], rotationRef.current) return [gridX, 0, gridZ]
}
const commitAtCursor = (event: ClickTriggerEvent) => {
if (!currentLevelId) return
const nodeEvent = 'node' in event ? (event as NodeEvent<AnyNode>) : null
if (nodeEvent) {
nodeEvent.stopPropagation()
nodeEvent.nativeEvent.stopPropagation()
}
const position = nodeEvent
? lastCanonicalPositionRef.current
: getAlignedGridPosition(event as GridEvent)
if (!position) return
commitStairPlacement(currentLevelId, position, rotationRef.current)
openingPreview.clear()
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId) alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
} }
@@ -319,7 +400,7 @@ export const StairTool: React.FC = () => {
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
rotationRef.current += rotationDelta rotationRef.current += rotationDelta
if (lastCanonicalPositionRef.current) { if (lastCanonicalPositionRef.current) {
applyPreview(lastCanonicalPositionRef.current, rotationRef.current) applyDraftPreview(lastCanonicalPositionRef.current, rotationRef.current)
} else if (previewRef.current) { } else if (previewRef.current) {
previewRef.current.rotation.y = rotationRef.current previewRef.current.rotation.y = rotationRef.current
} }
@@ -327,14 +408,25 @@ export const StairTool: React.FC = () => {
} }
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', commitAtCursor)
type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
for (const kind of CLICK_TRIGGER_KINDS) {
const key = `${kind}:click` as ClickKey
emitter.on(key, commitAtCursor as never)
}
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
return () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', commitAtCursor)
for (const kind of CLICK_TRIGGER_KINDS) {
const key = `${kind}:click` as ClickKey
emitter.off(key, commitAtCursor as never)
}
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
openingPreview.clear()
} }
}, [currentLevelId]) }, [currentLevelId])
+8
View File
@@ -209,6 +209,14 @@ export type { SceneGraph } from './lib/scene'
export { applySceneGraphToEditor } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene'
export { triggerSFX } from './lib/sfx-bus' export { triggerSFX } from './lib/sfx-bus'
export { duplicateStairSubtree } from './lib/stair-duplication' export { duplicateStairSubtree } from './lib/stair-duplication'
export {
getBuildingLevelsForLevel,
getStairLevelOptions,
resolveStairDestinationLevel,
resolveStairFromLevelId,
resolveStairPlacementLevelId,
resolveStairToLevelId,
} from './lib/stair-levels'
// `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/ // `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/
// nodes` so they don't need their own copy / their own tailwind-merge // nodes` so they don't need their own copy / their own tailwind-merge
// dependency. // dependency.
@@ -0,0 +1,146 @@
import { describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
BuildingNode,
LevelNode,
StairNode,
} from '@pascal-app/core/schema'
import {
getBuildingLevelsForLevel,
getStairLevelOptions,
resolveStairDestinationLevel,
resolveStairFromLevelId,
resolveStairPlacementLevelId,
resolveStairToLevelId,
} from './stair-levels'
describe('stair level helpers', () => {
test('creates a missing upper level in the same building', () => {
const ground = LevelNode.parse({ level: 0, children: [] })
const building = BuildingNode.parse({ children: [ground.id] })
const nodes = {
[building.id]: building,
[ground.id]: ground,
} as Record<AnyNodeId, AnyNode>
const plan = resolveStairDestinationLevel({
createMissing: true,
fromLevelId: ground.id,
nodes,
})
expect(plan?.buildingId).toBe(building.id)
expect(plan?.fromLevel.id).toBe(ground.id)
expect(plan?.toLevel.level).toBe(1)
expect(plan?.toLevel.id).toBe(plan?.createdLevel?.id)
expect(plan?.createdLevel?.parentId).toBe(building.id)
})
test('uses the nearest higher sibling level instead of creating one', () => {
const building = BuildingNode.parse({})
const ground = LevelNode.parse({ level: 0, parentId: building.id })
const second = LevelNode.parse({ level: 1, parentId: building.id })
const third = LevelNode.parse({ level: 2, parentId: building.id })
const nodes = {
[building.id]: { ...building, children: [ground.id, third.id, second.id] },
[ground.id]: ground,
[second.id]: second,
[third.id]: third,
} as Record<AnyNodeId, AnyNode>
const plan = resolveStairDestinationLevel({
createMissing: true,
fromLevelId: ground.id,
nodes,
})
expect(plan?.createdLevel).toBeNull()
expect(plan?.toLevel.id).toBe(second.id)
})
test('ignores levels from other buildings', () => {
const buildingA = BuildingNode.parse({})
const buildingB = BuildingNode.parse({})
const groundA = LevelNode.parse({ level: 0, parentId: buildingA.id })
const upperA = LevelNode.parse({ level: 1, parentId: buildingA.id })
const upperB = LevelNode.parse({ level: 1, parentId: buildingB.id })
const nodes = {
[buildingA.id]: { ...buildingA, children: [groundA.id, upperA.id] },
[buildingB.id]: { ...buildingB, children: [upperB.id] },
[groundA.id]: groundA,
[upperA.id]: upperA,
[upperB.id]: upperB,
} as Record<AnyNodeId, AnyNode>
expect(getBuildingLevelsForLevel(nodes, groundA.id).map((level) => level.id)).toEqual([
groundA.id,
upperA.id,
])
expect(
resolveStairDestinationLevel({ createMissing: true, fromLevelId: groundA.id, nodes })?.toLevel
.id,
).toBe(upperA.id)
})
test('includes source and parent-linked sibling levels when building children are stale', () => {
const building = BuildingNode.parse({ children: [] })
const ground = LevelNode.parse({ level: 0, parentId: building.id })
const upper = LevelNode.parse({ level: 1, parentId: building.id })
const nodes = {
[building.id]: building,
[ground.id]: ground,
[upper.id]: upper,
} as Record<AnyNodeId, AnyNode>
const levels = getBuildingLevelsForLevel(nodes, ground.id)
const plan = resolveStairDestinationLevel({
createMissing: true,
fromLevelId: ground.id,
nodes,
})
expect(levels.map((level) => level.id)).toEqual([ground.id, upper.id])
expect(plan?.createdLevel).toBeNull()
expect(plan?.toLevel.id).toBe(upper.id)
})
test('falls back from stale placement level ids to a valid level in the selected building', () => {
const buildingA = BuildingNode.parse({})
const groundA = LevelNode.parse({ level: 0, parentId: buildingA.id })
const buildingB = BuildingNode.parse({})
const groundB = LevelNode.parse({ level: 0, parentId: buildingB.id })
const nodes = {
[buildingA.id]: { ...buildingA, children: [groundA.id] },
[groundA.id]: groundA,
[buildingB.id]: { ...buildingB, children: [groundB.id] },
[groundB.id]: groundB,
} as Record<AnyNodeId, AnyNode>
expect(resolveStairPlacementLevelId(nodes, 'level_missing', buildingB.id)).toBe(groundB.id)
})
test('repairs panel level ids for stairs with stale from-level data', () => {
const building = BuildingNode.parse({})
const ground = LevelNode.parse({ level: 0, parentId: building.id })
const upper = LevelNode.parse({ level: 1, parentId: building.id })
const stair = StairNode.parse({
parentId: ground.id,
fromLevelId: 'default',
toLevelId: upper.id,
})
const nodes = {
[building.id]: { ...building, children: [ground.id, upper.id] },
[ground.id]: ground,
[upper.id]: upper,
[stair.id]: stair,
} as Record<AnyNodeId, AnyNode>
const levels = getStairLevelOptions(nodes, stair)
const fromLevelId = resolveStairFromLevelId(nodes, stair, levels)
expect(levels.map((level) => level.id)).toEqual([ground.id, upper.id])
expect(fromLevelId).toBe(ground.id)
expect(resolveStairToLevelId(nodes, stair, fromLevelId, levels)).toBe(upper.id)
})
})
+177
View File
@@ -0,0 +1,177 @@
import {
type AnyNode,
type AnyNodeId,
LevelNode,
type LevelNode as LevelNodeType,
resolveBuildingForLevel,
type StairNode,
} from '@pascal-app/core'
function sortLevelsByHeight(levels: LevelNodeType[]) {
return [...levels].sort((left, right) => left.level - right.level)
}
function isLevelNode(node: AnyNode | undefined): node is LevelNodeType {
return node?.type === 'level'
}
function getAllSceneLevels(nodes: Record<string, AnyNode>) {
return sortLevelsByHeight(
Object.values(nodes).filter((entry): entry is LevelNodeType => entry?.type === 'level'),
)
}
function getBuildingLevels(
nodes: Record<string, AnyNode>,
buildingId: AnyNodeId | string | null | undefined,
source?: LevelNodeType,
) {
if (!buildingId) return source ? [source] : []
const building = nodes[buildingId as AnyNodeId]
if (building?.type !== 'building') return source ? [source] : []
const levels = new Map<string, LevelNodeType>()
if (source) levels.set(source.id, source)
for (const childId of building.children ?? []) {
const child = nodes[childId as AnyNodeId]
if (isLevelNode(child)) levels.set(child.id, child)
}
for (const candidate of Object.values(nodes)) {
if (isLevelNode(candidate) && candidate.parentId === building.id) {
levels.set(candidate.id, candidate)
}
}
return sortLevelsByHeight(Array.from(levels.values()))
}
export function getBuildingLevelsForLevel(
nodes: Record<string, AnyNode>,
levelId: AnyNodeId | string | null | undefined,
) {
if (!levelId) return []
const source = nodes[levelId as AnyNodeId]
if (!isLevelNode(source)) return []
const buildingId = resolveBuildingForLevel(
source.id as AnyNodeId,
nodes as Record<AnyNodeId, AnyNode>,
)
return getBuildingLevels(nodes, buildingId, source)
}
export function getStairLevelOptions(nodes: Record<string, AnyNode>, stair: StairNode) {
for (const candidateId of [stair.fromLevelId, stair.parentId, stair.toLevelId]) {
if (isLevelNode(nodes[candidateId as AnyNodeId])) {
return getBuildingLevelsForLevel(nodes, candidateId)
}
}
return getAllSceneLevels(nodes)
}
export function resolveStairPlacementLevelId(
nodes: Record<string, AnyNode>,
preferredLevelId: AnyNodeId | string | null | undefined,
preferredBuildingId?: AnyNodeId | string | null,
) {
if (isLevelNode(nodes[preferredLevelId as AnyNodeId])) {
return preferredLevelId as LevelNodeType['id']
}
const buildingLevels = getBuildingLevels(nodes, preferredBuildingId)
return buildingLevels[0]?.id ?? getAllSceneLevels(nodes)[0]?.id ?? null
}
export function resolveStairFromLevelId(
nodes: Record<string, AnyNode>,
stair: StairNode,
levels = getStairLevelOptions(nodes, stair),
) {
const optionIds = new Set<string>(levels.map((level) => level.id))
if (stair.fromLevelId && optionIds.has(stair.fromLevelId)) return stair.fromLevelId
if (stair.parentId && optionIds.has(stair.parentId)) return stair.parentId
const toLevel = stair.toLevelId ? nodes[stair.toLevelId as AnyNodeId] : undefined
if (isLevelNode(toLevel)) {
const lowerLevel = [...levels].reverse().find((level) => level.level < toLevel.level)
if (lowerLevel) return lowerLevel.id
}
return levels[0]?.id ?? null
}
export function resolveStairToLevelId(
nodes: Record<string, AnyNode>,
stair: StairNode,
fromLevelId: AnyNodeId | string | null | undefined,
levels = getStairLevelOptions(nodes, stair),
) {
const optionIds = new Set<string>(levels.map((level) => level.id))
if (stair.toLevelId && stair.toLevelId !== fromLevelId && optionIds.has(stair.toLevelId)) {
return stair.toLevelId
}
const fromLevel = fromLevelId ? nodes[fromLevelId as AnyNodeId] : undefined
if (isLevelNode(fromLevel)) {
return levels.find((level) => level.level > fromLevel.level)?.id ?? fromLevel.id
}
return levels[0]?.id ?? null
}
export function resolveStairDestinationLevel({
createMissing,
fromLevelId,
nodes,
}: {
createMissing?: boolean
fromLevelId: AnyNodeId | string | null | undefined
nodes: Record<string, AnyNode>
}) {
if (!fromLevelId) return null
const fromLevel = nodes[fromLevelId as AnyNodeId]
if (!isLevelNode(fromLevel)) return null
const buildingId = resolveBuildingForLevel(
fromLevel.id as AnyNodeId,
nodes as Record<AnyNodeId, AnyNode>,
)
const levels = getBuildingLevelsForLevel(nodes, fromLevel.id)
const nextExistingLevel = levels.find((level) => level.level > fromLevel.level) ?? null
if (nextExistingLevel) {
return {
buildingId,
createdLevel: null,
fromLevel,
levels,
toLevel: nextExistingLevel,
}
}
if (createMissing && buildingId) {
const createdLevel = LevelNode.parse({
children: [],
level: fromLevel.level + 1,
parentId: buildingId,
})
return {
buildingId,
createdLevel,
fromLevel,
levels: sortLevelsByHeight([...levels, createdLevel]),
toLevel: createdLevel,
}
}
return {
buildingId,
createdLevel: null,
fromLevel,
levels,
toLevel: fromLevel,
}
}
@@ -56,6 +56,7 @@ function roofSegmentWidthHandle(side: 'left' | 'right'): HandleDescriptor<RoofSe
// 'max' = +X edge anchored (left arrow grows the -X edge outward). // 'max' = +X edge anchored (left arrow grows the -X edge outward).
anchor: side === 'right' ? 'min' : 'max', anchor: side === 'right' ? 'min' : 'max',
min: MIN_ROOF_DIM, min: MIN_ROOF_DIM,
gridSnap: true,
currentValue: (n) => n.width, currentValue: (n) => n.width,
apply: (initial, newWidth) => { apply: (initial, newWidth) => {
const rotY = initial.rotation ?? 0 const rotY = initial.rotation ?? 0
@@ -100,6 +101,7 @@ function roofSegmentDepthHandle(side: 'front' | 'back'): HandleDescriptor<RoofSe
axis: 'z', axis: 'z',
anchor: side === 'front' ? 'min' : 'max', anchor: side === 'front' ? 'min' : 'max',
min: MIN_ROOF_DIM, min: MIN_ROOF_DIM,
gridSnap: true,
currentValue: (n) => n.depth, currentValue: (n) => n.depth,
apply: (initial, newDepth) => { apply: (initial, newDepth) => {
// Recenter so the anchored Z edge stays at the same world point. // Recenter so the anchored Z edge stays at the same world point.
@@ -4,6 +4,7 @@ import {
type FloorplanMoveTarget, type FloorplanMoveTarget,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
snapScalar,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -56,7 +57,7 @@ function resolveSegmentFrame(
* the math survives any parent-roof rotation. * the math survives any parent-roof rotation.
*/ */
export const roofSegmentResizeAffordance: FloorplanAffordance<RoofSegmentNode> = { export const roofSegmentResizeAffordance: FloorplanAffordance<RoofSegmentNode> = {
start({ node, payload, nodes, initialPlanPoint }) { start({ node, payload, nodes, initialPlanPoint, gridSnapStep }) {
const { axis, side } = payload as RoofSegmentResizePayload const { axis, side } = payload as RoofSegmentResizePayload
const segmentId = node.id as AnyNodeId const segmentId = node.id as AnyNodeId
const initialValue = axis === 'x' ? node.width : node.depth const initialValue = axis === 'x' ? node.width : node.depth
@@ -79,7 +80,9 @@ export const roofSegmentResizeAffordance: FloorplanAffordance<RoofSegmentNode> =
apply({ planPoint }) { apply({ planPoint }) {
const currentLocal = projectLocalAxis(planPoint[0], planPoint[1]) const currentLocal = projectLocalAxis(planPoint[0], planPoint[1])
const delta = (currentLocal - initialLocal) * side 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 lastValue = newValue
useScene useScene
.getState() .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 { buildRoofFloorplan } from './floorplan'
import { roofParametrics } from './parametrics' import { roofParametrics } from './parametrics'
import { RoofNode } from './schema' 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` * Roof — Stage A registration. Wrap-exports the legacy `RoofRenderer`
* + `RoofSystem` (geometry generation via `getRoofSegmentBrushes` + * + `RoofSystem` (geometry generation via `getRoofSegmentBrushes` +
@@ -43,6 +119,7 @@ export const roofDefinition: NodeDefinition<typeof RoofNode> = {
}, },
parametrics: roofParametrics, parametrics: roofParametrics,
handles: roofHandles,
floorplan: buildRoofFloorplan, floorplan: buildRoofFloorplan,
renderer: { renderer: {
+7 -1
View File
@@ -5,6 +5,7 @@ import {
hasSegmentMaterialOverride, hasSegmentMaterialOverride,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
useLiveNodeOverrides,
useRegistry, useRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -14,8 +15,13 @@ import * as THREE from 'three'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { getRoofDebugMaterials, getRoofMaterials } from './roof-materials' 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 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) useRegistry(node.id, 'roof', ref)
useLayoutEffect(() => { useLayoutEffect(() => {
+3 -37
View File
@@ -1,6 +1,6 @@
import { import {
type AnyNodeId, type AnyNodeId,
getActiveRoofHeight, getRoofSegmentSurfaceY,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
sceneRegistry, sceneRegistry,
@@ -17,40 +17,6 @@ export type RoofSegmentHit = {
localZ: number 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 * Resolve which roof-segment the user clicked. Used by every placement
* tool that drops a new node onto a roof (box-vent, ridge-vent, * 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- * point's (x, z) lies inside *every* segment's axis-aligned half-
* extents, so a naive first-match returns the wrong slope (typically * extents, so a naive first-match returns the wrong slope (typically
* segments[0]). We instead score each candidate by * 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 * smallest — the slope the user actually clicked is the one whose
* sloped surface passes through the hit point. * sloped surface passes through the hit point.
* *
@@ -101,7 +67,7 @@ export function resolveRoofSegmentHit(
const halfW = seg.width / 2 + overhang const halfW = seg.width / 2 + overhang
const halfD = seg.depth / 2 + overhang const halfD = seg.depth / 2 + overhang
if (Math.abs(local.x) <= halfW && Math.abs(local.z) <= halfD) { 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) const score = Math.abs(local.y - surfaceY)
if (!best || score < best.score) { if (!best || score < best.score) {
best = { best = {
+2 -21
View File
@@ -1,5 +1,5 @@
import { import {
getActiveRoofHeight, getRoofSegmentSurfaceY,
getSegmentSlopeFrame, getSegmentSlopeFrame,
ROOF_SHAPE_DEFAULTS, ROOF_SHAPE_DEFAULTS,
type RoofSegmentNode, type RoofSegmentNode,
@@ -13,26 +13,7 @@ import * as THREE from 'three'
// accessories don't reach across into a sibling kind for it. // accessories don't reach across into a sibling kind for it.
export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): number { export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): number {
const { roofType, wallHeight, depth, width } = seg return getRoofSegmentSurfaceY(seg, lx, lz)
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
} }
// Outward normal for a roof surface tilting at angle θ in the horizontal // 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 { buildSlabFloorplan } from './floorplan'
import { import {
slabAddVertexAffordance, slabAddVertexAffordance,
@@ -13,8 +18,7 @@ import { SlabNode } from './schema'
const HEIGHT_HANDLE_OFFSET = 0.22 const HEIGHT_HANDLE_OFFSET = 0.22
const MIN_SLAB_ELEVATION = 0.02 const MIN_SLAB_ELEVATION = 0.02
function slabPolygonCenter(n: SlabNodeType): [number, number] { function polygonVertexAverage(polygon: SlabNodeType['polygon']): [number, number] {
const polygon = n.polygon ?? []
if (polygon.length === 0) return [0, 0] if (polygon.length === 0) return [0, 0]
let cx = 0 let cx = 0
let cz = 0 let cz = 0
@@ -25,11 +29,68 @@ function slabPolygonCenter(n: SlabNodeType): [number, number] {
return [cx / polygon.length, cz / polygon.length] return [cx / polygon.length, cz / polygon.length]
} }
// Slab height arrow — vertical chevron at the polygon centroid, just function pointIsOnSolidSlab(point: [number, number], slab: SlabNodeType) {
// above the slab's top face. Drags elevation (the extrusion thickness) if (!pointInPolygon2D(point, slab.polygon, { includeBoundary: false })) return false
// with `anchor: 'min'` so the bottom stays at world Y=0 and the top return !(slab.holes ?? []).some(
// follows the pointer. Same registry-handle pipeline as the column (hole) => hole.length >= 3 && pointInPolygon2D(point, hole, { includeBoundary: true }),
// height arrow, so live override + commit-on-release come for free. )
}
function slabHandleAnchor(slab: SlabNodeType): [number, number] {
const polygon = slab.polygon ?? []
const fallback = polygonVertexAverage(polygon)
if (polygon.length < 3) return fallback
if (pointIsOnSolidSlab(fallback, slab)) return fallback
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minZ = Number.POSITIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
for (const [x, z] of polygon) {
minX = Math.min(minX, x)
maxX = Math.max(maxX, x)
minZ = Math.min(minZ, z)
maxZ = Math.max(maxZ, z)
}
const candidates: [number, number][] = []
for (const point of polygon) {
candidates.push([
fallback[0] + (point[0] - fallback[0]) * 0.35,
fallback[1] + (point[1] - fallback[1]) * 0.35,
])
}
const steps = 12
for (let xi = 1; xi < steps; xi += 1) {
const x = minX + ((maxX - minX) * xi) / steps
for (let zi = 1; zi < steps; zi += 1) {
const z = minZ + ((maxZ - minZ) * zi) / steps
candidates.push([x, z])
}
}
let best: [number, number] | null = null
let bestDistance = Number.POSITIVE_INFINITY
for (const candidate of candidates) {
if (!pointIsOnSolidSlab(candidate, slab)) continue
const dx = candidate[0] - fallback[0]
const dz = candidate[1] - fallback[1]
const distance = dx * dx + dz * dz
if (distance < bestDistance) {
best = candidate
bestDistance = distance
}
}
return best ?? fallback
}
// Slab height arrow — vertical chevron on solid slab surface near the
// polygon center. Drags elevation (the extrusion thickness) with
// `anchor: 'min'` so the bottom stays at world Y=0 and the top follows
// the pointer. Same registry-handle pipeline as the column height arrow,
// so live override + commit-on-release come for free.
function slabHeightHandle(): HandleDescriptor<SlabNodeType> { function slabHeightHandle(): HandleDescriptor<SlabNodeType> {
return { return {
kind: 'linear-resize', kind: 'linear-resize',
@@ -40,7 +101,7 @@ function slabHeightHandle(): HandleDescriptor<SlabNodeType> {
apply: (_n, newValue) => ({ elevation: newValue }), apply: (_n, newValue) => ({ elevation: newValue }),
placement: { placement: {
position: (n) => { position: (n) => {
const [cx, cz] = slabPolygonCenter(n) const [cx, cz] = slabHandleAnchor(n)
const elevation = n.elevation ?? 0.05 const elevation = n.elevation ?? 0.05
return [cx, elevation + HEIGHT_HANDLE_OFFSET, cz] return [cx, elevation + HEIGHT_HANDLE_OFFSET, cz]
}, },
+49 -16
View File
@@ -18,9 +18,13 @@ import {
ActionGroup, ActionGroup,
DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE, DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE,
duplicateStairSubtree, duplicateStairSubtree,
getStairLevelOptions,
MetricControl, MetricControl,
PanelSection, PanelSection,
PanelWrapper, PanelWrapper,
resolveStairDestinationLevel,
resolveStairFromLevelId,
resolveStairToLevelId,
SegmentedControl, SegmentedControl,
SliderControl, SliderControl,
ToggleControl, ToggleControl,
@@ -29,7 +33,7 @@ import {
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { useCallback, useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [ const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
@@ -62,16 +66,14 @@ export default function StairPanel() {
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode) const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const nodes = useScene((s) => s.nodes)
const node = useScene((s) => const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
) )
const levels = useScene( const levels = useMemo<LevelNode[]>(
useShallow((s) => () => (node?.type === 'stair' ? getStairLevelOptions(nodes, node) : []),
Object.values(s.nodes) [node, nodes],
.filter((entry): entry is LevelNode => entry.type === 'level')
.sort((left, right) => left.level - right.level),
),
) )
const segments = useScene( const segments = useScene(
useShallow((s) => { useShallow((s) => {
@@ -96,6 +98,41 @@ export default function StairPanel() {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
const handleAutoCutoutChange = useCallback(
(checked: boolean) => {
if (!node) return
const updates: Partial<StairNode> = {
slabOpeningMode: checked ? 'destination' : 'none',
}
const sceneNodes = useScene.getState().nodes
const fromLevelId = resolveStairFromLevelId(sceneNodes, node)
if (checked && fromLevelId) updates.fromLevelId = fromLevelId
if (checked && (!node.toLevelId || node.toLevelId === fromLevelId)) {
const plan = resolveStairDestinationLevel({
fromLevelId,
nodes: sceneNodes,
})
if (plan?.toLevel.id) updates.toLevelId = plan.toLevel.id
}
handleUpdate(updates)
},
[node, handleUpdate],
)
const handleFromLevelChange = useCallback(
(fromLevelId: string) => {
const plan = resolveStairDestinationLevel({
fromLevelId: fromLevelId as AnyNodeId,
nodes: useScene.getState().nodes,
})
handleUpdate({
fromLevelId,
toLevelId: plan?.toLevel.id ?? fromLevelId,
})
},
[handleUpdate],
)
const getLastSegmentFillDefaults = useCallback(() => { const getLastSegmentFillDefaults = useCallback(() => {
if (!node) return { fillToFloor: true } if (!node) return { fillToFloor: true }
const children = node.children ?? [] const children = node.children ?? []
@@ -184,8 +221,8 @@ export default function StairPanel() {
if (!(node && node.type === 'stair' && selectedId && selectedCount === 1)) return null if (!(node && node.type === 'stair' && selectedId && selectedCount === 1)) return null
const resolvedFromLevelId = node.fromLevelId ?? node.parentId ?? levels[0]?.id ?? null const resolvedFromLevelId = resolveStairFromLevelId(nodes, node, levels)
const resolvedToLevelId = node.toLevelId ?? resolvedFromLevelId const resolvedToLevelId = resolveStairToLevelId(nodes, node, resolvedFromLevelId, levels)
return ( return (
<PanelWrapper <PanelWrapper
@@ -217,11 +254,7 @@ export default function StairPanel() {
<ToggleControl <ToggleControl
checked={(node.slabOpeningMode ?? 'none') === 'destination'} checked={(node.slabOpeningMode ?? 'none') === 'destination'}
label="Auto Cutout" label="Auto Cutout"
onChange={(checked) => onChange={handleAutoCutoutChange}
handleUpdate({
slabOpeningMode: checked ? 'destination' : 'none',
})
}
/> />
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -230,7 +263,7 @@ export default function StairPanel() {
</div> </div>
<select <select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm" className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm"
onChange={(event) => handleUpdate({ fromLevelId: event.target.value })} onChange={(event) => handleFromLevelChange(event.target.value)}
value={resolvedFromLevelId ?? ''} value={resolvedFromLevelId ?? ''}
> >
{levels.map((level) => ( {levels.map((level) => (
@@ -259,7 +292,7 @@ export default function StairPanel() {
</div> </div>
<SegmentedControl <SegmentedControl
onChange={(value) => handleUpdate({ slabOpeningMode: value as StairSlabOpeningMode })} onChange={(value) => handleAutoCutoutChange(value === 'destination')}
options={STAIR_SLAB_OPENING_OPTIONS} options={STAIR_SLAB_OPENING_OPTIONS}
value={node.slabOpeningMode ?? 'none'} value={node.slabOpeningMode ?? 'none'}
/> />
+9 -3
View File
@@ -14,6 +14,7 @@ import {
planAutoCeilingsForLevel, planAutoCeilingsForLevel,
planAutoSlabsForLevel, planAutoSlabsForLevel,
planWallMoveJunctions, planWallMoveJunctions,
projectAutoSlabsForPlan,
resumeSceneHistory, resumeSceneHistory,
type SlabNode, type SlabNode,
useLiveNodeOverrides, useLiveNodeOverrides,
@@ -266,10 +267,15 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
// existing room → existing slab" logic sees stable IDs across // existing room → existing slab" logic sees stable IDs across
// ticks. Without this anchor, IDs would drift as overrides // ticks. Without this anchor, IDs would drift as overrides
// re-flowed through the planner. // 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( const ceilingPlan = planAutoCeilingsForLevel(
roomPolygons, roomPolygons,
getLevelCeilings(levelId, sceneState.nodes), getLevelCeilings(levelId, sceneState.nodes),
{
walls: levelWalls,
slabs: projectAutoSlabsForPlan(existingSlabs, slabPlan),
},
) )
latestSurfacePlans = { slabs: slabPlan, ceilings: ceilingPlan } latestSurfacePlans = { slabs: slabPlan, ceilings: ceilingPlan }
@@ -281,8 +287,8 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
touchedSlabIds.add(update.id as AnyNodeId) touchedSlabIds.add(update.id as AnyNodeId)
} }
for (const update of ceilingPlan.update) { for (const update of ceilingPlan.update) {
if (update.data.polygon === undefined) continue if (update.data.polygon === undefined && update.data.height === undefined) continue
overrideEntries.push([update.id, { polygon: update.data.polygon }]) overrideEntries.push([update.id, update.data as Record<string, unknown>])
touchedCeilingIds.add(update.id as AnyNodeId) 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_MIN_RADIUS = 0.32
const DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72 const DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72
const DRAFT_ANGLE_ARC_SEGMENTS = 24 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 = { type DraftAngleLabel = {
id: string id: string
@@ -78,6 +90,21 @@ type DraftMeasurementState = {
angleLabels: DraftAngleLabel[] angleLabels: DraftAngleLabel[]
} | null } | null
type DraftAxisGuideState = {
origin: WallPlanPoint
y: number
angleLabel: DraftAngleLabel | null
} | null
type AxisAngleCandidate = {
angle: number
arc: {
startAngle: number
endAngle: number
midAngle: number
}
}
type FaceAngleCandidate = { type FaceAngleCandidate = {
index: number index: number
point: WallPlanPoint point: WallPlanPoint
@@ -122,6 +149,53 @@ function pointMatches(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-5) {
return distanceSquared(a, b) <= tolerance * tolerance 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 { function toWallPlanPoint(point: Point2D): WallPlanPoint {
return [point.x, point.y] return [point.x, point.y]
} }
@@ -423,6 +497,7 @@ export const WallTool: React.FC = () => {
const buildingState = useRef(0) const buildingState = useRef(0)
const shiftPressed = useRef(false) const shiftPressed = useRef(false)
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null) const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
const [axisGuide, setAxisGuide] = useState<DraftAxisGuideState>(null)
const measurementColor = isDark ? '#ffffff' : '#111111' const measurementColor = isDark ? '#ffffff' : '#111111'
const measurementShadowColor = isDark ? '#111111' : '#ffffff' const measurementShadowColor = isDark ? '#111111' : '#ffffff'
@@ -463,6 +538,7 @@ export const WallTool: React.FC = () => {
wallPreviewRef.current.visible = false wallPreviewRef.current.visible = false
} }
setDraftMeasurement(null) setDraftMeasurement(null)
setAxisGuide(null)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
} }
@@ -484,6 +560,15 @@ export const WallTool: React.FC = () => {
const snappedLocal = gridPosition const snappedLocal = gridPosition
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1]) endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
cursorRef.current.position.copy(endingPoint.current) 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]] const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
if ( if (
@@ -514,6 +599,7 @@ export const WallTool: React.FC = () => {
} else { } else {
cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1]) cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1])
setDraftMeasurement(null) setDraftMeasurement(null)
setAxisGuide(null)
} }
} }
@@ -538,6 +624,11 @@ export const WallTool: React.FC = () => {
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
endingPoint.current.copy(startingPoint.current) endingPoint.current.copy(startingPoint.current)
buildingState.current = 1 buildingState.current = 1
setAxisGuide({
origin: snappedStart,
y: event.localPosition[1],
angleLabel: null,
})
triggerSFX('sfx:structure-build-start') triggerSFX('sfx:structure-build-start')
// Visibility is owned by `updateWallPreview` — it flips // Visibility is owned by `updateWallPreview` — it flips
// `mesh.visible` based on segment length. Setting it here // `mesh.visible` based on segment length. Setting it here
@@ -572,6 +663,11 @@ export const WallTool: React.FC = () => {
endingPoint.current.copy(startingPoint.current) endingPoint.current.copy(startingPoint.current)
cursorRef.current?.position.copy(startingPoint.current) cursorRef.current?.position.copy(startingPoint.current)
buildingState.current = 1 buildingState.current = 1
setAxisGuide({
origin: nextStart,
y: event.localPosition[1],
angleLabel: null,
})
// Hide the preview until the next `onGridMove` writes the // Hide the preview until the next `onGridMove` writes the
// new segment's geometry. Without this the prior segment's // new segment's geometry. Without this the prior segment's
// BoxGeometry stays visible for a frame on top of the // BoxGeometry stays visible for a frame on top of the
@@ -615,6 +711,11 @@ export const WallTool: React.FC = () => {
return ( return (
<group> <group>
<WallAxisGuides
guide={axisGuide}
labelColor={measurementColor}
labelShadowColor={measurementShadowColor}
/>
<CursorSphere height={previewHeight} ref={cursorRef} /> <CursorSphere height={previewHeight} ref={cursorRef} />
<mesh layers={EDITOR_LAYER} ref={wallPreviewRef} renderOrder={1} visible={false}> <mesh layers={EDITOR_LAYER} ref={wallPreviewRef} renderOrder={1} visible={false}>
<shapeGeometry /> <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 }) { function DraftAngleArc({ arc, color }: { arc: DraftAngleLabel['arc']; color: string }) {
const geometry = useMemo(() => { const geometry = useMemo(() => {
const segmentCount = Math.max( 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 // @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
// depend on @types/bun so the import type is unresolved at compile time. // depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { type Point2D, unionPolygons } from './polygon-union' import { type Point2D, subtractPolygonsFromPolygon, unionPolygons } from './polygon-union'
function polygonArea(points: Point2D[]) { function polygonArea(points: Point2D[]) {
let area = 0 let area = 0
@@ -75,3 +75,47 @@ describe('unionPolygons', () => {
expect(result.map(polygonArea)).toEqual([1, 1]) expect(result.map(polygonArea)).toEqual([1, 1])
}) })
}) })
describe('subtractPolygonsFromPolygon', () => {
test('turns a boundary-overlapping cutter into an indentation', () => {
const slab: Point2D[] = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
const cutout: Point2D[] = [
[1, -0.5],
[3, -0.5],
[3, 1],
[1, 1],
]
const result = subtractPolygonsFromPolygon(slab, [cutout])
expect(result).toHaveLength(1)
expect(result[0]).toContainEqual([1, 1])
expect(result[0]).toContainEqual([3, 1])
expect(polygonArea(result[0]!)).toBeCloseTo(10)
})
test('returns separate contours when a cutter splits the subject', () => {
const slab: Point2D[] = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
const cutout: Point2D[] = [
[1.5, -1],
[2.5, -1],
[2.5, 4],
[1.5, 4],
]
const result = subtractPolygonsFromPolygon(slab, [cutout])
expect(result).toHaveLength(2)
expect(result.map(polygonArea).sort((a, b) => a - b)).toEqual([4.5, 4.5])
})
})
+74
View File
@@ -53,6 +53,18 @@ function pointOnSegment(point: Point2D, start: Point2D, end: Point2D) {
return dot <= EPSILON return dot <= EPSILON
} }
function pointInPolygonOrOnBoundary(point: Point2D, polygon: Point2D[]) {
if (
polygon.some((start, index) =>
pointOnSegment(point, start, polygon[(index + 1) % polygon.length]!),
)
) {
return true
}
return pointInPolygon(point, polygon)
}
function pointInPolygon(point: Point2D, polygon: Point2D[]) { function pointInPolygon(point: Point2D, polygon: Point2D[]) {
let inside = false let inside = false
@@ -292,3 +304,65 @@ export function unionPolygons(polygons: Point2D[][]): Point2D[][] {
return rings.length > 0 ? rings : validPolygons return rings.length > 0 ? rings : validPolygons
} }
function buildDifferenceBoundarySegments(edges: Edge[], polygons: Point2D[][]) {
const subject = polygons[0]
const cutters = polygons.slice(1)
if (!subject) return []
const segments: Segment[] = []
for (const edge of edges) {
const splits = [...edge.splits].sort((a, b) => a - b)
for (let i = 0; i < splits.length - 1; i++) {
const startT = splits[i]!
const endT = splits[i + 1]!
if (endT - startT <= EPSILON) continue
const start = interpolate(edge.start, edge.end, startT)
const end = interpolate(edge.start, edge.end, endT)
const mid = interpolate(edge.start, edge.end, (startT + endT) / 2)
if (edge.polygonIndex === 0) {
const insideCutter = cutters.some((cutter) => pointInPolygonOrOnBoundary(mid, cutter))
if (!insideCutter) {
segments.push({ start, end, used: false })
}
continue
}
const insideSubject = pointInPolygon(mid, subject)
const insideAnotherCutter = cutters.some(
(cutter, cutterIndex) =>
cutterIndex !== edge.polygonIndex - 1 && pointInPolygonOrOnBoundary(mid, cutter),
)
if (insideSubject && !insideAnotherCutter) {
segments.push({ start: end, end: start, used: false })
}
}
}
return removeDuplicateInteriorSegments(segments)
}
export function subtractPolygonsFromPolygon(subject: Point2D[], cutters: Point2D[][]): Point2D[][] {
const validSubject = normalizeRing(subject)
if (validSubject.length < 3) return []
const validCutters = cutters.map(normalizeRing).filter((polygon) => polygon.length >= 3)
if (validCutters.length === 0) return [validSubject]
const polygons = [validSubject, ...validCutters]
const edges = buildEdges(polygons)
const segments = buildDifferenceBoundarySegments(edges, polygons)
const rings = assembleRings(segments)
if (rings.length > 0) return rings
const fullyCovered = validSubject.every((point) =>
validCutters.some((cutter) => pointInPolygonOrOnBoundary(point, cutter)),
)
return fullyCovered ? [] : [validSubject]
}
@@ -0,0 +1,70 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import { SlabNode } from '@pascal-app/core'
import type * as THREE from 'three'
import { generateSlabGeometry } from './slab-system'
function hasVertexAt(geometry: THREE.BufferGeometry, x: number, z: number) {
const positions = geometry.getAttribute('position')
for (let index = 0; index < positions.count; index += 1) {
if (Math.abs(positions.getX(index) - x) < 1e-6 && Math.abs(positions.getZ(index) - z) < 1e-6) {
return true
}
}
return false
}
describe('generateSlabGeometry', () => {
test('renders a boundary-overlapping hole as an open indentation', () => {
const slab = SlabNode.parse({
elevation: 0.05,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
holes: [
[
[1, -0.5],
[3, -0.5],
[3, 1],
[1, 1],
],
],
})
const geometry = generateSlabGeometry(slab)
expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0)
expect(hasVertexAt(geometry, 1, 1)).toBe(true)
expect(hasVertexAt(geometry, 3, 1)).toBe(true)
})
test('renders a boundary-overlapping hole as an open indentation on recessed slabs', () => {
const slab = SlabNode.parse({
elevation: -0.2,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
holes: [
[
[1, -0.5],
[3, -0.5],
[3, 1],
[1, 1],
],
],
})
const geometry = generateSlabGeometry(slab)
expect((geometry.index?.count ?? 0) / 3).toBeGreaterThan(0)
expect(hasVertexAt(geometry, 1, 1)).toBe(true)
expect(hasVertexAt(geometry, 3, 1)).toBe(true)
})
})
+113 -66
View File
@@ -1,6 +1,10 @@
import { import {
type AnyNodeId, type AnyNodeId,
getEffectiveNode,
getRenderableSlabPolygon, getRenderableSlabPolygon,
type PolygonPoint2D,
pointInPolygon2D,
polygonsIntersect,
type SlabNode, type SlabNode,
sceneRegistry, sceneRegistry,
useScene, useScene,
@@ -8,6 +12,7 @@ import {
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect } from 'react' import { useEffect } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { subtractPolygonsFromPolygon } from '../../lib/polygon-union'
import { mergeSurfaceHolePolygons } from '../surface-hole-geometry' import { mergeSurfaceHolePolygons } from '../surface-hole-geometry'
function ensureUv2Attribute(geometry: THREE.BufferGeometry) { function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
@@ -47,7 +52,7 @@ export const SlabSystem = () => {
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
if (mesh) { if (mesh) {
updateSlabGeometry(node as SlabNode, mesh) updateSlabGeometry(getEffectiveNode(node as SlabNode), mesh)
clearDirty(id as AnyNodeId) clearDirty(id as AnyNodeId)
} }
// If mesh not found, keep it dirty for next frame // If mesh not found, keep it dirty for next frame
@@ -95,6 +100,40 @@ function ensureCounterClockwisePolygon(polygon: Array<[number, number]>): Array<
return area2 < 0 ? [...polygon].reverse() : polygon return area2 < 0 ? [...polygon].reverse() : polygon
} }
function isStrictInteriorHole(contour: PolygonPoint2D[], hole: PolygonPoint2D[]) {
return (
hole.every((point) => pointInPolygon2D(point, contour, { includeBoundary: false })) &&
!polygonsIntersect(contour, hole)
)
}
function affectsContour(contour: PolygonPoint2D[], hole: PolygonPoint2D[]) {
return (
polygonsIntersect(contour, hole) ||
hole.some((point) => pointInPolygon2D(point, contour, { includeBoundary: false })) ||
contour.some((point) => pointInPolygon2D(point, hole, { includeBoundary: false }))
)
}
function buildSlabRegions(contour: PolygonPoint2D[], holes: PolygonPoint2D[][]) {
const containedHoles: PolygonPoint2D[][] = []
const edgeCutouts: PolygonPoint2D[][] = []
for (const hole of holes) {
if (hole.length < 3) continue
if (isStrictInteriorHole(contour, hole)) containedHoles.push(hole)
else if (affectsContour(contour, hole)) edgeCutouts.push(hole)
}
const contours =
edgeCutouts.length > 0 ? subtractPolygonsFromPolygon(contour, edgeCutouts) : [contour]
return contours.map((regionContour) => ({
contour: regionContour,
holes: containedHoles.filter((hole) => isStrictInteriorHole(regionContour, hole)),
}))
}
/** /**
* Standard slab: flat extrusion upward from Y=0 by elevation thickness. * Standard slab: flat extrusion upward from Y=0 by elevation thickness.
* *
@@ -118,35 +157,6 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
const uvs: number[] = [] const uvs: number[] = []
const indices: number[] = [] const indices: number[] = []
const contour2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!))
const holes2d = holePolygons
.filter((h) => h.length >= 3)
.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
// --- Top & bottom caps ---
// capPoints order (contour then holes) matches triangulateShape's index space.
// UVs reproduce ExtrudeGeometry's WorldUVGenerator mapping (shape-space x,-z)
// so textured slabs keep the same floor projection.
const capPoints = [...contour2d, ...holes2d.flat()]
const topBase = positions.length / 3
for (const p of capPoints) {
positions.push(p.x, elevation, p.y)
uvs.push(p.x, -p.y)
}
const bottomBase = positions.length / 3
for (const p of capPoints) {
positions.push(p.x, 0, p.y)
uvs.push(p.x, -p.y)
}
const capTris = THREE.ShapeUtils.triangulateShape(contour2d, holes2d)
for (const tri of capTris) {
const [a, b, c] = [tri[0]!, tri[1]!, tri[2]!]
// Reversed winding → +Y normal on top; standard winding → -Y on bottom.
indices.push(topBase + a, topBase + c, topBase + b)
indices.push(bottomBase + a, bottomBase + b, bottomBase + c)
}
// --- Side walls --- // --- Side walls ---
// Each segment gets its own 4 verts so computeVertexNormals doesn't average // Each segment gets its own 4 verts so computeVertexNormals doesn't average
// across faces. Outer walls are single-sided with outward normals; hole walls // across faces. Outer walls are single-sided with outward normals; hole walls
@@ -171,15 +181,49 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
} }
} }
for (let i = 0; i < contour2d.length; i++) { for (const region of buildSlabRegions(polygon, holePolygons)) {
addWall(contour2d[i]!, contour2d[(i + 1) % contour2d.length]!, false) const contour2d = ensureCounterClockwisePolygon(region.contour).map(
} ([x, z]) => new THREE.Vector2(x!, z!),
for (const hole of holes2d) { )
for (let i = 0; i < hole.length; i++) { const holes2d = region.holes
const a = hole[i]! .filter((h) => h.length >= 3)
const b = hole[(i + 1) % hole.length]! .map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
addWall(a, b, false)
addWall(a, b, true) // --- Top & bottom caps ---
// capPoints order (contour then holes) matches triangulateShape's index space.
// UVs reproduce ExtrudeGeometry's WorldUVGenerator mapping (shape-space x,-z)
// so textured slabs keep the same floor projection.
const capPoints = [...contour2d, ...holes2d.flat()]
const topBase = positions.length / 3
for (const p of capPoints) {
positions.push(p.x, elevation, p.y)
uvs.push(p.x, -p.y)
}
const bottomBase = positions.length / 3
for (const p of capPoints) {
positions.push(p.x, 0, p.y)
uvs.push(p.x, -p.y)
}
const capTris = THREE.ShapeUtils.triangulateShape(contour2d, holes2d)
for (const tri of capTris) {
const [a, b, c] = [tri[0]!, tri[1]!, tri[2]!]
// Reversed winding → +Y normal on top; standard winding → -Y on bottom.
indices.push(topBase + a, topBase + c, topBase + b)
indices.push(bottomBase + a, bottomBase + b, bottomBase + c)
}
for (let i = 0; i < contour2d.length; i++) {
addWall(contour2d[i]!, contour2d[(i + 1) % contour2d.length]!, false)
}
for (const hole of holes2d) {
for (let i = 0; i < hole.length; i++) {
const a = hole[i]!
const b = hole[(i + 1) % hole.length]!
addWall(a, b, false)
addWall(a, b, true)
}
} }
} }
@@ -210,7 +254,6 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const positions: number[] = [] const positions: number[] = []
const uvs: number[] = [] const uvs: number[] = []
const indices: number[] = [] const indices: number[] = []
const n = polygon.length
const bounds = new THREE.Box2() const bounds = new THREE.Box2()
for (const [x, z] of polygon) { for (const [x, z] of polygon) {
@@ -235,37 +278,41 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
uvs.push(u, v) uvs.push(u, v)
} }
// --- Floor at Y=0 --- for (const region of buildSlabRegions(polygon, holePolygons)) {
for (const [x, z] of polygon) pushFloorVertex(x!, 0, z!) const contour = ensureCounterClockwisePolygon(region.contour)
const floorBase = positions.length / 3
const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!)) // --- Floor at Y=0 ---
const holesPts2d = holePolygons.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!))) for (const [x, z] of contour) pushFloorVertex(x!, 0, z!)
for (const hole of holePolygons) { const pts2d = contour.map(([x, z]) => new THREE.Vector2(x!, z!))
for (const [x, z] of hole) pushFloorVertex(x!, 0, z!) const holesPts2d = region.holes.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
} for (const hole of region.holes) {
for (const [x, z] of hole) pushFloorVertex(x!, 0, z!)
}
const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d) const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d)
for (const tri of floorTris) { for (const tri of floorTris) {
// Reversed winding → normals point +Y (upward) in XZ plane // Reversed winding → normals point +Y (upward) in XZ plane
indices.push(tri[0]!, tri[2]!, tri[1]!) indices.push(floorBase + tri[0]!, floorBase + tri[2]!, floorBase + tri[1]!)
} }
// --- Inner walls (no top cap at Y=depth) --- // --- Inner walls (no top cap at Y=depth) ---
// Standard winding on a CCW polygon in XZ gives inward-facing normals. // Standard winding on a CCW polygon in XZ gives inward-facing normals.
for (let i = 0; i < n; i++) { for (let i = 0; i < contour.length; i++) {
const j = (i + 1) % n const j = (i + 1) % contour.length
const [x0, z0] = polygon[i]! const [x0, z0] = contour[i]!
const [x1, z1] = polygon[j]! const [x1, z1] = contour[j]!
const vBase = positions.length / 3 const vBase = positions.length / 3
const segmentLength = Math.max(Math.hypot(x1 - x0, z1 - z0), 0.001) const segmentLength = Math.max(Math.hypot(x1 - x0, z1 - z0), 0.001)
pushWallVertex(x0!, 0, z0!, 0, 0) // v0 — floor level pushWallVertex(x0!, 0, z0!, 0, 0) // v0 — floor level
pushWallVertex(x1!, 0, z1!, segmentLength, 0) // v1 — floor level pushWallVertex(x1!, 0, z1!, segmentLength, 0) // v1 — floor level
pushWallVertex(x1!, depth, z1!, segmentLength, depth) // v2 — ground level pushWallVertex(x1!, depth, z1!, segmentLength, depth) // v2 — ground level
pushWallVertex(x0!, depth, z0!, 0, depth) // v3 — ground level pushWallVertex(x0!, depth, z0!, 0, depth) // v3 — ground level
indices.push(vBase, vBase + 1, vBase + 2) indices.push(vBase, vBase + 1, vBase + 2)
indices.push(vBase, vBase + 2, vBase + 3) indices.push(vBase, vBase + 2, vBase + 3)
}
} }
const geo = new THREE.BufferGeometry() const geo = new THREE.BufferGeometry()