@pascal-app/editor 1.0.1 → 1.0.3

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 (111) hide show
  1. package/package.json +10 -5
  2. package/src/components/editor/custom-camera-controls.tsx +22 -74
  3. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  4. package/src/components/editor/floorplan-panel.tsx +210 -163
  5. package/src/components/editor/grid.tsx +31 -7
  6. package/src/components/editor/group-actions.ts +5 -9
  7. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  8. package/src/components/editor/group-move-3d.ts +4 -6
  9. package/src/components/editor/group-rotate-handle.tsx +81 -35
  10. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  11. package/src/components/editor/group-transform-shared.test.ts +126 -1
  12. package/src/components/editor/group-transform-shared.ts +79 -73
  13. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  14. package/src/components/editor/handles/use-handle-drag.ts +75 -6
  15. package/src/components/editor/index.tsx +24 -9
  16. package/src/components/editor/node-action-menu.tsx +2 -0
  17. package/src/components/editor/node-arrow-handles.tsx +141 -7
  18. package/src/components/editor/registry-action-contributions.tsx +53 -0
  19. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  20. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  21. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  22. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  23. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  24. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  25. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  26. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +67 -21
  27. package/src/components/systems/zone/zone-system.tsx +8 -4
  28. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  29. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  30. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  31. package/src/components/tools/item/placement-strategies.ts +55 -43
  32. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  33. package/src/components/tools/item/use-draft-node.ts +41 -2
  34. package/src/components/tools/item/use-placement-coordinator.tsx +166 -73
  35. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  36. package/src/components/tools/registry/item-surface-move.ts +46 -24
  37. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  38. package/src/components/tools/registry/move-registry-node-tool.tsx +23 -9
  39. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  40. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  41. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  42. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  43. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  44. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  45. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  46. package/src/components/tools/tool-manager.tsx +36 -3
  47. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  48. package/src/components/ui/controls/material-picker.tsx +5 -67
  49. package/src/components/ui/controls/slider-control.tsx +76 -31
  50. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  51. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  52. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  53. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  54. package/src/components/ui/panels/parametric-inspector.tsx +3 -15
  55. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  56. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  57. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +585 -369
  58. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  59. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  60. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  61. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  62. package/src/components/viewer/floorplan-preview.tsx +1 -12
  63. package/src/components/viewer-zone-system.tsx +7 -2
  64. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  65. package/src/hooks/use-auto-save.ts +21 -6
  66. package/src/hooks/use-ceiling-events.test.ts +1 -0
  67. package/src/hooks/use-ceiling-events.ts +2 -0
  68. package/src/hooks/use-grid-events.test.ts +2 -0
  69. package/src/hooks/use-grid-events.ts +16 -11
  70. package/src/hooks/use-keyboard.test.ts +7 -0
  71. package/src/hooks/use-keyboard.ts +42 -29
  72. package/src/index.tsx +63 -3
  73. package/src/lib/catalog-panel-model.ts +39 -0
  74. package/src/lib/catalog-selection.test.ts +27 -0
  75. package/src/lib/catalog-selection.ts +8 -0
  76. package/src/lib/contextual-help.test.ts +27 -1
  77. package/src/lib/contextual-help.ts +16 -9
  78. package/src/lib/floorplan/floorplan-export.test.ts +3 -1
  79. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  80. package/src/lib/fresh-planar-placement.ts +168 -36
  81. package/src/lib/glb-export.test.ts +59 -16
  82. package/src/lib/glb-export.ts +26 -13
  83. package/src/lib/grid-frame.test.ts +19 -0
  84. package/src/lib/grid-frame.ts +18 -0
  85. package/src/lib/interaction/scope.ts +9 -1
  86. package/src/lib/keyboard-pan.test.ts +62 -0
  87. package/src/lib/keyboard-pan.ts +89 -0
  88. package/src/lib/material-catalog-model.ts +72 -0
  89. package/src/lib/material-paint-panel-model.ts +76 -0
  90. package/src/lib/overlay-transform.test.ts +28 -0
  91. package/src/lib/overlay-transform.ts +12 -0
  92. package/src/lib/panel-rows.ts +75 -0
  93. package/src/lib/panel-tool-options.test.ts +34 -0
  94. package/src/lib/panel-tool-options.ts +46 -0
  95. package/src/lib/parametric-node-update.ts +37 -0
  96. package/src/lib/scene.test.ts +99 -0
  97. package/src/lib/scene.ts +21 -3
  98. package/src/lib/snapping-mode.test.ts +13 -2
  99. package/src/lib/snapping-mode.ts +11 -6
  100. package/src/lib/spatial-drag-plane.test.ts +53 -0
  101. package/src/lib/spatial-drag-plane.ts +19 -0
  102. package/src/lib/spatial-pointer-input.test.ts +72 -0
  103. package/src/lib/spatial-pointer-input.ts +62 -0
  104. package/src/lib/spatial-snap.test.ts +47 -0
  105. package/src/lib/terrain-panel-model.ts +117 -0
  106. package/src/lib/world-grid-snap.test.ts +48 -0
  107. package/src/lib/world-grid-snap.ts +9 -5
  108. package/src/store/rotation-snapping.test.ts +53 -0
  109. package/src/store/use-editor.tsx +10 -2
  110. package/src/store/use-floorplan-draft-preview.ts +10 -1
  111. package/src/store/use-interaction-scope.ts +155 -6
