Merge pull request #380 from pascalorg/bug-fixes-and-improvements
Fix stair openings and editor placement polish
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export {
|
|||||||
getActiveRoofHeight,
|
getActiveRoofHeight,
|
||||||
getEffectiveSegmentSurfaceMaterial,
|
getEffectiveSegmentSurfaceMaterial,
|
||||||
getPitchFromActiveRoofHeight,
|
getPitchFromActiveRoofHeight,
|
||||||
|
getRoofSegmentSurfaceY,
|
||||||
getSegmentSlopeFrame,
|
getSegmentSlopeFrame,
|
||||||
hasSegmentMaterialOverride,
|
hasSegmentMaterialOverride,
|
||||||
ROOF_SHAPE_DEFAULTS,
|
ROOF_SHAPE_DEFAULTS,
|
||||||
|
|||||||
@@ -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() }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user