@pascal-app/editor 1.0.7 → 1.1.0

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.
Files changed (136) hide show
  1. package/package.json +7 -7
  2. package/src/components/editor/custom-camera-controls.tsx +90 -34
  3. package/src/components/editor/duplicate-node.ts +2 -1
  4. package/src/components/editor/first-person-controls.tsx +5 -7
  5. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  6. package/src/components/editor/fit-scene-framing.ts +142 -0
  7. package/src/components/editor/floating-action-menu.tsx +4 -2
  8. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  9. package/src/components/editor/floor-region-controls.tsx +2 -2
  10. package/src/components/editor/floorplan-panel.tsx +173 -8
  11. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  12. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  13. package/src/components/editor/footprint-height-handle.tsx +1 -1
  14. package/src/components/editor/group-transform-shared.ts +3 -1
  15. package/src/components/editor/index.tsx +20 -0
  16. package/src/components/editor/measurement-pill.tsx +1 -1
  17. package/src/components/editor/node-arrow-handles.tsx +1 -1
  18. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -2
  19. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  20. package/src/components/editor/quick-measurement-card.tsx +1 -1
  21. package/src/components/editor/room-controls.tsx +3 -2
  22. package/src/components/editor/room-handles-2d.tsx +2 -1
  23. package/src/components/editor/room-handles.tsx +3 -3
  24. package/src/components/editor/room-labels.tsx +4 -2
  25. package/src/components/editor/site-edge-labels.tsx +6 -2
  26. package/src/components/editor/wall-measurement-label.tsx +30 -14
  27. package/src/components/editor/wall-move-side-handles.tsx +10 -6
  28. package/src/components/editor/wall-region-handles.tsx +2 -2
  29. package/src/components/editor/wall-snap-beacon-layer.tsx +53 -32
  30. package/src/components/editor-2d/floorplan-group-move.tsx +1 -1
  31. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +12 -12
  32. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +1 -2
  33. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  34. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +22 -4
  35. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  36. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  37. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  38. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  39. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +22 -0
  40. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +14 -2
  41. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  42. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  43. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  44. package/src/components/tools/distribution/distribution-run-tool.tsx +150 -32
  45. package/src/components/tools/item/move-tool.tsx +1 -6
  46. package/src/components/tools/item/use-placement-coordinator.tsx +1 -1
  47. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  48. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  49. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  50. package/src/components/tools/shared/placement-box.tsx +1 -5
  51. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  52. package/src/components/tools/shared/segment-angle.ts +0 -4
  53. package/src/components/tools/tool-manager.tsx +1 -24
  54. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  55. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  56. package/src/components/ui/controls/slider-control.tsx +18 -6
  57. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  58. package/src/components/ui/helpers/helper-manager.test.tsx +53 -0
  59. package/src/components/ui/helpers/helper-manager.tsx +54 -0
  60. package/src/components/ui/panels/floor-foundation-panel.tsx +1 -1
  61. package/src/components/ui/panels/floor-opening-panel.tsx +1 -1
  62. package/src/components/ui/panels/mobile-selection-bar.tsx +28 -24
  63. package/src/components/ui/panels/multi-height-mode.tsx +3 -3
  64. package/src/components/ui/panels/multi-selection-panel.tsx +1 -1
  65. package/src/components/ui/panels/panel-manager.tsx +4 -2
  66. package/src/components/ui/panels/panel-wrapper.tsx +41 -18
  67. package/src/components/ui/panels/room-construction-rows.tsx +1 -1
  68. package/src/components/ui/panels/room-openings-field.tsx +1 -1
  69. package/src/components/ui/panels/room-panel.tsx +1 -1
  70. package/src/components/ui/right-stack.test.ts +13 -6
  71. package/src/components/ui/right-stack.tsx +21 -10
  72. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +1 -1
  74. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1 -8
  75. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +1 -1
  76. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +1 -1
  77. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +1 -1
  78. package/src/components/viewer/viewer-rooms.tsx +1 -1
  79. package/src/i18n/locales/en/messages.po +856 -124
  80. package/src/i18n/locales/en/messages.ts +1 -1
  81. package/src/i18n/locales/en-XA/messages.ts +1 -1
  82. package/src/i18n/locales/es/messages.po +2087 -257
  83. package/src/i18n/locales/fr/messages.po +2087 -257
  84. package/src/i18n/locales/ja/messages.po +2087 -257
  85. package/src/i18n/locales/zh-CN/messages.po +2087 -257
  86. package/src/index.tsx +74 -47
  87. package/src/lib/contextual-help.ts +6 -0
  88. package/src/lib/elevation-guides.ts +21 -6
  89. package/src/lib/elevator-support.ts +1 -72
  90. package/src/lib/floor-footprints.test.ts +1 -1
  91. package/src/lib/floor-footprints.ts +1 -9
  92. package/src/lib/floor-foundation-support.test.ts +8 -3
  93. package/src/lib/floor-opening-draft.ts +1 -1
  94. package/src/lib/floor-region-session.ts +1 -1
  95. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  96. package/src/lib/gesture-lifecycle.ts +3 -1
  97. package/src/lib/interaction/scope.ts +1 -0
  98. package/src/lib/layer-thickness.ts +1 -1
  99. package/src/lib/level-selection.test.ts +18 -2
  100. package/src/lib/level-selection.ts +31 -8
  101. package/src/lib/linear-display.ts +1 -1
  102. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  103. package/src/lib/measurement-label.test.ts +1 -1
  104. package/src/lib/measurements.test.ts +8 -7
  105. package/src/lib/measurements.ts +1 -218
  106. package/src/lib/node-action-movement.test.tsx +115 -0
  107. package/src/lib/node-action-movement.ts +2 -0
  108. package/src/lib/placement-metadata.ts +4 -0
  109. package/src/lib/point-ask/describe-target.ts +6 -8
  110. package/src/lib/point-ask/region.ts +1 -1
  111. package/src/lib/print-shell-compiler.ts +12 -1
  112. package/src/lib/print-wall-solids.test.ts +11 -0
  113. package/src/lib/print-wall-solids.ts +9 -0
  114. package/src/lib/room-assembly-overlay.ts +1 -1
  115. package/src/lib/room-built-on.test.ts +2 -5
  116. package/src/lib/room-built-on.ts +1 -15
  117. package/src/lib/room-construction-commands.ts +3 -2
  118. package/src/lib/room-controls-phase6.test.ts +1 -1
  119. package/src/lib/room-handle-drag.ts +6 -25
  120. package/src/lib/room-push-dimensions.test.ts +1 -1
  121. package/src/lib/scene-bounds.test.ts +144 -0
  122. package/src/lib/scene-bounds.ts +79 -15
  123. package/src/lib/scene-clipboard.ts +2 -1
  124. package/src/lib/snapping-mode.test.ts +23 -1
  125. package/src/lib/viewer-selection.ts +1 -1
  126. package/src/lib/wall-region-list.ts +3 -1
  127. package/src/lib/window-interaction.test.ts +4 -2
  128. package/src/store/use-editor.tsx +5 -0
  129. package/src/store/use-placement-typing.test.ts +165 -0
  130. package/src/store/use-placement-typing.ts +186 -0
  131. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  132. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  133. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  134. package/src/lib/door-interaction.ts +0 -102
  135. package/src/lib/measurement-label.ts +0 -68
  136. package/src/lib/window-interaction.ts +0 -142