@@ -3,14 +3,22 @@ import {
3
3
  type AnyNodeId,
4
4
  cloneNodesInto,
5
5
  collectSubtree,
6
+ createSceneApi,
6
7
  type DuplicableConfig,
8
+ getSurfaceProvider,
7
9
  nodeRegistry,
10
+ resolveSurfacePlacement,
11
+ type SurfaceRejectReason,
8
12
  useScene,
9
13
  } from '@pascal-app/core'
14
+ import { evaluateRecipe, isProceduralItem } from '@pascal-app/core/procedural-items'
15
+ import useInteractionScope from '../store/use-interaction-scope'
16
+ import usePlacementPreview from '../store/use-placement-preview'
10
17
  import { getPlacementMetadataRecord, stripPlacementMetadataFlags } from './placement-metadata'
11
18
  import {
12
19
  surfaceAttachmentId,
13
20
  surfaceAttachmentUpdates,
21
+ surfaceFramePose,
14
22
  updateSurfaceNode,
15
23
  } from './surface-attachment'
16
24
 
@@ -32,7 +40,13 @@ function duplicableConfigFor(node: AnyNode): DuplicableConfig | null {
32
40
  }
33
41
 
34
42
  export function duplicatesAsFreshSubtree(node: AnyNode): boolean {
35
- return duplicableConfigFor(node)?.subtree === true
43
+ // A surface-local root needs the same attachment lifecycle even without descendants.
44
+ if (surfaceAttachmentId(node) !== null) return true
45
+ const policy = duplicableConfigFor(node)?.subtree
46
+ return (
47
+ policy === true ||
48
+ (policy === 'with-children' && 'children' in node && node.children.length > 0)
49
+ )
36
50
  }
37
51
 
