Merge branch 'main' into feat/elevator-system

# Conflicts:
#	packages/editor/src/components/tools/item/move-tool.tsx
#	packages/editor/src/components/tools/tool-manager.tsx
#	packages/editor/src/components/ui/panels/panel-manager.tsx
#	packages/editor/src/store/use-editor.tsx
#	packages/viewer/src/components/renderers/site/site-renderer.tsx
#	packages/viewer/src/components/viewer/ground-occluder.tsx
#	packages/viewer/src/components/viewer/index.tsx
#	packages/viewer/src/components/viewer/post-processing.tsx
This commit is contained in:
sudhir
2026-05-13 01:38:55 +05:30
256 changed files with 9027 additions and 5479 deletions
@@ -1,5 +1,3 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; core does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import type { AnyNode } from '../../schema'
import { BuildingNode, CeilingNode, ElevatorNode, LevelNode, SlabNode } from '../../schema'
@@ -1,5 +1,3 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; core does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import type { AnyNode } from '../../schema'
import {
@@ -35,10 +35,9 @@ type AxisAlignedRect = {
maxZ: number
}
const CURVED_STAIR_SLAB_OPENING_RATIO = 0.9
const CURVED_STAIR_SLAB_OPENING_RATIO = 0.8
const STRAIGHT_STAIR_TARGET_THRESHOLD_MIN = 0.35
const STAIR_SLAB_OPENING_TIGHTENING = 0
const CURVED_STAIR_OPENING_STEP_PADDING = 3
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
@@ -280,7 +279,7 @@ function polygonArea(points: Point2D[]) {
for (let index = 0; index < points.length; index += 1) {
const current = points[index]
const next = points[(index + 1) % points.length]
if (!current || !next) continue
if (!(current && next)) continue
area += current[0] * next[1] - next[0] * current[1]
}
return area / 2
@@ -430,39 +429,24 @@ function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] {
return polygons
}
function getCurvedOpeningStepCount(
stair: StairNode,
innerRadius: number,
outerRadius: number,
totalSweep: number,
) {
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
const stepSweep = Math.abs(totalSweep) / stepCount
const midRadius = Math.max((innerRadius + outerRadius) * 0.5, 0.01)
const treadDepth = Math.max(stepSweep * midRadius, 0.2)
return Math.min(
stepCount,
function getCurvedOpeningPolygon(stair: StairNode): Point2D[] {
const width = Math.max(stair.width ?? 1, 0.4)
const innerRadius = Math.max(0.2, stair.innerRadius ?? 0.9)
const outerRadius = innerRadius + width
const totalSweep = stair.sweepAngle ?? Math.PI / 2
const openingSweep =
Math.sign(totalSweep || 1) *
Math.max(
1,
Math.ceil(1.8 / treadDepth),
Math.ceil(stepCount * CURVED_STAIR_SLAB_OPENING_RATIO),
),
)
}
function buildArcOpeningPolygon(
stair: StairNode,
innerRadius: number,
outerRadius: number,
startAngle: number,
endAngle: number,
): Point2D[] {
const sweep = endAngle - startAngle
Math.abs(totalSweep) * CURVED_STAIR_SLAB_OPENING_RATIO,
Math.abs(totalSweep) / Math.max(stair.stepCount ?? 1, 1),
)
const startAngle = totalSweep / 2 - openingSweep
const endAngle = totalSweep / 2
const segmentCount = Math.max(
10,
Math.min(
32,
Math.ceil(Math.abs(sweep) / (Math.PI / 24) + Math.max(stair.stepCount ?? 1, 1) * 0.5),
Math.ceil(Math.abs(openingSweep) / (Math.PI / 24) + Math.max(stair.stepCount ?? 1, 1) * 0.5),
),
)
const outerPoints: Point2D[] = []
@@ -470,7 +454,7 @@ function buildArcOpeningPolygon(
for (let index = 0; index <= segmentCount; index++) {
const t = index / segmentCount
const angle = startAngle + sweep * t
const angle = startAngle + (endAngle - startAngle) * t
outerPoints.push(
toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius),
)
@@ -478,8 +462,7 @@ function buildArcOpeningPolygon(
for (let index = segmentCount; index >= 0; index--) {
const t = index / segmentCount
const angle = startAngle + sweep * t
const angle = startAngle + (endAngle - startAngle) * t
innerPoints.push(
toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius),
)
@@ -488,39 +471,6 @@ function buildArcOpeningPolygon(
return [...outerPoints, ...innerPoints]
}
function getCurvedOpeningPolygon(stair: StairNode, targetElevation?: number): Point2D[] {
const width = Math.max(stair.width ?? 1, 0.4)
const innerRadius = Math.max(0.2, stair.innerRadius ?? 0.9)
const outerRadius = innerRadius + width
const totalSweep = stair.sweepAngle ?? Math.PI / 2
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
const stepHeight = Math.max(stair.totalRise ?? 2.5, 0.1) / stepCount
const stepSweep = totalSweep / stepCount
const targetThreshold = Math.max(stepHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN)
const endAngle = totalSweep / 2
const fallbackStartStepIndex = Math.max(
0,
stepCount - getCurvedOpeningStepCount(stair, innerRadius, outerRadius, totalSweep),
)
let startStepIndex = fallbackStartStepIndex
if (typeof targetElevation === 'number') {
for (let index = 0; index < stepCount; index += 1) {
const stepTopElevation = stepHeight * (index + 1)
if (stepTopElevation >= targetElevation - targetThreshold) {
startStepIndex = Math.max(
0,
Math.min(fallbackStartStepIndex, index - CURVED_STAIR_OPENING_STEP_PADDING),
)
break
}
}
}
const startAngle = -totalSweep / 2 + stepSweep * startStepIndex
return buildArcOpeningPolygon(stair, innerRadius, outerRadius, startAngle, endAngle)
}
function getSpiralOpeningPolygon(stair: StairNode): Point2D[] {
const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4)
const segmentCount = 48
@@ -625,7 +575,7 @@ function getStairOpeningPolygons(
}
if (stair.stairType === 'curved') {
return [getCurvedOpeningPolygon(stair, targetElevation)]
return [getCurvedOpeningPolygon(stair)]
}
if (stair.stairType === 'spiral') {
@@ -784,8 +734,7 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
]
if (
!polygonsEqual(existingHoles, nextHoles) ||
!metadataEqual(existingMetadata, nextMetadata)
!(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata))
) {
updates.push({
id: slab.id,
@@ -840,8 +789,7 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
]
if (
!polygonsEqual(existingHoles, nextHoles) ||
!metadataEqual(existingMetadata, nextMetadata)
!(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata))
) {
updates.push({
id: ceiling.id,
+6 -3
View File
@@ -1,5 +1,5 @@
import type { Point2D } from './wall-mitering'
import type { FenceNode, WallNode } from '../../schema'
import type { Point2D } from './wall-mitering'
const CURVE_EPSILON = 1e-6
const DEFAULT_SAMPLE_SEGMENTS = 24
@@ -115,7 +115,7 @@ function getWallArcData(wall: WallCurveLike) {
}
const absSagitta = Math.abs(sagitta)
const radius = chord.length * chord.length / (8 * absSagitta) + absSagitta / 2
const radius = (chord.length * chord.length) / (8 * absSagitta) + absSagitta / 2
const centerOffset = radius - absSagitta
const direction = Math.sign(sagitta) || 1
const center = {
@@ -183,7 +183,10 @@ export function getWallMidpointHandlePoint(wall: WallCurveLike) {
export function sampleWallCenterline(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) {
const count = Math.max(1, segments)
return Array.from({ length: count + 1 }, (_, index) => getWallCurveFrameAt(wall, index / count).point)
return Array.from(
{ length: count + 1 },
(_, index) => getWallCurveFrameAt(wall, index / count).point,
)
}
export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) {
@@ -135,10 +135,7 @@ function findJunctions(walls: WallNode[]): Map<string, Junction> {
return actualJunctions
}
function getWallDirectionFromJunction(
wall: WallNode,
endType: 'start' | 'end' | 'passthrough',
) {
function getWallDirectionFromJunction(wall: WallNode, endType: 'start' | 'end' | 'passthrough') {
if (endType === 'passthrough') {
return {
x: wall.end[0] - wall.start[0],
@@ -148,9 +145,7 @@ function getWallDirectionFromJunction(
if (isCurvedWall(wall)) {
const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1)
return endType === 'start'
? frame.tangent
: { x: -frame.tangent.x, y: -frame.tangent.y }
return endType === 'start' ? frame.tangent : { x: -frame.tangent.x, y: -frame.tangent.y }
}
return endType === 'start'
@@ -158,18 +153,12 @@ function getWallDirectionFromJunction(
: { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] }
}
function getWallBoundaryFrame(
wall: WallNode,
endType: 'start' | 'end',
) {
function getWallBoundaryFrame(wall: WallNode, endType: 'start' | 'end') {
if (isCurvedWall(wall)) {
const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1)
return {
point: frame.point,
tangent:
endType === 'start'
? frame.tangent
: { x: -frame.tangent.x, y: -frame.tangent.y },
tangent: endType === 'start' ? frame.tangent : { x: -frame.tangent.x, y: -frame.tangent.y },
normal: frame.normal,
}
}
+227
View File
@@ -0,0 +1,227 @@
import type { WallNode } from '../../schema'
const AXIS_EPSILON = 1e-6
export type WallPlanPoint = [number, number]
export type WallMoveAxis = 'x' | 'z'
export type WallMoveEndpoint = 'start' | 'end'
export type WallMoveBridgePlan<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
wall: TWall
originalPoint: WallPlanPoint
movedEndpoint: WallMoveEndpoint
}
export type WallMoveLinkedWallTargetPlan<
TWall extends Pick<WallNode, 'id' | 'start' | 'end'>,
> = {
wall: TWall
originalPoint: WallPlanPoint
targetPoint: WallPlanPoint
}
export type WallMoveJunctionPlan<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
linkedWallsToMove: TWall[]
linkedWallTargetPlans: Array<WallMoveLinkedWallTargetPlan<TWall>>
bridgePlans: Array<WallMoveBridgePlan<TWall>>
wallsToDelete: TWall[]
}
export function getPerpendicularWallMoveAxis(
start: WallPlanPoint,
end: WallPlanPoint,
): WallMoveAxis | null {
const wallDeltaX = Math.abs(end[0] - start[0])
const wallDeltaZ = Math.abs(end[1] - start[1])
if (wallDeltaX < AXIS_EPSILON && wallDeltaZ < AXIS_EPSILON) return null
return wallDeltaX >= wallDeltaZ ? 'z' : 'x'
}
export function constrainWallMoveDeltaToAxis(
deltaX: number,
deltaZ: number,
axis: WallMoveAxis | null,
): WallPlanPoint {
if (axis === 'x') return [deltaX, 0]
if (axis === 'z') return [0, deltaZ]
return [deltaX, deltaZ]
}
function pointsEqual(a: WallPlanPoint, b: WallPlanPoint) {
return Math.abs(a[0] - b[0]) <= AXIS_EPSILON && Math.abs(a[1] - b[1]) <= AXIS_EPSILON
}
function wallTouchesPoint(wall: Pick<WallNode, 'start' | 'end'>, point: WallPlanPoint) {
return pointsEqual(wall.start, point) || pointsEqual(wall.end, point)
}
function otherWallEndpoint(wall: Pick<WallNode, 'start' | 'end'>, point: WallPlanPoint) {
return pointsEqual(wall.start, point) ? wall.end : wall.start
}
type MoveWallRelation = 'same-direction' | 'opposite-direction' | 'off-axis' | 'stationary'
type RelatedWallEntry<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
wall: TWall
relation: MoveWallRelation
}
function wallLengthFromPoint(wall: Pick<WallNode, 'start' | 'end'>, point: WallPlanPoint) {
const freeEndpoint = otherWallEndpoint(wall, point)
return Math.hypot(freeEndpoint[0] - point[0], freeEndpoint[1] - point[1])
}
function getMoveWallRelation(
wall: Pick<WallNode, 'start' | 'end'>,
sharedPoint: WallPlanPoint,
nextPoint: WallPlanPoint,
): MoveWallRelation {
const moveX = nextPoint[0] - sharedPoint[0]
const moveZ = nextPoint[1] - sharedPoint[1]
const moveLength = Math.hypot(moveX, moveZ)
if (moveLength < AXIS_EPSILON) return 'stationary'
const freeEndpoint = otherWallEndpoint(wall, sharedPoint)
const wallX = freeEndpoint[0] - sharedPoint[0]
const wallZ = freeEndpoint[1] - sharedPoint[1]
const wallLength = Math.hypot(wallX, wallZ)
if (wallLength < AXIS_EPSILON) return 'stationary'
const normalizedCross = Math.abs(moveX * wallZ - moveZ * wallX) / (moveLength * wallLength)
if (normalizedCross > 1e-4) return 'off-axis'
const normalizedDot = (moveX * wallX + moveZ * wallZ) / (moveLength * wallLength)
return normalizedDot >= 0 ? 'same-direction' : 'opposite-direction'
}
export function planWallMoveJunctions<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>>(
linkedWalls: TWall[],
originalStart: WallPlanPoint,
originalEnd: WallPlanPoint,
nextStart: WallPlanPoint,
nextEnd: WallPlanPoint,
): WallMoveJunctionPlan<TWall> {
const linkedWallsToMove = new Map<TWall['id'], TWall>()
const linkedWallTargetPlans = new Map<TWall['id'], WallMoveLinkedWallTargetPlan<TWall>>()
const bridgePlans = new Map<string, WallMoveBridgePlan<TWall>>()
const wallsToDelete = new Map<TWall['id'], TWall>()
const addStandardEndpointPlan = (
endpoint: WallMoveEndpoint,
point: WallPlanPoint,
nextPoint: WallPlanPoint,
relatedWalls: Array<RelatedWallEntry<TWall>>,
keySuffix = '',
useTargetPlans = false,
) => {
const hasSideBranch = relatedWalls.some((entry) => entry.relation === 'off-axis')
const hasOppositeBridge = relatedWalls.some(
(entry) => entry.relation === 'opposite-direction' && hasSideBranch,
)
for (const { wall, relation } of relatedWalls) {
if (
relation === 'stationary' ||
relation === 'same-direction' ||
(relation === 'opposite-direction' && !hasSideBranch)
) {
if (useTargetPlans) {
linkedWallTargetPlans.set(wall.id, {
wall,
originalPoint: point,
targetPoint: nextPoint,
})
} else {
linkedWallsToMove.set(wall.id, wall)
}
continue
}
if (relation === 'off-axis' && hasOppositeBridge) {
continue
}
bridgePlans.set(`${wall.id}:${endpoint}${keySuffix}`, {
wall,
originalPoint: point,
movedEndpoint: endpoint,
})
}
}
const addEndpointPlan = (
endpoint: WallMoveEndpoint,
point: WallPlanPoint,
nextPoint: WallPlanPoint,
) => {
const moveLength = Math.hypot(nextPoint[0] - point[0], nextPoint[1] - point[1])
const linkedAtEndpoint = linkedWalls
.filter((wall) => wallTouchesPoint(wall, point))
.map((wall) => ({
wall,
relation: getMoveWallRelation(wall, point, nextPoint),
}))
const consumedSameDirectionWall = linkedAtEndpoint
.filter((entry) => entry.relation === 'same-direction')
.map((entry) => ({
...entry,
distance: wallLengthFromPoint(entry.wall, point),
}))
.filter((entry) => moveLength + AXIS_EPSILON >= entry.distance)
.sort((a, b) => a.distance - b.distance)[0]
if (consumedSameDirectionWall) {
const pivotPoint = [...otherWallEndpoint(consumedSameDirectionWall.wall, point)] as WallPlanPoint
const bridgeSource = linkedAtEndpoint.find((entry) => entry.relation === 'opposite-direction')
wallsToDelete.set(consumedSameDirectionWall.wall.id, consumedSameDirectionWall.wall)
linkedWallTargetPlans.set(consumedSameDirectionWall.wall.id, {
wall: consumedSameDirectionWall.wall,
originalPoint: point,
targetPoint: pivotPoint,
})
if (bridgeSource) {
linkedWallTargetPlans.set(bridgeSource.wall.id, {
wall: bridgeSource.wall,
originalPoint: point,
targetPoint: pivotPoint,
})
bridgePlans.set(`${bridgeSource.wall.id}:${endpoint}:through`, {
wall: bridgeSource.wall,
originalPoint: pivotPoint,
movedEndpoint: endpoint,
})
return
}
const linkedAtPivot = linkedWalls
.filter(
(wall) => wall.id !== consumedSameDirectionWall.wall.id && wallTouchesPoint(wall, pivotPoint),
)
.map((wall) => ({
wall,
relation: getMoveWallRelation(wall, pivotPoint, nextPoint),
}))
addStandardEndpointPlan(endpoint, pivotPoint, nextPoint, linkedAtPivot, ':through-pivot', true)
return
}
addStandardEndpointPlan(endpoint, point, nextPoint, linkedAtEndpoint)
}
addEndpointPlan('start', originalStart, nextStart)
addEndpointPlan('end', originalEnd, nextEnd)
return {
linkedWallsToMove: Array.from(linkedWallsToMove.values()),
linkedWallTargetPlans: Array.from(linkedWallTargetPlans.values()),
bridgePlans: Array.from(bridgePlans.values()),
wallsToDelete: Array.from(wallsToDelete.values()),
}
}