@@ -16,12 +16,15 @@ import {
16
16
  type Point,
17
17
  planWallDivision,
18
18
  resolveMovedWallSupportSlabPatch,
19
+ roomFloorElevation,
20
+ roomOwnPlate,
19
21
  runAsSingleSceneHistoryStep,
20
22
  SEPARATOR_JOIN_REACH,
21
23
  type SeparatorNode,
22
24
  type SeparatorSpan,
23
25
  type StructureNodes,
24
26
  type StructurePlan,
27
+ separatorFloorElevation,
25
28
  setRoomFloorConstruction,
26
29
  setWallGeometry,
27
30
  setZoneEdges,
@@ -46,7 +49,6 @@ import { clientToPlan } from './floorplan/plan-coords'
46
49
  import { beginGesture } from './gesture-lifecycle'
47
50
  import { isHistoryShortcut } from './history'
48
51
  import { MEZZANINE_FAILED_MESSAGE, mezzanineConflictMessage } from './mezzanine-messages'
49
- import { roomOwnPlate } from './room-built-on'
50
52
  import {
51
53
  mezzanineEdgeDimensions,
52
54
  type RoomDimension,
@@ -146,19 +148,6 @@ export const useRoomHandleDrag = create<{ drag: RoomHandleDrag | null }>(() => (
146
148
 
147
149
  type Nodes = Record<string, AnyNode>
148
150
 
149
- /** The walking surface of a room: its own elevation, else its plate's. */
150
- export function roomFloorElevation(nodes: Nodes, zoneId: string): number {
151
- const zone = nodes[zoneId]
152
- if (zone?.type !== 'zone') return 0
153
- const own = roomOwnPlate(nodes, zoneId)
154
- if (own) return own.elevation
155
- if (typeof zone.floor?.elevation === 'number') return zone.floor.elevation
156
- const plate = Object.values(nodes).find(
157
- (node) => node.type === 'slab' && node.zoneIds?.includes(zoneId),
158
- )
159
- return plate?.type === 'slab' ? (plate.elevation ?? 0.05) : 0.05
160
- }
161
-
162
151
  /**
163
152
  * The stretch of a wall a push moves: `[0, 1]` is the whole wall (its
164
153
  * neighbours follow through the junction planner), anything shorter is split
@@ -206,7 +195,7 @@ function wallPushHandle(
206
195
  const offsets = getWallFaceOffsets(wall)
207
196
  const face = side === 'a' ? offsets.a : -offsets.b
208
197
  const offset = Math.max(face + HANDLE_OFFSET, HANDLE_MIN_OFFSET)
209
- const wallHeight = getWallEffectiveHeightForNodes(wall, nodes)
198
+ const wallHeight = getWallEffectiveHeightForNodes(wall, nodes, 0.5)
210
199
  return {
211
200
  key,
212
201
  wallId: wall.id,
@@ -396,14 +385,6 @@ export function separatorPushHandles(separator: SeparatorNode, floor = 0): WallP
396
385
  })
397
386
  }
398
387
 
399
- /** The floor an invisible wall lies on: the walking surface of a room it closes, else 0. */
400
- export function separatorFloorElevation(nodes: Nodes, separatorId: string): number {
401
- const zone = Object.values(nodes).find(
402
- (node) => node.type === 'zone' && node.boundarySeparatorIds.includes(separatorId),
403
- )
404
- return zone ? roomFloorElevation(nodes, zone.id) : 0
405
- }
406
-
407
388
  /**
408
389
  * Snaps a push the way the wall move always has: the wall's reference line
409
390
  * (not the travel) lands on the grid lattice along its normal, so an off-grid
@@ -642,7 +623,7 @@ function previewPushPlan(
642
623
  start: change.node.start,
643
624
  end: change.node.end,
644
625
  thickness: change.node.thickness ?? 0.1,
645
- height: getWallEffectiveHeightForNodes(change.node, nodes),
626
+ height: getWallEffectiveHeightForNodes(change.node, nodes, 0),
646
627
  },
647
628
  ]
648
629
  : []
@@ -1403,7 +1384,7 @@ export function beginWallGroupPush(wallIds: readonly string[], levelId: string):
1403
1384
  {
1404
1385
  wall,
1405
1386
  base,
1406
- top: base + getWallEffectiveHeightForNodes(wall, nodes),
1387
+ top: base + getWallEffectiveHeightForNodes(wall, nodes, 0),
1407
1388
  normal: wallPushNormal(wall),
1408
1389
  rooms: pushDimensionRooms(nodes, levelId, { wallId: wall.id, t0: 0, t1: 1 }, base),
1409
1390
  },
@@ -2,11 +2,11 @@ import { describe, expect, test } from 'bun:test'
2
2
  import {
3
3
  type AnyNode,
4
4
  createRoomTopologyIndex,
5
+ formatLinearMeasurement,
5
6
  LevelNode,
6
7
  type Point,
7
8
  WallNode,
8
9
  } from '@pascal-app/core'
9
- import { formatLinearMeasurement } from './measurements'
10
10
  import {
11
11
  DIMENSION_FLOOR_LIFT,
12
12
  mezzanineEdgeDimensions,
@@ -172,6 +172,150 @@ describe('computeSceneBoundsXZ', () => {
172
172
  expect(bounds!.center).toEqual([2, 2])
173
173
  })
174
174
 
175
+ describe('ancestor transforms', () => {
176
+ function makeBuilding(
177
+ id: string,
178
+ position: [number, number, number],
179
+ rotationY = 0,
180
+ parentId: string | null = null,
181
+ ): AnyNode {
182
+ return {
183
+ object: 'node',
184
+ id,
185
+ type: 'building',
186
+ parentId,
187
+ visible: true,
188
+ metadata: {},
189
+ children: [],
190
+ position,
191
+ rotation: [0, rotationY, 0],
192
+ } as unknown as AnyNode
193
+ }
194
+
195
+ function makeLevel(id: string, parentId: string): AnyNode {
196
+ return {
197
+ object: 'node',
198
+ id,
199
+ type: 'level',
200
+ parentId,
201
+ visible: true,
202
+ metadata: {},
203
+ children: [],
204
+ level: 0,
205
+ } as unknown as AnyNode
206
+ }
207
+
208
+ function child(node: AnyNode, parentId: string): AnyNode {
209
+ return { ...node, parentId } as AnyNode
210
+ }
211
+
212
+ function expectClose(actual: [number, number], expected: [number, number]) {
213
+ expect(actual[0]).toBeCloseTo(expected[0], 9)
214
+ expect(actual[1]).toBeCloseTo(expected[1], 9)
215
+ }
216
+
217
+ test('carries building-local walls and zones through a translated building', () => {
218
+ const nodes = [
219
+ makeBuilding('building_a', [100, 0, 100]),
220
+ makeLevel('level_a', 'building_a'),
221
+ child(makeWall([-5, -4], [5, -4]), 'level_a'),
222
+ child(
223
+ makeZone([
224
+ [-5, -4],
225
+ [5, -4],
226
+ [5, 4],
227
+ [-5, 4],
228
+ ]),
229
+ 'level_a',
230
+ ),
231
+ ]
232
+ const bounds = computeSceneBoundsXZ(nodes)!
233
+ expectClose(bounds.min, [95, 96])
234
+ expectClose(bounds.max, [105, 104])
235
+ expectClose(bounds.center, [100, 100])
236
+ })
237
+
238
+ test('turns building-local geometry by the building yaw', () => {
239
+ // three.js Y rotation by +90°: local (x, z) → (z, −x).
240
+ const nodes = [
241
+ makeBuilding('building_r', [10, 0, 0], Math.PI / 2),
242
+ makeLevel('level_r', 'building_r'),
243
+ child(makeWall([0, 0], [8, 0]), 'level_r'),
244
+ child(makeWall([8, 0], [8, 2]), 'level_r'),
245
+ ]
246
+ const bounds = computeSceneBoundsXZ(nodes)!
247
+ expectClose(bounds.min, [10, -8])
248
+ expectClose(bounds.max, [12, 0])
249
+ expectClose(bounds.size, [2, 8])
250
+ })
251
+
252
+ test('composes nested positioned and rotated ancestors', () => {
253
+ const item = {
254
+ object: 'node',
255
+ id: 'item_host',
256
+ type: 'item',
257
+ parentId: 'level_n',
258
+ visible: true,
259
+ metadata: {},
260
+ children: [],
261
+ position: [2, 0, 0],
262
+ rotation: [0, Math.PI / 2, 0],
263
+ } as unknown as AnyNode
264
+ const hosted = {
265
+ object: 'node',
266
+ id: 'item_hosted',
267
+ type: 'item',
268
+ parentId: 'item_host',
269
+ visible: true,
270
+ metadata: {},
271
+ children: [],
272
+ position: [1, 0, 0],
273
+ rotation: [0, 0, 0],
274
+ } as unknown as AnyNode
275
+ // Building at (50, 50) turned 90°: the host lands at (50, 48), and the
276
+ // hosted item (1, 0) under a total 180° yaw lands one metre further −x.
277
+ const nodes = [
278
+ makeBuilding('building_n', [50, 0, 50], Math.PI / 2),
279
+ makeLevel('level_n', 'building_n'),
280
+ item,
281
+ hosted,
282
+ ]
283
+ const bounds = computeSceneBoundsXZ(nodes)!
284
+ expectClose(bounds.min, [49, 48])
285
+ expectClose(bounds.max, [50, 50])
286
+ })
287
+
288
+ test('leaves geometry under an untransformed building unchanged', () => {
289
+ const nodes = [
290
+ makeBuilding('building_0', [0, 0, 0]),
291
+ makeLevel('level_0', 'building_0'),
292
+ child(makeWall([0, 0], [4, 0]), 'level_0'),
293
+ child(makeWall([4, 0], [4, 3]), 'level_0'),
294
+ ]
295
+ const bounds = computeSceneBoundsXZ(nodes)!
296
+ expect(bounds.min).toEqual([0, 0])
297
+ expect(bounds.max).toEqual([4, 3])
298
+ })
299
+ })
300
+
301
+ test('a door on a wall is bounded by the wall, not its wall-local position', () => {
302
+ const wall = makeWall([50, 50], [60, 50])
303
+ const door = {
304
+ object: 'node',
305
+ id: 'door_1',
306
+ type: 'door',
307
+ parentId: wall.id,
308
+ visible: true,
309
+ metadata: {},
310
+ position: [2, 1, 0],
311
+ } as unknown as AnyNode
312
+ const bounds = computeSceneBoundsXZ([wall, door])
313
+ expect(bounds).not.toBeNull()
314
+ expect(bounds!.min[0]).toBe(50)
315
+ expect(bounds!.max[0]).toBe(60)
316
+ expect(bounds!.center).toEqual([55, 50])
317
+ })
318
+
175
319
  test('skips non-finite coordinates', () => {
176
320
  const nodes: AnyNode[] = [makeWall([Number.NaN, 0], [4, 2]), makeWall([0, 0], [1, 1])]
177
321
  const bounds = computeSceneBoundsXZ(nodes)
@@ -11,6 +11,7 @@
11
11
  * tree and derives an axis-aligned bounding box on the XZ (plan) plane.
12
12
  */
13
13
 
14
+ import { turnPlanPoint, yawOf } from '@pascal-app/core'
14
15
  import type { AnyNode } from '@pascal-app/core/schema'
15
16
 
16
17
  export type SceneBoundsXZ = {
@@ -27,13 +28,19 @@ export type SceneBoundsXZ = {
27
28
  // A very small guard against degenerate bounds (e.g. a single wall of zero length).
28
29
  const MIN_BOUNDS_EXTENT = 0.0001
29
30
 
30
- function extendPoint(
31
- acc: { minX: number; minZ: number; maxX: number; maxZ: number; hasPoint: boolean },
32
- x: unknown,
33
- z: unknown,
34
- ): void {
35
- if (typeof x !== 'number' || typeof z !== 'number') return
36
- if (!(Number.isFinite(x) && Number.isFinite(z))) return
31
+ /** A node's plan frame in world XZ: local (x, z) → origin + turnPlanPoint(x, z, yaw). */
32
+ type PlanFrame = { x: number; z: number; yaw: number }
33
+
34
+ const WORLD_FRAME: PlanFrame = { x: 0, z: 0, yaw: 0 }
35
+
36
+ type BoundsAcc = { minX: number; minZ: number; maxX: number; maxZ: number; hasPoint: boolean }
37
+
38
+ function extendPoint(acc: BoundsAcc, frame: PlanFrame, localX: unknown, localZ: unknown): void {
39
+ if (typeof localX !== 'number' || typeof localZ !== 'number') return
40
+ if (!(Number.isFinite(localX) && Number.isFinite(localZ))) return
41
+ const [tx, tz] = frame.yaw === 0 ? [localX, localZ] : turnPlanPoint(localX, localZ, frame.yaw)
42
+ const x = frame.x + tx
43
+ const z = frame.z + tz
37
44
  if (x < acc.minX) acc.minX = x
38
45
  if (x > acc.maxX) acc.maxX = x
39
46
  if (z < acc.minZ) acc.minZ = z
@@ -50,6 +57,9 @@ function extendPoint(
50
57
  * - `polygon` → zone, slab, site-boundary polygons.
51
58
  * - `position` → building/item/door/window position; uses [x, z] only.
52
59
  *
60
+ * Each point is in its parent's frame, so it is carried to world XZ through
61
+ * the ancestors' `position` and `rotation` yaw (a moved or turned building).
62
+ *
53
63
  * Site-node polygons are intentionally excluded when they are the default
54
64
  * 30×30 bootstrap polygon — otherwise a brand-new empty scene would frame
55
65
  * an empty square around the origin. We still include site polygons that
@@ -63,7 +73,7 @@ export function computeSceneBoundsXZ(
63
73
  const list: AnyNode[] = Array.isArray(nodes) ? nodes : Object.values(nodes)
64
74
  if (list.length === 0) return null
65
75
 
66
- const acc = {
76
+ const acc: BoundsAcc = {
67
77
  minX: Number.POSITIVE_INFINITY,
68
78
  minZ: Number.POSITIVE_INFINITY,
69
79
  maxX: Number.NEGATIVE_INFINITY,
@@ -71,15 +81,24 @@ export function computeSceneBoundsXZ(
71
81
  hasPoint: false,
72
82
  }
73
83
 
84
+ const byId = new Map<string, AnyNode>()
85
+ for (const node of list) {
86
+ if (node && typeof node === 'object' && typeof node.id === 'string') byId.set(node.id, node)
87
+ }
88
+ const frameOf = createFrameResolver(byId)
89
+
74
90
  for (const node of list) {
75
91
  if (!node || typeof node !== 'object') continue
76
92
  const anyNode = node as unknown as Record<string, unknown>
93
+ // A node's own fields live in its parent's frame (a building's walls are
94
+ // building-local), so every point goes through the ancestors' transforms.
95
+ const frame = frameOf(node.parentId ?? null)
77
96
 
78
97
  // Wall / fence endpoints in level coordinates.
79
98
  const start = anyNode.start as unknown
80
99
  const end = anyNode.end as unknown
81
- if (Array.isArray(start) && start.length >= 2) extendPoint(acc, start[0], start[1])
82
- if (Array.isArray(end) && end.length >= 2) extendPoint(acc, end[0], end[1])
100
+ if (Array.isArray(start) && start.length >= 2) extendPoint(acc, frame, start[0], start[1])
101
+ if (Array.isArray(end) && end.length >= 2) extendPoint(acc, frame, end[0], end[1])
83
102
 
84
103
  // Zone / slab polygons (and explicit polygon-shaped site boundaries).
85
104
  const polygon = anyNode.polygon as unknown
@@ -88,7 +107,7 @@ export function computeSceneBoundsXZ(
88
107
  // points under `polygon.points` (a discriminated PropertyLineData shape).
89
108
  for (const point of polygon) {
90
109
  if (Array.isArray(point) && point.length >= 2) {
91
- extendPoint(acc, point[0], point[1])
110
+ extendPoint(acc, frame, point[0], point[1])
92
111
  }
93
112
  }
94
113
  } else if (
@@ -105,16 +124,18 @@ export function computeSceneBoundsXZ(
105
124
  } else {
106
125
  for (const point of points) {
107
126
  if (Array.isArray(point) && point.length >= 2) {
108
- extendPoint(acc, point[0], point[1])
127
+ extendPoint(acc, frame, point[0], point[1])
109
128
  }
110
129
  }
111
130
  }
112
131
  }
113
132
 
114
- // Position on the XZ plane (3D position = [x, y, z]).
133
+ // Position on the XZ plane (3D position = [x, y, z]). A node hosted on a
134
+ // wall (door, window, wall item) has a wall-local position, and the
135
+ // wall's endpoints already bound it.
115
136
  const position = anyNode.position as unknown
116
- if (Array.isArray(position) && position.length >= 3) {
117
- extendPoint(acc, position[0], position[2])
137
+ if (Array.isArray(position) && position.length >= 3 && !isWallHosted(node, byId)) {
138
+ extendPoint(acc, frame, position[0], position[2])
118
139
  }
119
140
  }
120
141
 
@@ -146,6 +167,49 @@ export function computeSceneBoundsXZ(
146
167
  }
147
168
  }
148
169
 
170
+ /**
171
+ * World plan frame of each node, composed down the parent chain the way the
172
+ * renderers nest groups: a node with a `position` (building, item) translates
173
+ * its children and turns them by its `rotation` yaw; nodes without one (site,
174
+ * level) pass their parent's frame through.
175
+ */
176
+ function createFrameResolver(byId: Map<string, AnyNode>): (id: string | null) => PlanFrame {
177
+ const cache = new Map<string, PlanFrame>()
178
+ const resolving = new Set<string>()
179
+
180
+ const resolve = (id: string | null): PlanFrame => {
181
+ if (!id) return WORLD_FRAME
182
+ const cached = cache.get(id)
183
+ if (cached) return cached
184
+ const node = byId.get(id)
185
+ if (!node || resolving.has(id)) return WORLD_FRAME
186
+ resolving.add(id)
187
+ const parent = resolve(node.parentId ?? null)
188
+ resolving.delete(id)
189
+
190
+ const anyNode = node as unknown as Record<string, unknown>
191
+ const position = anyNode.position
192
+ let frame = parent
193
+ if (Array.isArray(position) && position.length >= 3) {
194
+ const px = Number.isFinite(position[0]) ? (position[0] as number) : 0
195
+ const pz = Number.isFinite(position[2]) ? (position[2] as number) : 0
196
+ const [tx, tz] = turnPlanPoint(px, pz, parent.yaw)
197
+ frame = { x: parent.x + tx, z: parent.z + tz, yaw: parent.yaw + yawOf(anyNode.rotation) }
198
+ }
199
+ cache.set(id, frame)
200
+ return frame
201
+ }
202
+
203
+ return resolve
204
+ }
205
+
206
+ function isWallHosted(node: AnyNode, byId: Map<string, AnyNode>): boolean {
207
+ const parent = node.parentId ? byId.get(node.parentId) : undefined
208
+ if (!parent) return false
209
+ const anyParent = parent as unknown as Record<string, unknown>
210
+ return Array.isArray(anyParent.start) && Array.isArray(anyParent.end)
211
+ }
212
+
149
213
  /**
150
214
  * Matches the `SiteNode` bootstrap polygon defined in
151
215
  * `packages/core/src/schema/nodes/site.ts` (a 30×30 square at the origin).
@@ -6,6 +6,7 @@ import {
6
6
  generateSceneMaterialId,
7
7
  getArtifactStore,
8
8
  isDerivedNode,
9
+ isNodeDuplicable,
9
10
  isStoreyContent,
10
11
  type LevelNode,
11
12
  nodeRegistry,
@@ -156,7 +157,7 @@ function isClipboardRoot(
156
157
  }
157
158
 
158
159
  function isCopyableRootType(node: AnyNode) {
159
- if (isDerivedNode(node)) return false
160
+ if (isDerivedNode(node) || !isNodeDuplicable(node)) return false
160
161
  if (COPYABLE_ROOT_TYPES.has(node.type)) return true
161
162
  const definition = nodeRegistry.get(node.type)
162
163
  return !!definition && definition.capabilities?.duplicable !== false
@@ -106,6 +106,8 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
106
106
  roof: 'structural',
107
107
  zone: 'structural',
108
108
  block: 'structural',
109
+ door: 'item',
110
+ window: 'item',
109
111
  }
110
112
  const profileOf = (t: string) => declared[t]
111
113
  const profileOfNode = (id: string) =>
@@ -173,7 +175,7 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
173
175
  })
174
176
 
175
177
  it('an undeclared kind (no snapProfile) gets no snap context', () => {
176
- expect(ctx({ kind: 'moving', nodeType: 'door' })).toBeNull()
178
+ expect(ctx({ kind: 'moving', nodeType: 'unknown_plugin_kind' })).toBeNull()
177
179
  expect(ctx({ kind: 'idle' }, 'build', 'shelf')).toBeNull()
178
180
  })
179
181
 
@@ -205,4 +207,24 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
205
207
  }),
206
208
  ).toBe('polygon')
207
209
  })
210
+ it('moving scope resolves active snap context when moving a window or door', () => {
211
+ expect(
212
+ snapContextOf({
213
+ scope: { kind: 'moving', nodeType: 'window' },
214
+ mode: 'select',
215
+ tool: null,
216
+ profileOf,
217
+ profileOfNode,
218
+ }),
219
+ ).toBe('item')
220
+ expect(
221
+ snapContextOf({
222
+ scope: { kind: 'moving', nodeType: 'door' },
223
+ mode: 'select',
224
+ tool: null,
225
+ profileOf,
226
+ profileOfNode,
227
+ }),
228
+ ).toBe('item')
229
+ })
208
230
  })
@@ -11,6 +11,7 @@ import {
11
11
  type LevelNode,
12
12
  type NodeEvent,
13
13
  resolveSelectionProxyId,
14
+ roomFloorElevation,
14
15
  sceneRegistry,
15
16
  type UnitNode,
16
17
  type ZoneNode,
@@ -29,7 +30,6 @@ import {
29
30
  } from 'three'
30
31
  import { useBelowGrade } from './below-grade'
31
32
  import { nodeDisplayLabel } from './node-display-label'
32
- import { roomFloorElevation } from './room-handle-drag'
33
33
  import { type DescriptorTranslator, displayRoomName } from './room-name-messages'
34
34
  import { RoomSelectionIndex, type RoomSelectionRecord, resolveRoomHit } from './room-selection'
35
35
  import type { SelectionModifierKeys } from './selection-routing'
@@ -1,7 +1,10 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  getCatalogMaterialById,
4
+ getLinearUnitLabel,
4
5
  getMaterialSwatch,
6
+ type LinearUnit,
7
+ metersToLinearUnit,
5
8
  parseMaterialRef,
6
9
  roomSideFaces,
7
10
  type SceneMaterial,
@@ -11,7 +14,6 @@ import {
11
14
  type WallFaceRegion,
12
15
  type WallNode,
13
16
  } from '@pascal-app/core'
14
- import { getLinearUnitLabel, type LinearUnit, metersToLinearUnit } from './measurements'
15
17
 
16
18
  // Rows for the wall panel's "Paint regions" list: finish swatch + name, the
17
19
  // face in room terms when the wall has a room on exactly one side, and the
@@ -1,11 +1,13 @@
1
1
  import { afterEach, expect, test } from 'bun:test'
2
- import { useInteractive, useScene, WindowNode } from '@pascal-app/core'
3
2
  import {
4
3
  getDisplayedWindowShutterValue,
5
4
  getDisplayedWindowValue,
6
5
  openWindowOpenState,
7
6
  setWindowShuttersOpenState,
8
- } from '../index'
7
+ useInteractive,
8
+ useScene,
9
+ WindowNode,
10
+ } from '@pascal-app/core'
9
11
 
10
12
  const originalScene = useScene.getState()
11
13
  const originalInteractive = useInteractive.getState()
@@ -10,6 +10,7 @@ import {
10
10
  type ChimneyMaterialRole,
11
11
  DEFAULT_BRUSH_SETTINGS,
12
12
  type DormerSurfaceMaterialRole,
13
+ isMoveLocked,
13
14
  type LevelNode,
14
15
  nodeRegistry,
15
16
  type RoofSurfaceMaterialRole,
@@ -1256,6 +1257,10 @@ const useEditor = create<EditorState>()(
1256
1257
  set({ placementDragMode: false })
1257
1258
  return
1258
1259
  }
1260
+ if (isMoveLocked(node)) {
1261
+ set({ placementDragMode: false })
1262
+ return
1263
+ }
1259
1264
  const targetNode = node
1260
1265
  const isNew = Boolean((targetNode as { metadata?: { isNew?: boolean } }).metadata?.isNew)
1261
1266
  if (isNew) {