38
52
  /**
@@ -99,57 +113,175 @@ export function createFreshPlacementSubtree(
99
113
  parentId,
100
114
  })
101
115
 
102
- useScene
103
- .getState()
104
- .createNodes(
105
- cloned.nodes.map((node, index) => (index === 0 && parentId ? { node, parentId } : { node })),
106
- )
116
+ scene.applyNodeChanges({
117
+ create: cloned.nodes.map((node, index) =>
118
+ index === 0 && parentId ? { node, parentId } : { node },
119
+ ),
120
+ update: surfaceAttachmentUpdates(cloned.rootId, parentId, surfaceAttachmentId(subtree.root)),
121
+ })
107
122
 
123
+ const created = useScene.getState().nodes[cloned.rootId]
124
+ if (!created) return null
125
+ useInteractionScope.getState().noteSubtreeCreation(created)
108
126
  return cloned.rootId
109
127
  }
110
128
 
129
+ export function discardFreshPlacementSubtree(rootId: AnyNodeId): void {
130
+ const scene = useScene.getState()
131
+ if (!scene.nodes[rootId]) return
132
+ scene.applyNodeChanges({
133
+ delete: [rootId],
134
+ update: surfaceAttachmentUpdates(rootId, null, null),
135
+ })
136
+ }
137
+
138
+ function namedSurfaceRejection(
139
+ root: AnyNode,
140
+ surfaceId: string | null,
141
+ ): SurfaceRejectReason | null {
142
+ if (surfaceId === null) return null
143
+ const scene = createSceneApi(useScene)
144
+ const host = root.parentId ? scene.get(root.parentId as AnyNodeId) : undefined
145
+ if (!host || !('position' in root) || !Array.isArray(root.rotation)) return 'no-surface'
146
+ const surface = getSurfaceProvider(host)
147
+ .surfaces?.(host, { scene })
148
+ .find((s) => s.id === surfaceId)
149
+ if (!surface) return 'no-surface'
150
+ const pose = surfaceFramePose(
151
+ root.parentId,
152
+ surfaceId,
153
+ root as {
154
+ position: [number, number, number]
155
+ rotation: [number, number, number]
156
+ },
157
+ false,
158
+ )
159
+ const capabilities = nodeRegistry.get(root.type)?.capabilities
160
+ const evaluated = isProceduralItem(root) ? evaluateRecipe(root.recipe, root.parameters) : null
161
+ const bounds =
162
+ capabilities?.dragBounds?.(root, scene.nodes()) ??
163
+ (evaluated
164
+ ? {
165
+ size: evaluated.dimensions,
166
+ center: evaluated.min.map((v, i) => (v + evaluated.max[i]!) / 2) as [
167
+ number,
168
+ number,
169
+ number,
170
+ ],
171
+ }
172
+ : undefined)
173
+ const size = bounds?.size ??
174
+ capabilities?.floorPlaced?.footprint?.(root, { nodes: scene.nodes() }).dimensions ?? [0, 0, 0]
175
+ const center = bounds?.center
176
+ const localBounds = center
177
+ ? {
178
+ min: center.map((value, axis) => value - size[axis]! / 2) as [number, number, number],
179
+ max: center.map((value, axis) => value + size[axis]! / 2) as [number, number, number],
180
+ }
181
+ : undefined
182
+ let reason: SurfaceRejectReason | null = null
183
+ const placement = resolveSurfacePlacement({
184
+ host,
185
+ surface,
186
+ childKind: root.type,
187
+ childId: root.id,
188
+ childFootprint: {
189
+ size,
190
+ rotationY: pose.rotation[1],
191
+ rotation: pose.rotation,
192
+ localBounds,
193
+ },
194
+ hit: { point: pose.position, normalWorldY: 1 },
195
+ origin: pose.position,
196
+ scene,
197
+ onReject: (value) => {
198
+ reason = value
199
+ },
200
+ })
201
+ if (!placement) return reason ?? 'no-surface'
202
+ return null
203
+ }
204
+
111
205
  /**
112
- * Finalises a fresh catalog/duplicate draft as a single undoable creation.
113
- *
114
- * Fresh drafts already exist in the scene so renderers and move tools can
115
- * preview real geometry. On commit we delete that draft while history is
116
- * paused, then create a clean clone at the final cursor position with history
117
- * resumed. Undo therefore removes the placed node instead of resurrecting the
118
- * hidden draft at its origin.
206
+ * Replace the draft in one validated write. History already excludes fresh
207
+ * subtrees, so this records one creation without first deleting the preview.
119
208
  */
