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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user