@pascal-app/editor 1.0.0-beta.3 → 1.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -6
- package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
- package/src/components/editor/editor-layout-v2.tsx +8 -3
- package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
- package/src/components/editor/first-person-controls.tsx +2 -45
- package/src/components/editor/floating-action-menu.tsx +13 -4
- package/src/components/editor/floorplan-background-selection.test.ts +31 -4
- package/src/components/editor/floorplan-background-selection.ts +27 -7
- package/src/components/editor/floorplan-panel.tsx +5 -45
- package/src/components/editor/group-floating-action-menu.tsx +30 -15
- package/src/components/editor/index.tsx +112 -25
- package/src/components/editor/measurement-pill.tsx +12 -12
- package/src/components/editor/node-action-menu.tsx +29 -1
- package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
- package/src/components/editor/selection-manager.tsx +28 -5
- package/src/components/editor/site-edge-labels.tsx +2 -1
- package/src/components/editor/snapshot-capture-overlay.tsx +11 -2
- package/src/components/editor/thumbnail-generator.tsx +27 -11
- package/src/components/editor/wall-measurement-label.tsx +3 -2
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +26 -84
- package/src/components/tools/item/placement-strategies.test.ts +167 -0
- package/src/components/tools/item/placement-strategies.ts +57 -22
- package/src/components/tools/item/use-placement-coordinator.tsx +19 -27
- package/src/components/tools/shared/placement-box.tsx +9 -4
- package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
- package/src/components/tools/wall/wall-drafting.test.ts +207 -2
- package/src/components/tools/wall/wall-drafting.ts +84 -400
- package/src/components/ui/command-palette/editor-commands.tsx +3 -1
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/panels/multi-selection-panel.tsx +54 -13
- package/src/components/ui/panels/panel-wrapper.tsx +228 -12
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +11 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +12 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +8 -1
- package/src/components/ui/sidebar/tab-bar.tsx +7 -1
- package/src/components/viewer/floorplan-compass-button.tsx +50 -0
- package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
- package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
- package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
- package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
- package/src/components/viewer/floorplan-preview.tsx +1069 -0
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
- package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
- package/src/components/viewer/viewer-stage-modes.ts +49 -0
- package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
- package/src/components/viewer/viewer-stage.test.tsx +47 -0
- package/src/components/viewer/viewer-stage.tsx +218 -0
- package/src/components/viewer-overlay.tsx +12 -8
- package/src/hooks/use-auto-save.test.ts +50 -1
- package/src/hooks/use-auto-save.ts +44 -9
- package/src/hooks/use-keyboard.ts +35 -1
- package/src/index.tsx +27 -0
- package/src/lib/contextual-help.test.ts +8 -0
- package/src/lib/contextual-help.ts +8 -0
- package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
- package/src/lib/floorplan/floorplan-readonly.ts +84 -0
- package/src/lib/history.test.ts +75 -10
- package/src/lib/history.ts +58 -13
- package/src/lib/inspector-card-mode.test.ts +92 -0
- package/src/lib/inspector-card-mode.ts +60 -0
- package/src/lib/measurement-parser.ts +3 -0
- package/src/lib/measurements.test.ts +4 -0
- package/src/lib/paint-scope.test.ts +58 -2
- package/src/lib/paint-scope.ts +8 -2
- package/src/lib/selection-routing.test.ts +20 -5
- package/src/lib/selection-routing.ts +15 -1
- package/src/lib/session-groups.test.ts +88 -0
- package/src/lib/session-groups.ts +201 -0
- package/src/lib/structured-clone-fallback.test.ts +45 -0
- package/src/lib/structured-clone-fallback.ts +24 -0
- package/src/store/use-session-groups.ts +103 -0
|
@@ -295,6 +295,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
295
295
|
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
|
|
296
296
|
const { asset, draftNode } = config
|
|
297
297
|
const unit = useViewer((state) => state.unit)
|
|
298
|
+
const metricNotation = useViewer((state) => state.metricNotation)
|
|
298
299
|
const gridSnapStep = useEditor((s) => s.gridSnapStep)
|
|
299
300
|
const updatePreviewGeometry = useCallback((bounds: PreviewBounds) => {
|
|
300
301
|
const [width, height, depth] = bounds.dimensions
|
|
@@ -1129,19 +1130,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1129
1130
|
}
|
|
1130
1131
|
const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX)
|
|
1131
1132
|
const correctedY = wallDragAnchor.startY + (rawY - wallDragAnchor.rawY)
|
|
1132
|
-
const wallMesh = sceneRegistry.nodes.get(event.node.id)
|
|
1133
|
-
// Derive the world cursor from the corrected wall-local point so the
|
|
1134
|
-
// visual cursor (world) and the stored position (wall-local) agree; if
|
|
1135
|
-
// the wall mesh is somehow absent, keep the raw world hit unchanged.
|
|
1136
|
-
const correctedWorld = wallMesh
|
|
1137
|
-
? wallMesh.localToWorld(new Vector3(correctedX, correctedY, event.localPosition[2]))
|
|
1138
|
-
: null
|
|
1139
1133
|
wallMoveEvent = {
|
|
1140
1134
|
...event,
|
|
1141
1135
|
localPosition: [correctedX, correctedY, event.localPosition[2]],
|
|
1142
|
-
position: correctedWorld
|
|
1143
|
-
? [correctedWorld.x, correctedWorld.y, correctedWorld.z]
|
|
1144
|
-
: event.position,
|
|
1145
1136
|
}
|
|
1146
1137
|
}
|
|
1147
1138
|
const result = wallStrategy.move(ctx, wallMoveEvent, getActiveValidators())
|
|
@@ -1203,22 +1194,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
1203
1194
|
|
|
1204
1195
|
// Publish live transform for the 2D floorplan. The floorplan resolves a
|
|
1205
1196
|
// wall item's footprint (and its wall-side depth offset) from this
|
|
1206
|
-
// rotation as a PLAN-space yaw
|
|
1207
|
-
//
|
|
1208
|
-
// raw flips the footprint to the far side of the wall during placement.
|
|
1209
|
-
// Publish the plan rotation (wall angle + the item's wall-local yaw) so
|
|
1210
|
-
// the preview matches what the committed node resolves to.
|
|
1211
|
-
let liveRotation = result.cursorRotationY
|
|
1212
|
-
const liveWallId = placementState.current.wallId
|
|
1213
|
-
const liveWall = liveWallId ? useScene.getState().nodes[liveWallId as AnyNodeId] : undefined
|
|
1214
|
-
if (liveWall?.type === 'wall') {
|
|
1215
|
-
const w = liveWall as WallNode
|
|
1216
|
-
const wallPlanRotation = -Math.atan2(w.end[1] - w.start[1], w.end[0] - w.start[0])
|
|
1217
|
-
liveRotation = wallPlanRotation + (draft.rotation[1] ?? 0)
|
|
1218
|
-
}
|
|
1197
|
+
// rotation as a PLAN-space yaw — which is exactly what the wall strategy
|
|
1198
|
+
// composes `cursorRotationY` as (wall yaw + the item's wall-local yaw).
|
|
1219
1199
|
useLiveTransforms.getState().set(draft.id, {
|
|
1220
1200
|
position: result.cursorPosition,
|
|
1221
|
-
rotation:
|
|
1201
|
+
rotation: result.cursorRotationY,
|
|
1222
1202
|
})
|
|
1223
1203
|
}
|
|
1224
1204
|
}
|
|
@@ -2558,9 +2538,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|
|
2558
2538
|
depth: currentDimensionBounds.dimensions[2],
|
|
2559
2539
|
center: [currentDimensionBounds.center[0], currentDimensionBounds.center[2]],
|
|
2560
2540
|
}
|
|
2561
|
-
const widthLabel = formatLinearMeasurement(
|
|
2562
|
-
|
|
2563
|
-
|
|
2541
|
+
const widthLabel = formatLinearMeasurement(
|
|
2542
|
+
currentDimensionBounds.dimensions[0],
|
|
2543
|
+
unit,
|
|
2544
|
+
metricNotation,
|
|
2545
|
+
)
|
|
2546
|
+
const depthLabel = formatLinearMeasurement(
|
|
2547
|
+
currentDimensionBounds.dimensions[2],
|
|
2548
|
+
unit,
|
|
2549
|
+
metricNotation,
|
|
2550
|
+
)
|
|
2551
|
+
const heightLabel = formatLinearMeasurement(
|
|
2552
|
+
currentDimensionBounds.dimensions[1],
|
|
2553
|
+
unit,
|
|
2554
|
+
metricNotation,
|
|
2555
|
+
)
|
|
2564
2556
|
const widthLabelPosition: [number, number, number] = [
|
|
2565
2557
|
currentDimensionBounds.center[0],
|
|
2566
2558
|
0.04,
|
|
@@ -8,7 +8,11 @@ import { PlaneGeometry } from 'three'
|
|
|
8
8
|
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
|
9
9
|
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
|
10
10
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
formatLinearMeasurement,
|
|
13
|
+
type LinearUnit,
|
|
14
|
+
type MetricNotation,
|
|
15
|
+
} from '../../../lib/measurements'
|
|
12
16
|
import { createLineGeometry, getBoxEdgePoints } from './placement-box-geometry'
|
|
13
17
|
|
|
14
18
|
const VALID_COLOR = 0x22_c5_5e // green-500
|
|
@@ -17,6 +21,7 @@ const MEASUREMENT_COLOR = 0x0f_17_2a
|
|
|
17
21
|
|
|
18
22
|
type PlacementBoxMeasurements = {
|
|
19
23
|
unit: LinearUnit
|
|
24
|
+
metricNotation?: MetricNotation
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
function getMeasurementGuidePoints(width: number, height: number, depth: number) {
|
|
@@ -271,15 +276,15 @@ export function PlacementBox({
|
|
|
271
276
|
renderOrder={998}
|
|
272
277
|
/>
|
|
273
278
|
<MeasurementPill
|
|
274
|
-
label={formatLinearMeasurement(width, measurements.unit)}
|
|
279
|
+
label={formatLinearMeasurement(width, measurements.unit, measurements.metricNotation)}
|
|
275
280
|
position={[0, 0.04, depth / 2 + 0.24]}
|
|
276
281
|
/>
|
|
277
282
|
<MeasurementPill
|
|
278
|
-
label={formatLinearMeasurement(depth, measurements.unit)}
|
|
283
|
+
label={formatLinearMeasurement(depth, measurements.unit, measurements.metricNotation)}
|
|
279
284
|
position={[width / 2 + 0.24, 0.04, 0]}
|
|
280
285
|
/>
|
|
281
286
|
<MeasurementPill
|
|
282
|
-
label={formatLinearMeasurement(height, measurements.unit)}
|
|
287
|
+
label={formatLinearMeasurement(height, measurements.unit, measurements.metricNotation)}
|
|
283
288
|
position={[-width / 2 - 0.24, height / 2, -depth / 2]}
|
|
284
289
|
/>
|
|
285
290
|
</>
|
|
@@ -79,6 +79,7 @@ export const TerrainBrushCursor: React.FC<{
|
|
|
79
79
|
const sampling = useEditor((state) => state.terrainSampling)
|
|
80
80
|
const color = brushRingColor(verb, sampling)
|
|
81
81
|
const unit = useViewer((state) => state.unit)
|
|
82
|
+
const metricNotation = useViewer((state) => state.metricNotation)
|
|
82
83
|
|
|
83
84
|
const lineRef = useRef<Line>(null)
|
|
84
85
|
const centerRef = useRef<Group>(null)
|
|
@@ -195,7 +196,7 @@ export const TerrainBrushCursor: React.FC<{
|
|
|
195
196
|
|
|
196
197
|
const centerHeight = surfaceHeightAt(surface, hit.x, hit.z)
|
|
197
198
|
center.position.set(hit.x, centerHeight + RING_LIFT, hit.z)
|
|
198
|
-
const heightLabel = `H ${formatMeasurement(centerHeight, unit)}`
|
|
199
|
+
const heightLabel = `H ${formatMeasurement(centerHeight, unit, metricNotation)}`
|
|
199
200
|
if (heightRef.current) {
|
|
200
201
|
heightRef.current.style.display = ''
|
|
201
202
|
if (heightLabel !== lastHeightLabelRef.current) {
|
|
@@ -3,12 +3,15 @@ import {
|
|
|
3
3
|
type AnyNode,
|
|
4
4
|
type AnyNodeId,
|
|
5
5
|
applyHeightPatch,
|
|
6
|
+
CeilingNode,
|
|
6
7
|
createTerrainField,
|
|
7
8
|
DoorNode as DoorSchema,
|
|
8
9
|
encodeTerrainField,
|
|
9
10
|
flattenPatch,
|
|
10
11
|
GROUND_SUPPORT_ID,
|
|
12
|
+
initSpaceDetectionSync,
|
|
11
13
|
runAsSingleSceneHistoryStep,
|
|
14
|
+
SlabNode,
|
|
12
15
|
spatialGridManager,
|
|
13
16
|
useScene,
|
|
14
17
|
type WallNode,
|
|
@@ -56,7 +59,7 @@ function seedLevel(walls: WallNode[], extraNodes: AnyNode[] = []) {
|
|
|
56
59
|
parentId: null,
|
|
57
60
|
visible: true,
|
|
58
61
|
metadata: {},
|
|
59
|
-
children: walls.map((wall) => wall.id),
|
|
62
|
+
children: [...walls.map((wall) => wall.id), ...extraNodes.map((node) => node.id)],
|
|
60
63
|
level: 0,
|
|
61
64
|
} as AnyNode,
|
|
62
65
|
],
|
|
@@ -69,6 +72,66 @@ function seedLevel(walls: WallNode[], extraNodes: AnyNode[] = []) {
|
|
|
69
72
|
} as never)
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
// Seeds a site/building/level chain whose terrain field carries a sculpted
|
|
76
|
+
// patch raised to `liftTo` in the far corner — enough for ground drafts to be
|
|
77
|
+
// terrain chains, while the walls under test stand where the ground is 0.
|
|
78
|
+
function seedTerrainLevel(walls: WallNode[], liftTo: number) {
|
|
79
|
+
const field = createTerrainField({ cols: 9, rows: 9, spacing: 1, origin: [-4, -4] })
|
|
80
|
+
const patch = flattenPatch(field, { maxX: -2, maxZ: -2, minX: -4, minZ: -4 }, liftTo)
|
|
81
|
+
if (!patch) throw new Error('Expected terrain patch')
|
|
82
|
+
const terrain = applyHeightPatch(field, patch)
|
|
83
|
+
useScene.setState({
|
|
84
|
+
nodes: Object.fromEntries([
|
|
85
|
+
[
|
|
86
|
+
'site_test',
|
|
87
|
+
{
|
|
88
|
+
id: 'site_test',
|
|
89
|
+
type: 'site',
|
|
90
|
+
object: 'node',
|
|
91
|
+
parentId: null,
|
|
92
|
+
visible: true,
|
|
93
|
+
metadata: {},
|
|
94
|
+
children: ['building_test'],
|
|
95
|
+
terrain: encodeTerrainField(terrain),
|
|
96
|
+
} as unknown as AnyNode,
|
|
97
|
+
],
|
|
98
|
+
[
|
|
99
|
+
'building_test',
|
|
100
|
+
{
|
|
101
|
+
id: 'building_test',
|
|
102
|
+
type: 'building',
|
|
103
|
+
object: 'node',
|
|
104
|
+
parentId: 'site_test',
|
|
105
|
+
visible: true,
|
|
106
|
+
metadata: {},
|
|
107
|
+
children: [LEVEL_ID],
|
|
108
|
+
position: [0, 0, 0],
|
|
109
|
+
rotation: [0, 0, 0],
|
|
110
|
+
} as AnyNode,
|
|
111
|
+
],
|
|
112
|
+
[
|
|
113
|
+
LEVEL_ID,
|
|
114
|
+
{
|
|
115
|
+
id: LEVEL_ID,
|
|
116
|
+
type: 'level',
|
|
117
|
+
object: 'node',
|
|
118
|
+
parentId: 'building_test',
|
|
119
|
+
visible: true,
|
|
120
|
+
metadata: {},
|
|
121
|
+
children: walls.map((wall) => wall.id),
|
|
122
|
+
level: 0,
|
|
123
|
+
baseElevation: 0,
|
|
124
|
+
height: 2.5,
|
|
125
|
+
} as AnyNode,
|
|
126
|
+
],
|
|
127
|
+
...walls.map((wall) => [wall.id, wall] as const),
|
|
128
|
+
]),
|
|
129
|
+
rootNodeIds: ['site_test' as AnyNodeId],
|
|
130
|
+
dirtyNodes: new Set(),
|
|
131
|
+
collections: {},
|
|
132
|
+
} as never)
|
|
133
|
+
}
|
|
134
|
+
|
|
72
135
|
function levelWalls(): WallNode[] {
|
|
73
136
|
return Object.values(useScene.getState().nodes).filter(
|
|
74
137
|
(node): node is WallNode => node?.type === 'wall',
|
|
@@ -109,7 +172,9 @@ describe('createWallOnCurrentLevel', () => {
|
|
|
109
172
|
expect(levelWalls()).toHaveLength(2)
|
|
110
173
|
})
|
|
111
174
|
|
|
112
|
-
test('committed wall preserves the ghost construction elevation on
|
|
175
|
+
test('committed wall preserves the ghost construction elevation on terrain', () => {
|
|
176
|
+
seedTerrainLevel([makeWall([0, 0], [4, 0], 'wall_a')], 1.75)
|
|
177
|
+
|
|
113
178
|
const created = createWallOnCurrentLevel([2, 2], [3, 2], {
|
|
114
179
|
supportCap: 1.75,
|
|
115
180
|
preferredSupportSlabId: GROUND_SUPPORT_ID,
|
|
@@ -133,6 +198,69 @@ describe('createWallOnCurrentLevel', () => {
|
|
|
133
198
|
expect(support.elevation).toBe(1.75)
|
|
134
199
|
})
|
|
135
200
|
|
|
201
|
+
test('a flat-ground draft (no sculpted terrain) commits plane-bound', () => {
|
|
202
|
+
// Pointing at bare ground freezes a GROUND construction plane at 0. With
|
|
203
|
+
// no terrain field in the scene that plane is just the backdrop — none of
|
|
204
|
+
// the draft options may reach the committed node: no stamped height, no
|
|
205
|
+
// persisted ground host (a slab drawn later must lift the wall), no
|
|
206
|
+
// election cap.
|
|
207
|
+
const created = createWallOnCurrentLevel([2, 2], [3, 2], {
|
|
208
|
+
supportCap: 0,
|
|
209
|
+
preferredSupportSlabId: GROUND_SUPPORT_ID,
|
|
210
|
+
constructionElevation: 0,
|
|
211
|
+
constructionHeight: 2.5,
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
expect(created).not.toBeNull()
|
|
215
|
+
expect(created?.height).toBeUndefined()
|
|
216
|
+
expect(created?.supportOffset).toBeUndefined()
|
|
217
|
+
expect(created?.supportSlabId).toBeUndefined()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('a wall started on a slab stays plane-bound (no stamped height or offset)', () => {
|
|
221
|
+
const slab = SlabNode.parse({
|
|
222
|
+
id: 'slab_floor',
|
|
223
|
+
parentId: LEVEL_ID,
|
|
224
|
+
polygon: [
|
|
225
|
+
[-1, -1],
|
|
226
|
+
[5, -1],
|
|
227
|
+
[5, 5],
|
|
228
|
+
[-1, 5],
|
|
229
|
+
],
|
|
230
|
+
elevation: 0.05,
|
|
231
|
+
thickness: 0.05,
|
|
232
|
+
})
|
|
233
|
+
seedLevel([makeWall([0, 0], [4, 0], 'wall_a')], [slab])
|
|
234
|
+
|
|
235
|
+
// Mirrors the 3D tool's first click on the slab top: frozen plane at the
|
|
236
|
+
// slab elevation, ghost drawn at the level height. None of it may reach
|
|
237
|
+
// the committed node — plane-bound is the default.
|
|
238
|
+
const created = createWallOnCurrentLevel([2, 2], [3, 2], {
|
|
239
|
+
supportCap: 0.05,
|
|
240
|
+
preferredSupportSlabId: slab.id,
|
|
241
|
+
constructionElevation: 0.05,
|
|
242
|
+
constructionHeight: 2.55,
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
expect(created).not.toBeNull()
|
|
246
|
+
expect(created?.height).toBeUndefined()
|
|
247
|
+
expect(created?.supportOffset).toBeUndefined()
|
|
248
|
+
expect(created?.supportSlabId).not.toBe(GROUND_SUPPORT_ID)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('a non-ground draft never freezes the ghost height, even at a raised plane', () => {
|
|
252
|
+
const created = createWallOnCurrentLevel([2, 2], [3, 2], {
|
|
253
|
+
supportCap: 1.2,
|
|
254
|
+
preferredSupportSlabId: null,
|
|
255
|
+
constructionElevation: 1.2,
|
|
256
|
+
constructionHeight: 2.5,
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
expect(created).not.toBeNull()
|
|
260
|
+
expect(created?.height).toBeUndefined()
|
|
261
|
+
expect(created?.supportOffset).toBeUndefined()
|
|
262
|
+
})
|
|
263
|
+
|
|
136
264
|
test('2D terrain construction options freeze the first-point elevation and wall height', () => {
|
|
137
265
|
const field = createTerrainField({ cols: 5, rows: 5, spacing: 1, origin: [-2, -2] })
|
|
138
266
|
const patch = flattenPatch(field, { minX: -2, minZ: -2, maxX: 2, maxZ: 2 }, 1.5)
|
|
@@ -168,6 +296,7 @@ describe('createWallOnCurrentLevel', () => {
|
|
|
168
296
|
metadata: {},
|
|
169
297
|
children: [],
|
|
170
298
|
level: 0,
|
|
299
|
+
baseElevation: 0,
|
|
171
300
|
height: 3,
|
|
172
301
|
} as AnyNode
|
|
173
302
|
const nodes = Object.fromEntries([site, building, level].map((node) => [node.id, node]))
|
|
@@ -261,6 +390,82 @@ describe('createWallOnCurrentLevel', () => {
|
|
|
261
390
|
expect(created).not.toBeNull()
|
|
262
391
|
expect(useScene.temporal.getState().pastStates.length - before).toBe(1)
|
|
263
392
|
})
|
|
393
|
+
|
|
394
|
+
test('a crossing splits both the host and the inserted wall in one undo step', () => {
|
|
395
|
+
const before = useScene.temporal.getState().pastStates.length
|
|
396
|
+
|
|
397
|
+
const created = createWallOnCurrentLevel([2, -2], [2, 2])
|
|
398
|
+
|
|
399
|
+
expect(created?.start).toEqual([2, 0])
|
|
400
|
+
expect(created?.end).toEqual([2, 2])
|
|
401
|
+
expect(useScene.getState().nodes['wall_a' as AnyNodeId]).toBeUndefined()
|
|
402
|
+
expect(levelWalls()).toHaveLength(4)
|
|
403
|
+
expect(useScene.temporal.getState().pastStates.length - before).toBe(1)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
test('a room divider splits customized auto slabs and ceilings', () => {
|
|
407
|
+
const walls = [
|
|
408
|
+
makeWall([0, 0], [8, 0], 'wall_bottom'),
|
|
409
|
+
makeWall([8, 0], [8, 6], 'wall_right'),
|
|
410
|
+
makeWall([8, 6], [0, 6], 'wall_top'),
|
|
411
|
+
makeWall([0, 6], [0, 0], 'wall_left'),
|
|
412
|
+
]
|
|
413
|
+
const slab = SlabNode.parse({
|
|
414
|
+
id: 'slab_auto',
|
|
415
|
+
parentId: LEVEL_ID,
|
|
416
|
+
polygon: [
|
|
417
|
+
[0, 0],
|
|
418
|
+
[8, 0],
|
|
419
|
+
[8, 6],
|
|
420
|
+
[0, 6],
|
|
421
|
+
],
|
|
422
|
+
elevation: 0.18,
|
|
423
|
+
thickness: 0.32,
|
|
424
|
+
autoFromWalls: true,
|
|
425
|
+
})
|
|
426
|
+
const ceiling = CeilingNode.parse({
|
|
427
|
+
id: 'ceiling_auto',
|
|
428
|
+
parentId: LEVEL_ID,
|
|
429
|
+
polygon: slab.polygon,
|
|
430
|
+
autoFromWalls: true,
|
|
431
|
+
})
|
|
432
|
+
seedLevel(walls, [slab, ceiling])
|
|
433
|
+
const stopDetection = initSpaceDetectionSync(useScene, useEditor)
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
expect(createWallOnCurrentLevel([4, 0], [4, 6])).not.toBeNull()
|
|
437
|
+
|
|
438
|
+
const nodes = Object.values(useScene.getState().nodes)
|
|
439
|
+
const slabs = nodes.filter((node) => node.type === 'slab')
|
|
440
|
+
const ceilings = nodes.filter((node) => node.type === 'ceiling')
|
|
441
|
+
expect(slabs).toHaveLength(2)
|
|
442
|
+
expect(ceilings).toHaveLength(2)
|
|
443
|
+
expect(
|
|
444
|
+
slabs.every(
|
|
445
|
+
(node) => node.autoFromWalls && node.elevation === 0.18 && node.thickness === 0.32,
|
|
446
|
+
),
|
|
447
|
+
).toBe(true)
|
|
448
|
+
expect(ceilings.every((node) => node.autoFromWalls)).toBe(true)
|
|
449
|
+
} finally {
|
|
450
|
+
stopDetection()
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
test('close crossings reject the whole insertion without mutating the scene', () => {
|
|
455
|
+
seedLevel([
|
|
456
|
+
makeWall([2, -2], [2, 2], 'wall_close_a'),
|
|
457
|
+
makeWall([2.0055, -2], [2.0055, 2], 'wall_close_b'),
|
|
458
|
+
])
|
|
459
|
+
useScene.temporal.getState().clear()
|
|
460
|
+
const beforeNodes = useScene.getState().nodes
|
|
461
|
+
|
|
462
|
+
const created = createWallOnCurrentLevel([0, 0], [4, 0])
|
|
463
|
+
|
|
464
|
+
expect(created).toBeNull()
|
|
465
|
+
expect(useScene.getState().nodes).toBe(beforeNodes)
|
|
466
|
+
expect(levelWalls()).toHaveLength(2)
|
|
467
|
+
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
|
|
468
|
+
})
|
|
264
469
|
})
|
|
265
470
|
|
|
266
471
|
describe('resolveEndpointWallSplit', () => {
|