120
209
  export function commitFreshPlacementSubtree(
121
210
  rootId: AnyNodeId,
122
211
  rootPatch: Partial<AnyNode>,
212
+ onReject?: (reason: SurfaceRejectReason) => void,
123
213
  ): AnyNodeId | null {
124
214
  const scene = useScene.getState()
125
215
  const subtree = collectSubtree(scene.nodes, rootId)
126
- if (!subtree) return null
216
+ if (!subtree || scene.readOnly) return null
127
217
 
128
- const root = cleanPlacementMetadata({
129
- ...subtree.root,
130
- ...rootPatch,
131
- } as AnyNode)
218
+ const root = cleanPlacementMetadata({ ...subtree.root, ...rootPatch } as AnyNode)
219
+ const surfaceId =
220
+ root.parentId === subtree.root.parentId ? surfaceAttachmentId(subtree.root) : null
221
+ const rejection = namedSurfaceRejection(root, surfaceId)
222
+ if (rejection) {
223
+ onReject?.(rejection)
224
+ return null
225
+ }
132
226
  const descendants = subtree.descendants.map((node) => cleanPlacementMetadata(node))
133
227
  const parentId = parentIdOf(root)
134
- const cloned = cloneNodesInto([root, ...descendants], {
135
- rootId,
136
- parentId,
137
- })
228
+ const cloned = cloneNodesInto([root, ...descendants], { rootId, parentId })
229
+ const updates = surfaceAttachmentUpdates(rootId, null, null)
230
+ for (const update of surfaceAttachmentUpdates(cloned.rootId, parentId, surfaceId)) {
231
+ const attachments = { ...(update.data as { attachments: Record<string, string> }).attachments }
232
+ delete attachments[rootId]
233
+ const previous = updates.findIndex((entry) => entry.id === update.id)
234
+ const merged = { id: update.id, data: { attachments } }
235
+ if (previous === -1) updates.push(merged)
236
+ else updates[previous] = merged
237
+ }
138
238
 
139
- const surfaceId = surfaceAttachmentId(subtree.root)
239
+ const scope = useInteractionScope.getState()
240
+ const factoryCreated =
241
+ scope.ownedSubtree?.creation.rootId === rootId || scope.pendingSubtree?.rootId === rootId
140
242
  const temporal = useScene.temporal.getState()
141
- const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
142
- if (wasTracking) temporal.pause()
143
- updateSurfaceNode(rootId, {}, null)
144
- useScene.getState().deleteNode(rootId)
145
- temporal.resume()
146
- useScene.getState().applyNodeChanges({
147
- create: cloned.nodes.map((node, index) =>
148
- index === 0 && parentId ? { node, parentId } : { node },
149
- ),
150
- update: surfaceAttachmentUpdates(cloned.rootId, parentId, surfaceId),
151
- })
152
- if (!wasTracking) temporal.pause()
153
-
243
+ const wasTracking = temporal.isTracking
244
+ if (!factoryCreated && descendants.length === 0 && surfaceId === null) {
245
+ // Preserve the established root-only lifecycle for placements outside the subtree factory.
246
+ if (wasTracking) temporal.pause()
247
+ updateSurfaceNode(rootId, {}, null)
248
+ useScene.getState().deleteNode(rootId)
249
+ temporal.resume()
250
+ useScene.getState().applyNodeChanges({
251
+ create: cloned.nodes.map((node, index) =>
252
+ index === 0 && parentId ? { node, parentId } : { node },
253
+ ),
254
+ update: updates,
255
+ })
256
+ if (!wasTracking) temporal.pause()
257
+ } else {
258
+ temporal.resume()
259
+ try {
260
+ // applyNodeChanges validates the complete proposed graph before publishing any part of it.
261
+ scene.applyNodeChanges({
262
+ delete: [rootId],
263
+ create: cloned.nodes.map((node, index) =>
264
+ index === 0 && parentId ? { node, parentId } : { node },
265
+ ),
266
+ update: updates,
267
+ })
268
+ for (const node of [subtree.root, ...subtree.descendants]) scene.clearDirty(node.id)
269
+ } catch (error) {
270
+ // Zustand publishes before notifying subscribers. A subscriber error must restore
271
+ // the draft and its ownership/history, never masquerade as a placement refusal.
272
+ temporal.pause()
273
+ try {
274
+ if (useScene.getState() !== scene) useScene.setState(scene, true)
275
+ } finally {
276
+ useInteractionScope.setState(scope)
277
+ useScene.temporal.setState(temporal)
278
+ }
279
+ throw error
280
+ } finally {
281
+ if (!wasTracking) temporal.pause()
282
+ }
283
+ }
284
+ useInteractionScope.getState().finishSubtree(rootId)
285
+ if (usePlacementPreview.getState().node?.id === rootId) usePlacementPreview.getState().clear()
154
286
  return cloned.rootId
155
287
  }
@@ -590,19 +590,64 @@ describe('prepareSceneForExport', () => {
590
590
  expect(animations).toHaveLength(0)
591
591
  })
592
592
 
593
- test('inherits hidden Site visibility for detached declared children and their descendants', async () => {
593
+ test('keeps the buildings on a hidden Site and drops only the Site ground', () => {
594
+ // A layout authored outside the editor can hide the root Site (the
595
+ // renderer ignores that flag) while every node on it stays visible. The
596
+ // Site's own flag must stop at the Site: it is the parcel reference, not
597
+ // a container the building inherits visibility from.
598
+ const root = new THREE.Group()
599
+ const siteGroup = new THREE.Group()
600
+ const siteGround = meshWithNodeMaterial(nodeMaterial())
601
+ const buildingGroup = new THREE.Group()
602
+ const levelGroup = new THREE.Group()
603
+ const itemGroup = new THREE.Group()
604
+ itemGroup.add(meshWithNodeMaterial(nodeMaterial()))
605
+ levelGroup.add(itemGroup)
606
+ buildingGroup.add(levelGroup)
607
+ siteGroup.add(buildingGroup, siteGround)
608
+ root.add(siteGroup)
609
+
610
+ const siteId = 'site_hidden'
611
+ const buildingId = 'building_on_hidden_site'
612
+ const levelId = 'level_on_hidden_site'
613
+ const itemId = 'item_on_hidden_site'
614
+ sceneRegistry.nodes.set(siteId, siteGroup)
615
+ sceneRegistry.nodes.set(buildingId, buildingGroup)
616
+ sceneRegistry.nodes.set(levelId, levelGroup)
617
+ sceneRegistry.nodes.set(itemId, itemGroup)
618
+ const node = (id: string, type: string, parentId: string | null, visible: boolean) =>
619
+ ({ object: 'node', id, type, parentId, visible }) as unknown as AnyNode
620
+ const nodes: Record<string, AnyNode> = {
621
+ [siteId]: node(siteId, 'site', null, false),
622
+ [buildingId]: node(buildingId, 'building', siteId, true),
623
+ [levelId]: node(levelId, 'level', buildingId, true),
624
+ [itemId]: node(itemId, 'item', levelId, true),
625
+ }
626
+
627
+ const { scene } = prepareSceneForExport(root, nodes)
628
+
629
+ const meshes: THREE.Mesh[] = []
630
+ scene.traverse((object) => {
631
+ if ((object as THREE.Mesh).isMesh) meshes.push(object as THREE.Mesh)
632
+ })
633
+ expect(scene.getObjectByName(buildingId)).toBeDefined()
634
+ expect(scene.getObjectByName(itemId)).toBeDefined()
635
+ expect(meshes).toHaveLength(1)
636
+ expect(meshes[0]?.parent?.name).toBe(itemId)
637
+ })
638
+
639
+ test('keeps the nodes a hidden Site hosts, declared or detached, unless hidden themselves', async () => {
594
640
  const restoreRegistry = nodeRegistry._snapshot()
595
641
  try {
596
- const kind = 'test:detached-site-visibility'
642
+ const kind = 'test:hidden-site-host'
597
643
  const childId = 'detached_site_child'
598
644
  const descendantId = 'detached_site_descendant'
599
645
  const explicitId = 'explicit_site_child'
600
- const unownedId = 'unowned_site_child'
646
+ const hiddenChildId = 'hidden_site_child'
601
647
  const hiddenSite = SiteNode.parse({
602
648
  visible: false,
603
- children: [childId, explicitId],
649
+ children: [childId, explicitId, hiddenChildId],
604
650
  })
605
- const visibleSite = SiteNode.parse({ visible: true })
606
651
  registerNode({
607
652
  kind,
608
653
  schemaVersion: 1,
@@ -615,17 +660,19 @@ describe('prepareSceneForExport', () => {
615
660
  } as AnyNodeDefinition)
616
661
  const nodes = {
617
662
  [hiddenSite.id]: hiddenSite,
618
- [visibleSite.id]: visibleSite,
619
663
  [childId]: { id: childId, type: kind, parentId: null, visible: true },
620
664
  [descendantId]: { id: descendantId, type: kind, parentId: childId, visible: true },
621
- [explicitId]: { id: explicitId, type: kind, parentId: visibleSite.id, visible: true },
622
- [unownedId]: { id: unownedId, type: kind, parentId: null, visible: true },
665
+ [explicitId]: { id: explicitId, type: kind, parentId: hiddenSite.id, visible: true },
666
+ [hiddenChildId]: { id: hiddenChildId, type: kind, parentId: hiddenSite.id, visible: false },
623
667
  } as unknown as Record<string, AnyNode>
624
- const allIds = [childId, descendantId, explicitId, unownedId]
668
+ const allIds = [childId, descendantId, explicitId, hiddenChildId]
625
669
  const root = new THREE.Group()
626
- for (const id of [hiddenSite.id, visibleSite.id, ...allIds]) {
670
+ const siteObject = new THREE.Group()
671
+ root.add(siteObject)
672
+ sceneRegistry.nodes.set(hiddenSite.id, siteObject)
673
+ for (const id of allIds) {
627
674
  const object = new THREE.Group()
628
- root.add(object)
675
+ siteObject.add(object)
629
676
  sceneRegistry.nodes.set(id, object)
630
677
  }
631
678
  const exportedIds = async (onlyVisible?: boolean) => {
@@ -637,12 +684,8 @@ describe('prepareSceneForExport', () => {
637
684
  }
638
685
  }
639
686
 
640
- expect(await exportedIds()).toEqual([explicitId, unownedId])
687
+ expect(await exportedIds()).toEqual([childId, descendantId, explicitId])
641
688
  expect(await exportedIds(false)).toEqual(allIds)
642
- nodes[hiddenSite.id] = { ...hiddenSite, visible: true }
643
- expect(await exportedIds()).toEqual(allIds)
644
- nodes[hiddenSite.id] = hiddenSite
645
- expect(await exportedIds()).toEqual([explicitId, unownedId])
646
689
  } finally {
647
690
  restoreRegistry()
648
691
  }
@@ -7,6 +7,7 @@ import {
7
7
  findLevelAncestorId,
8
8
  type GeometryContext,
9
9
  getLevelDisplayName,
10
+ hidesDescendants,
10
11
  isNodeKindEnabled,
11
12
  isOperationDoorType,
12
13
  itemClipRegistry,
@@ -715,16 +716,6 @@ function pruneHiddenSceneNodes(
715
716
  registryEntries: readonly RegistryEntry[],
716
717
  ) {
717
718
  const visibility = new Map<string, boolean>()
718
- const declaredSiteParents = new Map<string, string>()
719
- for (const node of Object.values(nodes)) {
720
- if (node.type !== 'site' || !('children' in node) || !Array.isArray(node.children)) continue
721
- for (const childId of node.children) {
722
- const child = nodes[childId]
723
- if (child && !child.parentId && !declaredSiteParents.has(childId)) {
724
- declaredSiteParents.set(childId, node.id)
725
- }
726
- }
727
- }
728
719
 
729
720
  const isVisible = (id: string, path: Set<string>): boolean => {
730
721
  const cached = visibility.get(id)
@@ -736,8 +727,9 @@ function pruneHiddenSceneNodes(
736
727
  visibility.set(id, false)
737
728
  return false
738
729
  }
739
- const parentId = node.parentId || declaredSiteParents.get(id)
740
- if (!parentId || path.has(id)) {
730
+ const parentId = node.parentId
731
+ const parent = parentId ? nodes[parentId] : undefined
732
+ if (!parentId || path.has(id) || (parent && !hidesDescendants(parent))) {
741
733
  visibility.set(id, true)
742
734
  return true
743
735
  }
@@ -749,12 +741,33 @@ function pruneHiddenSceneNodes(
749
741
  return visible
750
742
  }
751
743
 
744
+ const nodeClones = new Set<THREE.Object3D>()
745
+ for (const [, original] of registryEntries) {
746
+ const clone = cloneByOriginal.get(original)
747
+ if (clone) nodeClones.add(clone)
748
+ }
749
+
752
750
  for (const [id, original] of registryEntries) {
753
751
  if (isVisible(id, new Set())) continue
754
- cloneByOriginal.get(original)?.removeFromParent()
752
+ const clone = cloneByOriginal.get(original)
753
+ if (!clone) continue
754
+ const node = nodes[id]
755
+ if (!node || hidesDescendants(node)) {
756
+ clone.removeFromParent()
757
+ continue
758
+ }
759
+ // Drop the hidden Site's own ground fill and boundary; keep what it hosts.
760
+ for (const child of [...clone.children]) {
761
+ if (!hostsSceneNode(child, nodeClones)) child.removeFromParent()
762
+ }
755
763
  }
756
764
  }
757
765
 
766
+ function hostsSceneNode(object: THREE.Object3D, nodeClones: Set<THREE.Object3D>): boolean {
767
+ if (nodeClones.has(object)) return true
768
+ return object.children.some((child) => hostsSceneNode(child, nodeClones))
769
+ }
770
+
758
771
  function retainedClones(
759
772
  root: THREE.Object3D,
760
773
  cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
@@ -0,0 +1,19 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { Group, Vector3 } from 'three'
3
+ import { gridLocalNormal, gridLocalPoint } from './grid-frame'
4
+
5
+ test('grid floor and cursor stay in model coordinates after God-view pan, scale and yaw', () => {
6
+ const root = new Group()
7
+ root.position.set(2, 4, -3)
8
+ root.rotation.y = Math.PI / 3
9
+ root.scale.setScalar(0.2)
10
+ root.updateMatrixWorld(true)
11
+ const local = new Vector3(3, 2.5, 7)
12
+ const world = root.localToWorld(local.clone())
13
+ expect(gridLocalPoint(root, world, new Vector3()).distanceTo(local)).toBeLessThan(1e-8)
14
+ const normal = new Vector3(1, 0, 0).transformDirection(root.matrixWorld)
15
+ expect(
16
+ gridLocalNormal(root, normal, new Vector3()).distanceTo(new Vector3(1, 0, 0)),
17
+ ).toBeLessThan(1e-8)
18
+ expect(world.distanceTo(local)).toBeGreaterThan(1)
19
+ })
@@ -0,0 +1,18 @@
1
+ import type { Object3D, Vector3 } from 'three'
2
+
3
+ // Placement surfaces are world-space; the grid lives under the scalable XR
4
+ // scene root. Keep its lattice and reveal in that root's model coordinates.
5
+ export function gridLocalPoint(parent: Object3D | null, world: Vector3, target: Vector3) {
6
+ target.copy(world)
7
+ if (parent) {
8
+ parent.updateWorldMatrix(true, false)
9
+ parent.worldToLocal(target)
10
+ }
11
+ return target
12
+ }
13
+
14
+ export function gridLocalNormal(parent: Object3D | null, normal: Vector3, target: Vector3) {
15
+ target.copy(normal)
16
+ if (parent) target.transformDirection(parent.matrixWorld.clone().invert())
17
+ return target
18
+ }
@@ -19,7 +19,15 @@ export type ReshapeDriver = 'tool' | 'floorplan'
19
19
  // node, one in-flight reshape. Grouping them as sub-states of `reshaping`
20
20
  // (rather than four sibling scopes) keeps the union small while still making
21
21
  // "curving and hole-editing at once" unrepresentable.
22
- export type ReshapeKind = 'curve' | 'hole' | 'endpoint' | 'boundary' | 'control-point' | 'tangent'
22
+ export type ReshapeKind =
23
+ | 'curve'
24
+ | 'hole'
25
+ | 'endpoint'
26
+ | 'boundary'
27
+ | 'control-point'
28
+ | 'tangent'
29
+ // A kind's own reshape (wall 'split'): mounted from `def.affordanceTools[reshape]`.
30
+ | 'split'
23
31
 
24
32
  export type InteractionScope =
25
33
  | { kind: 'idle' }
@@ -0,0 +1,62 @@
1
+ import { afterAll, beforeAll, expect, test } from 'bun:test'
2
+ import {
3
+ acceptsKeyboardPan,
4
+ clearKeyboardPanKeys,
5
+ hasKeyboardPanInput,
6
+ isKeyboardPanKey,
7
+ type KeyboardPanState,
8
+ keyboardPanDirection,
9
+ keyboardPanSpeed,
10
+ setKeyboardPanKey,
11
+ } from './keyboard-pan'
12
+
13
+ const idle = (): KeyboardPanState => ({
14
+ forward: false,
15
+ backward: false,
16
+ left: false,
17
+ right: false,
18
+ })
19
+ const key = (init: Partial<KeyboardEvent>) => ({ target: null, ...init }) as KeyboardEvent
20
+
21
+ // No DOM in this runner: stand in for the element classes the editable check reads.
22
+ const DOM_CLASSES = ['HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'HTMLSelectElement']
23
+ const stubbed: string[] = []
24
+ beforeAll(() => {
25
+ const scope = globalThis as Record<string, unknown>
26
+ for (const name of DOM_CLASSES) {
27
+ if (scope[name]) continue
28
+ scope[name] = class {}
29
+ stubbed.push(name)
30
+ }
31
+ })
32
+ afterAll(() => {
33
+ for (const name of stubbed) delete (globalThis as Record<string, unknown>)[name]
34
+ })
35
+
36
+ test('physical WASD keys drive a screen-space direction; letters on other layouts do not', () => {
37
+ const state = idle()
38
+ expect(isKeyboardPanKey('KeyZ')).toBe(false)
39
+ expect(setKeyboardPanKey(state, 'KeyW', true)).toBe(true)
40
+ expect(setKeyboardPanKey(state, 'KeyW', true)).toBe(false)
41
+ setKeyboardPanKey(state, 'KeyD', true)
42
+ expect(keyboardPanDirection(state)).toEqual({ horizontal: 1, vertical: 1 })
43
+ setKeyboardPanKey(state, 'KeyA', true)
44
+ expect(keyboardPanDirection(state)).toEqual({ horizontal: 0, vertical: 1 })
45
+ clearKeyboardPanKeys(state)
46
+ expect(hasKeyboardPanInput(state)).toBe(false)
47
+ })
48
+
49
+ test('modifier chords stay shortcuts, and typing in a field never pans', () => {
50
+ expect(acceptsKeyboardPan(key({}))).toBe(true)
51
+ for (const modifier of ['metaKey', 'ctrlKey', 'altKey'] as const)
52
+ expect(acceptsKeyboardPan(key({ [modifier]: true }))).toBe(false)
53
+ const Input = (globalThis as unknown as { HTMLInputElement: new () => EventTarget })
54
+ .HTMLInputElement
55
+ expect(acceptsKeyboardPan(key({ target: new Input() }))).toBe(false)
56
+ })
57
+
58
+ test('speed scales with the visible width within fixed bounds', () => {
59
+ expect(keyboardPanSpeed(0)).toBe(2)
60
+ expect(keyboardPanSpeed(20)).toBeCloseTo(13)
61
+ expect(keyboardPanSpeed(1000)).toBe(55)
62
+ })
@@ -0,0 +1,89 @@
1
+ // WASD navigation shared by the 3D camera and the 2D floor plan, so both views
2
+ // move the same way. Keys match by physical position (`event.code`): the
3
+ // cluster stays under the left hand on any layout (Z/Q/S/D on AZERTY).
4
+
5
+ export type KeyboardPanState = {
6
+ forward: boolean
7
+ backward: boolean
8
+ left: boolean
9
+ right: boolean
10
+ }
11
+
12
+ const KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND = 0.65
13
+ const KEYBOARD_PAN_MIN_SPEED = 2
14
+ const KEYBOARD_PAN_MAX_SPEED = 55
15
+
16
+ export function isEditableKeyboardTarget(target: EventTarget | null) {
17
+ return (
18
+ target instanceof HTMLInputElement ||
19
+ target instanceof HTMLTextAreaElement ||
20
+ target instanceof HTMLSelectElement ||
21
+ (target instanceof HTMLElement && target.isContentEditable)
22
+ )
23
+ }
24
+
25
+ export function setKeyboardPanKey(
26
+ state: KeyboardPanState,
27
+ code: string,
28
+ pressed: boolean,
29
+ ): boolean {
30
+ if (code === 'KeyW') {
31
+ const changed = state.forward !== pressed
32
+ state.forward = pressed
33
+ return changed
34
+ }
35
+ if (code === 'KeyS') {
36
+ const changed = state.backward !== pressed
37
+ state.backward = pressed
38
+ return changed
39
+ }
40
+ if (code === 'KeyA') {
41
+ const changed = state.left !== pressed
42
+ state.left = pressed
43
+ return changed
44
+ }
45
+ if (code === 'KeyD') {
46
+ const changed = state.right !== pressed
47
+ state.right = pressed
48
+ return changed
49
+ }
50
+ return false
51
+ }
52
+
53
+ export function isKeyboardPanKey(code: string): boolean {
54
+ return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD'
55
+ }
56
+
57
+ export function hasKeyboardPanInput(state: KeyboardPanState): boolean {
58
+ return state.forward || state.backward || state.left || state.right
59
+ }
60
+
61
+ export function clearKeyboardPanKeys(state: KeyboardPanState) {
62
+ state.forward = false
63
+ state.backward = false
64
+ state.left = false
65
+ state.right = false
66
+ }
67
+
68
+ /** Pan keys are ignored with a modifier held (shortcuts) or while typing. */
69
+ export function acceptsKeyboardPan(event: KeyboardEvent) {
70
+ return (
71
+ !(event.metaKey || event.ctrlKey || event.altKey) && !isEditableKeyboardTarget(event.target)
72
+ )
73
+ }
74
+
75
+ /** Screen-space direction: `horizontal` +1 is right, `vertical` +1 is forward (up). */
76
+ export function keyboardPanDirection(state: KeyboardPanState) {
77
+ return {
78
+ horizontal: (state.right ? 1 : 0) - (state.left ? 1 : 0),
79
+ vertical: (state.forward ? 1 : 0) - (state.backward ? 1 : 0),
80
+ }
81
+ }
82
+
83
+ /** World units per second for a view `viewWidth` wide. */
84
+ export function keyboardPanSpeed(viewWidth: number) {
85
+ return Math.min(
86
+ Math.max(viewWidth * KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND, KEYBOARD_PAN_MIN_SPEED),
87
+ KEYBOARD_PAN_MAX_SPEED,
88
+ )
89
+ }