@pascal-app/editor 1.0.4 → 1.0.6

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 (309) hide show
  1. package/README.md +106 -0
  2. package/package.json +40 -38
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/bake-thumbnail.tsx +6 -15
  5. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  6. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  7. package/src/components/editor/custom-camera-controls.tsx +81 -8
  8. package/src/components/editor/editor-layout-mobile.tsx +1 -1
  9. package/src/components/editor/editor-layout-v2.tsx +1 -1
  10. package/src/components/editor/export-manager.tsx +14 -19
  11. package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +22 -5
  13. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  14. package/src/components/editor/first-person/ground-holes.ts +130 -0
  15. package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
  16. package/src/components/editor/first-person/mechanism-targets.ts +52 -0
  17. package/src/components/editor/first-person-controls.tsx +19 -11
  18. package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  20. package/src/components/editor/floorplan-panel.tsx +72 -23
  21. package/src/components/editor/follow-pascal.test.tsx +912 -0
  22. package/src/components/editor/follow-pascal.tsx +354 -0
  23. package/src/components/editor/group-actions.ts +2 -0
  24. package/src/components/editor/group-move-3d.ts +2 -0
  25. package/src/components/editor/group-rotate-handle.tsx +2 -0
  26. package/src/components/editor/group-transform-shared.test.ts +460 -0
  27. package/src/components/editor/index.tsx +166 -79
  28. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  29. package/src/components/editor/room-highlight.tsx +13 -5
  30. package/src/components/editor/room-labels.test.ts +621 -128
  31. package/src/components/editor/room-labels.tsx +1090 -190
  32. package/src/components/editor/selection-manager.tsx +38 -117
  33. package/src/components/editor/slab-hole-highlights.tsx +2 -2
  34. package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
  35. package/src/components/editor/snapshot-capture.test.ts +69 -58
  36. package/src/components/editor/snapshot-capture.ts +7 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +350 -4
  38. package/src/components/editor/thumbnail-generator.tsx +128 -54
  39. package/src/components/editor/use-floorplan-background-placement.ts +3 -1
  40. package/src/components/editor/views/plugins-view.tsx +1 -8
  41. package/src/components/editor/views/view-bar.test.tsx +54 -0
  42. package/src/components/editor/views/view-bar.tsx +140 -17
  43. package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
  44. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  45. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  46. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
  47. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
  49. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  50. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  51. package/src/components/point-ask/bubble.tsx +538 -0
  52. package/src/components/point-ask/hover-chip.tsx +91 -0
  53. package/src/components/point-ask/mode-pill.tsx +92 -0
  54. package/src/components/point-ask/optional-motion.ts +22 -0
  55. package/src/components/point-ask/outline-corners.tsx +93 -0
  56. package/src/components/point-ask/pins.tsx +711 -0
  57. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  58. package/src/components/point-ask/point-ask-styles.ts +105 -0
  59. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  60. package/src/components/point-ask/region-marquee.tsx +64 -0
  61. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  62. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  63. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  64. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  65. package/src/components/tools/distribution/distribution-public.ts +9 -0
  66. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  67. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  68. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  69. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  70. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  71. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  72. package/src/components/tools/distribution/ports.test.ts +56 -0
  73. package/src/components/tools/distribution/ports.ts +378 -0
  74. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  75. package/src/components/tools/distribution/run-cursor.ts +36 -0
  76. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  77. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  78. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  79. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  80. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  81. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  82. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  83. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  84. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  85. package/src/components/tools/item/placement-history.test.tsx +38 -0
  86. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  87. package/src/components/tools/item/placement-strategies.ts +3 -6
  88. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  89. package/src/components/tools/item/use-draft-node.test.tsx +94 -94
  90. package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
  91. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  92. package/src/components/tools/registry/item-surface-move.ts +2 -0
  93. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  94. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
  96. package/src/components/tools/tool-manager.tsx +14 -5
  97. package/src/components/tools/wall/wall-drafting.test.ts +170 -57
  98. package/src/components/ui/action-menu/action-button.tsx +6 -1
  99. package/src/components/ui/action-menu/camera-actions.tsx +7 -3
  100. package/src/components/ui/action-menu/control-modes.tsx +13 -7
  101. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  102. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  103. package/src/components/ui/action-menu/index.tsx +56 -33
  104. package/src/components/ui/action-menu/measurement-control.tsx +11 -9
  105. package/src/components/ui/action-menu/placement.ts +20 -0
  106. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  107. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  108. package/src/components/ui/build-panel/build-panel.tsx +10 -3
  109. package/src/components/ui/command-palette/editor-commands.tsx +3 -12
  110. package/src/components/ui/command-palette/index.tsx +45 -15
  111. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  112. package/src/components/ui/controls/action-button.tsx +5 -2
  113. package/src/components/ui/controls/metric-control.tsx +1 -1
  114. package/src/components/ui/controls/segmented-control.tsx +21 -8
  115. package/src/components/ui/controls/select-control.tsx +1 -1
  116. package/src/components/ui/controls/slider-control.tsx +1 -1
  117. package/src/components/ui/controls/toggle-control.tsx +3 -3
  118. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  119. package/src/components/ui/floating-level-selector.tsx +150 -45
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
  121. package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
  122. package/src/components/ui/helpers/helper-manager.tsx +7 -1
  123. package/src/components/ui/helpers/item-helper.tsx +9 -1
  124. package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
  125. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  126. package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
  127. package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
  128. package/src/components/ui/panels/panel-wrapper.tsx +53 -27
  129. package/src/components/ui/panels/parametric-field-control.tsx +2 -2
  130. package/src/components/ui/panels/parametric-inspector.tsx +3 -3
  131. package/src/components/ui/panels/room-panel.tsx +65 -0
  132. package/src/components/ui/primitives/badge.tsx +8 -0
  133. package/src/components/ui/primitives/sidebar.tsx +1 -1
  134. package/src/components/ui/right-stack.tsx +21 -2
  135. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  136. package/src/components/ui/sidebar/icon-rail.tsx +3 -3
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  138. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  139. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  140. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  141. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  142. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  143. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
  144. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  145. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  146. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  147. package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
  148. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
  149. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
  150. package/src/components/ui/sidebar/tab-bar.tsx +4 -4
  151. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  152. package/src/components/viewer/viewer-rooms.tsx +16 -0
  153. package/src/hooks/use-auto-save.integration.test.tsx +96 -38
  154. package/src/hooks/use-auto-save.ts +26 -1
  155. package/src/hooks/use-ceiling-events.test.ts +123 -118
  156. package/src/hooks/use-grid-events.test.ts +105 -105
  157. package/src/hooks/use-keyboard.test.ts +65 -16
  158. package/src/hooks/use-keyboard.ts +29 -12
  159. package/src/hooks/use-placement-notice.test.ts +24 -1
  160. package/src/hooks/use-placement-notice.ts +37 -6
  161. package/src/index.tsx +71 -24
  162. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  163. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  164. package/src/lib/add-storey.test.ts +177 -0
  165. package/src/lib/add-storey.ts +59 -0
  166. package/src/lib/below-grade.test.tsx +171 -0
  167. package/src/lib/below-grade.ts +133 -0
  168. package/src/lib/camera-facing.test.ts +24 -0
  169. package/src/lib/camera-facing.ts +24 -0
  170. package/src/lib/direct-manipulation.ts +2 -0
  171. package/src/lib/download-project-file.ts +17 -0
  172. package/src/lib/editor-level-display.ts +9 -2
  173. package/src/lib/editor-views.ts +35 -1
  174. package/src/lib/export-texture-utils.ts +2 -4
  175. package/src/lib/floor-foundation-support.test.ts +1 -0
  176. package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
  177. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
  178. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
  179. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  180. package/src/lib/floorplan/sheet-export.test.ts +24 -0
  181. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
  182. package/src/lib/floorplan/site-plan/coverage.ts +2 -4
  183. package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
  184. package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
  185. package/src/lib/floorplan/site-plan/geometry.ts +1 -416
  186. package/src/lib/floorplan/site-plan/index.ts +0 -16
  187. package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
  188. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
  189. package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
  190. package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
  191. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  192. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  193. package/src/lib/follow-pascal-exterior.ts +39 -0
  194. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  195. package/src/lib/follow-pascal-presentation.ts +129 -0
  196. package/src/lib/follow-pascal-sight.ts +84 -0
  197. package/src/lib/follow-pascal-targets.test.ts +406 -0
  198. package/src/lib/follow-pascal-targets.ts +198 -0
  199. package/src/lib/follow-pascal.test.ts +350 -0
  200. package/src/lib/follow-pascal.ts +284 -0
  201. package/src/lib/fresh-planar-placement.test.ts +112 -0
  202. package/src/lib/fresh-planar-placement.ts +57 -3
  203. package/src/lib/geometry-script/author.ts +6 -1
  204. package/src/lib/geometry-script/client.ts +8 -1
  205. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  206. package/src/lib/geometry-script/protocol.ts +1 -0
  207. package/src/lib/glb-export.test.ts +187 -162
  208. package/src/lib/glb-export.ts +4 -1
  209. package/src/lib/history.test.ts +12 -7
  210. package/src/lib/interaction/scope.ts +4 -0
  211. package/src/lib/level-selection.test.ts +57 -0
  212. package/src/lib/level-selection.ts +16 -1
  213. package/src/lib/lot/clean-ring.test.ts +2 -1
  214. package/src/lib/lot/drop-in.ts +9 -24
  215. package/src/lib/lot/lot-patch.test.ts +1 -1
  216. package/src/lib/lot/lot-patch.ts +2 -2
  217. package/src/lib/measurement-kind.test.ts +6 -8
  218. package/src/lib/measurements.test.ts +23 -0
  219. package/src/lib/orbit-floor-clearance.test.ts +36 -0
  220. package/src/lib/orbit-floor-clearance.ts +48 -0
  221. package/src/lib/paint-pass-through.test.ts +11 -0
  222. package/src/lib/paint-pass-through.ts +4 -2
  223. package/src/lib/paint-scope.ts +1 -0
  224. package/src/lib/parametric-node-update.ts +16 -7
  225. package/src/lib/plugin-chat.ts +15 -0
  226. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  227. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  228. package/src/lib/point-ask/ask-created.test.ts +195 -0
  229. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  230. package/src/lib/point-ask/build-context.test.ts +173 -0
  231. package/src/lib/point-ask/build-context.ts +94 -0
  232. package/src/lib/point-ask/capture-crop.ts +129 -0
  233. package/src/lib/point-ask/choreography.ts +69 -0
  234. package/src/lib/point-ask/compare.test.ts +145 -0
  235. package/src/lib/point-ask/compare.ts +73 -0
  236. package/src/lib/point-ask/describe-target.test.ts +165 -0
  237. package/src/lib/point-ask/describe-target.ts +234 -0
  238. package/src/lib/point-ask/dictation.ts +79 -0
  239. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  240. package/src/lib/point-ask/edge-marker.ts +27 -0
  241. package/src/lib/point-ask/frame-loop.ts +25 -0
  242. package/src/lib/point-ask/history-marks.test.ts +94 -0
  243. package/src/lib/point-ask/history-marks.ts +39 -0
  244. package/src/lib/point-ask/host-api.ts +32 -0
  245. package/src/lib/point-ask/outline-box.test.ts +30 -0
  246. package/src/lib/point-ask/outline-box.ts +11 -0
  247. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  248. package/src/lib/point-ask/pick.test.ts +220 -0
  249. package/src/lib/point-ask/pick.ts +121 -0
  250. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  251. package/src/lib/point-ask/pin-machine.ts +217 -0
  252. package/src/lib/point-ask/placement.test.ts +203 -0
  253. package/src/lib/point-ask/placement.ts +141 -0
  254. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  255. package/src/lib/point-ask/projector.ts +83 -0
  256. package/src/lib/point-ask/region.test.ts +157 -0
  257. package/src/lib/point-ask/region.ts +136 -0
  258. package/src/lib/point-ask/retry.test.ts +217 -0
  259. package/src/lib/point-ask/retry.ts +37 -0
  260. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  261. package/src/lib/point-ask/scene-commit.ts +32 -0
  262. package/src/lib/point-ask/session.ts +1391 -0
  263. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  264. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  265. package/src/lib/point-ask/spring.test.ts +63 -0
  266. package/src/lib/point-ask/spring.ts +107 -0
  267. package/src/lib/point-ask/suggestions.test.ts +80 -0
  268. package/src/lib/point-ask/suggestions.ts +125 -0
  269. package/src/lib/point-ask/target-scope.test.ts +622 -0
  270. package/src/lib/point-ask/target-scope.ts +338 -0
  271. package/src/lib/point-ask/types.ts +48 -0
  272. package/src/lib/portable-export.test.ts +34 -1
  273. package/src/lib/portable-export.ts +119 -53
  274. package/src/lib/print-feature-thickness.ts +10 -1
  275. package/src/lib/print-roof-solids.test.ts +39 -1
  276. package/src/lib/print-roof-solids.ts +43 -10
  277. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  278. package/src/lib/room-assembly-overlay.ts +2 -3
  279. package/src/lib/room-selection.test.ts +45 -1
  280. package/src/lib/scene-clipboard.test.ts +246 -1
  281. package/src/lib/scene-clipboard.ts +44 -14
  282. package/src/lib/selection-routing.ts +1 -1
  283. package/src/lib/sfx-bus.ts +13 -0
  284. package/src/lib/sfx-player.test.ts +70 -9
  285. package/src/lib/sfx-player.ts +6 -0
  286. package/src/lib/stair-levels.test.ts +25 -0
  287. package/src/lib/stair-levels.ts +22 -3
  288. package/src/lib/terrace-draft.ts +2 -1
  289. package/src/lib/viewer-selection.ts +22 -11
  290. package/src/lib/wall-placement-pointer.test.ts +42 -0
  291. package/src/lib/wall-placement-pointer.ts +57 -0
  292. package/src/lib/window-changes.ts +35 -0
  293. package/src/store/use-audio.test.ts +146 -0
  294. package/src/store/use-audio.tsx +127 -3
  295. package/src/store/use-construction-reveal.test.ts +173 -0
  296. package/src/store/use-construction-reveal.ts +145 -0
  297. package/src/store/use-corner-snap-hint.test.ts +24 -0
  298. package/src/store/use-corner-snap-hint.ts +47 -0
  299. package/src/store/use-follow-pascal.test.ts +62 -0
  300. package/src/store/use-follow-pascal.ts +53 -0
  301. package/src/store/use-point-ask.ts +247 -0
  302. package/src/store/use-stair-build-preview.ts +25 -5
  303. package/src/store/view-layout.test.ts +27 -1
  304. package/src/store/view-layout.ts +4 -1
  305. package/src/test-utils/render-current-markup.tsx +16 -0
  306. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  307. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  308. package/src/components/tools/stair/stair-defaults.ts +0 -15
  309. package/src/components/tools/stair/stair-tool.tsx +0 -682
@@ -0,0 +1,622 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ BlockNode,
5
+ BuildingNode,
6
+ ColumnNode,
7
+ createBoxBlockTopology,
8
+ DoorNode,
9
+ getBlockFaceFrame,
10
+ getFloorPlacedFootprints,
11
+ getScaledDimensions,
12
+ getWallCurveFrameAt,
13
+ getWallCurveLength,
14
+ ImportedMeshNode,
15
+ ItemNode,
16
+ LevelNode,
17
+ nodeRegistry,
18
+ registerNode,
19
+ SeparatorNode,
20
+ SiteNode,
21
+ SlabNode,
22
+ WallNode,
23
+ ZoneNode,
24
+ } from '@pascal-app/core'
25
+ import { nodeLevelFrame } from '@pascal-app/core/procedural-items'
26
+ import { z } from 'zod'
27
+ import { pointTargetScope } from './target-scope'
28
+
29
+ const rectangle = (x = 0, z = 0, width = 4, depth = 4): [number, number][] => [
30
+ [x, z],
31
+ [x + width, z],
32
+ [x + width, z + depth],
33
+ [x, z + depth],
34
+ ]
35
+
36
+ function scene() {
37
+ const nodes: Record<string, AnyNode> = {}
38
+ const add = <T extends AnyNode>(node: T): T => {
39
+ nodes[node.id] = node
40
+ const parent = node.parentId ? nodes[node.parentId] : undefined
41
+ const children: string[] | undefined =
42
+ parent && 'children' in parent ? parent.children : undefined
43
+ if (children && !children.includes(node.id)) children.push(node.id)
44
+ return node
45
+ }
46
+ const site = add(SiteNode.parse({}))
47
+ const building = add(BuildingNode.parse({ parentId: site.id }))
48
+ const level = add(LevelNode.parse({ parentId: building.id }))
49
+ const upstairs = add(LevelNode.parse({ parentId: building.id, level: 1 }))
50
+ const room = add(
51
+ ZoneNode.parse({
52
+ parentId: level.id,
53
+ name: 'Kitchen',
54
+ polygon: rectangle(),
55
+ spaceRole: 'room',
56
+ }),
57
+ )
58
+ const item = (position: [number, number, number], parentId: string = level.id) =>
59
+ add(
60
+ ItemNode.parse({
61
+ parentId,
62
+ position,
63
+ asset: {
64
+ id: 'fixture',
65
+ name: 'Chair',
66
+ category: 'furniture',
67
+ thumbnail: '',
68
+ src: 'https://example.invalid/chair.glb',
69
+ dimensions: [1, 1, 1],
70
+ },
71
+ }),
72
+ )
73
+ const scope = (before = nodes, current = nodes, targets = [room.id]) =>
74
+ pointTargetScope(targets, before, current)
75
+ return { nodes, add, site, building, level, upstairs, room, item, scope }
76
+ }
77
+
78
+ function expectLevelPosition(
79
+ node: AnyNode,
80
+ nodes: Record<string, AnyNode>,
81
+ expected: [number, number, number],
82
+ ) {
83
+ const position = nodeLevelFrame(node.id, nodes).position
84
+ for (let axis = 0; axis < 3; axis++) expect(position[axis]).toBeCloseTo(expected[axis]!, 8)
85
+ }
86
+
87
+ describe('room membership uses hosting frames, not model frames', () => {
88
+ let restoreRegistry = () => {}
89
+ beforeEach(() => {
90
+ restoreRegistry = nodeRegistry._snapshot()
91
+ nodeRegistry._reset()
92
+ // Headless public registry definition: the real schema and scaled-footprint
93
+ // helper, without importing the host-only nodes plugin into the editor.
94
+ registerNode({
95
+ kind: 'item',
96
+ schemaVersion: 1,
97
+ schema: ItemNode,
98
+ category: 'furnish',
99
+ defaults: () => ({}),
100
+ capabilities: {
101
+ floorPlaced: {
102
+ applies: (node) => node.type === 'item' && !node.asset.attachTo,
103
+ footprint: (node) => {
104
+ if (node.type !== 'item') throw new Error('Expected an item footprint')
105
+ return { dimensions: getScaledDimensions(node), rotation: node.rotation }
106
+ },
107
+ },
108
+ },
109
+ })
110
+ })
111
+ afterEach(() => restoreRegistry())
112
+
113
+ // Analytic oracles: room 0..4, host x=6; child x=-4 is inside at 2,
114
+ // child x=-1.5 is outside at 4.5. Scale belongs to the mesh, not this sum.
115
+ for (const scale of [
116
+ [2, 3, 0.5],
117
+ [0.5, 2, 3],
118
+ ] satisfies [number, number, number][]) {
119
+ test(`item mesh scale ${scale.join('/')} neither drops inside children nor admits outside ones`, () => {
120
+ const s = scene()
121
+ const host = s.item([6, 0, 2])
122
+ host.scale = scale
123
+ const inside = s.item([-4, 1, 0], host.id)
124
+ const outside = s.item([-1.5, 1, 0], host.id)
125
+ const before = structuredClone(s.nodes)
126
+
127
+ expectLevelPosition(inside, s.nodes, [2, 1, 2])
128
+ expectLevelPosition(outside, s.nodes, [4.5, 1, 2])
129
+ const scope = s.scope()
130
+ expect(scope.has(inside.id)).toBe(true)
131
+ expect(scope.has(outside.id)).toBe(false)
132
+ expect(scope.has(host.id)).toBe(false)
133
+ expect(s.nodes).toEqual(before)
134
+ })
135
+ }
136
+
137
+ test('a rotated item host turns metre offsets without stretching them', () => {
138
+ const s = scene()
139
+ const host = s.item([6, 0, 2])
140
+ host.rotation = [0, Math.PI / 2, 0]
141
+ host.scale = [0.5, 3, 2]
142
+ const inside = s.item([0, 1, -4], host.id)
143
+ const outside = s.item([0, 1, -1.5], host.id)
144
+
145
+ expectLevelPosition(inside, s.nodes, [2, 1, 2])
146
+ expectLevelPosition(outside, s.nodes, [4.5, 1, 2])
147
+ const scope = s.scope()
148
+ expect(scope.has(inside.id)).toBe(true)
149
+ expect(scope.has(outside.id)).toBe(false)
150
+ expect(scope.has(host.id)).toBe(false)
151
+ })
152
+
153
+ test('nested rotated hosts preserve child coordinates while its own footprint still scales', () => {
154
+ const s = scene()
155
+ const outer = s.item([10, 0, 2])
156
+ outer.rotation = [0, Math.PI / 2, 0]
157
+ outer.scale = [5, 2, 0.5]
158
+ const inner = s.item([0, 1, -4], outer.id)
159
+ inner.rotation = [0, -Math.PI / 2, 0]
160
+ inner.scale = [2, 3, 4]
161
+ const inside = s.item([-4, 1, 0], inner.id)
162
+ inside.asset.dimensions = [2, 1, 3]
163
+ inside.scale = [3, 2, 4]
164
+ const outside = s.item([-1.5, 1, 0], inner.id)
165
+
166
+ expectLevelPosition(inner, s.nodes, [6, 1, 2])
167
+ expectLevelPosition(inside, s.nodes, [2, 2, 2])
168
+ expectLevelPosition(outside, s.nodes, [4.5, 2, 2])
169
+ const footprint = nodeRegistry.get('item')?.capabilities.floorPlaced
170
+ if (!footprint) throw new Error('Missing registered item footprint')
171
+ expect(getFloorPlacedFootprints(footprint, inside, { nodes: s.nodes })[0]?.dimensions).toEqual([
172
+ 6, 2, 12,
173
+ ])
174
+ const scope = s.scope()
175
+ expect(scope.has(inside.id)).toBe(true)
176
+ for (const node of [outer, inner, outside]) expect(scope.has(node.id)).toBe(false)
177
+ })
178
+
179
+ for (const change of ['moved', 'deleted', 'reparented'] as const) {
180
+ test(`a ${change} child of a scaled host retains before membership without acquiring outside descendants`, () => {
181
+ const s = scene()
182
+ const host = s.item([6, 0, 2])
183
+ host.scale = [2, 3, 4]
184
+ const member = s.item([-4, 1, 0], host.id)
185
+ const incoming = s.item([-1.5, 1, 0], host.id)
186
+ const before = structuredClone(s.nodes)
187
+ incoming.position = [-3, 1, 0]
188
+ if (change === 'moved') member.position = [4, 1, 0]
189
+ else {
190
+ host.children = host.children.filter((id) => id !== member.id)
191
+ if (change === 'deleted') delete s.nodes[member.id]
192
+ else {
193
+ member.parentId = s.upstairs.id
194
+ s.upstairs.children.push(member.id)
195
+ }
196
+ }
197
+ const outsideChild = s.item([0, 1, 0], change === 'deleted' ? host.id : member.id)
198
+
199
+ expect(s.scope(before, before).has(member.id)).toBe(true)
200
+ expect(s.scope().has(member.id)).toBe(false)
201
+ const scope = s.scope(before)
202
+ expect(scope.has(member.id)).toBe(true)
203
+ expect(scope.has(incoming.id)).toBe(true)
204
+ expect(scope.has(outsideChild.id)).toBe(false)
205
+ expect(scope.has(host.id)).toBe(false)
206
+ })
207
+ }
208
+
209
+ // A 4 x 2.4 x 4 box has its back face at z=2, right at x=2 and
210
+ // top at y=2.4. Face UVs are not level XZ. Fixed expected points below
211
+ // come from these planes and quarter-turns, never from Point's transform.
212
+ const placements: {
213
+ name: string
214
+ faceId: string
215
+ hostPosition: [number, number, number]
216
+ hostRotation: number
217
+ position: [number, number, number]
218
+ expected: [number, number, number]
219
+ wrongPlan: [number, number]
220
+ nested?: boolean
221
+ }[] = [
222
+ {
223
+ name: 'offset back face',
224
+ faceId: 'f-back',
225
+ hostPosition: [2, 0, 0],
226
+ hostRotation: 0,
227
+ position: [1, 0.3, 0.2],
228
+ expected: [3, 1.5, 2.2],
229
+ wrongPlan: [3, 0.2],
230
+ },
231
+ {
232
+ name: 'rotated right face',
233
+ faceId: 'f-right',
234
+ hostPosition: [5, 0, 3],
235
+ hostRotation: Math.PI / 2,
236
+ position: [1, -0.2, 0.4],
237
+ expected: [4, 1, 0.6],
238
+ wrongPlan: [5.4, 2],
239
+ },
240
+ {
241
+ name: 'top face',
242
+ faceId: 'f-top',
243
+ hostPosition: [2, 0, 3],
244
+ hostRotation: 0,
245
+ position: [0.75, 1, 0.2],
246
+ expected: [2.75, 2.6, 2],
247
+ wrongPlan: [2.75, 3.2],
248
+ },
249
+ {
250
+ name: 'nested rotated back face',
251
+ faceId: 'f-back',
252
+ hostPosition: [0, 1, -4],
253
+ hostRotation: Math.PI / 2,
254
+ position: [0.75, 0.2, 0.3],
255
+ expected: [5.25, 2.4, 3.7],
256
+ wrongPlan: [5.25, 5.7],
257
+ nested: true,
258
+ },
259
+ ]
260
+ for (const placement of placements) {
261
+ test(`${placement.name} includes the attachment's room, not its naive planar projection`, () => {
262
+ const s = scene()
263
+ const parent = placement.nested ? s.item([10, 0, 6]) : s.level
264
+ if (parent.type === 'item') {
265
+ parent.rotation = [0, Math.PI / 2, 0]
266
+ parent.scale = [2, 3, 4]
267
+ }
268
+ const block = s.add(
269
+ BlockNode.parse({
270
+ parentId: parent.id,
271
+ position: placement.hostPosition,
272
+ rotation: placement.hostRotation,
273
+ topology: createBoxBlockTopology(4, 2.4, 4),
274
+ }),
275
+ )
276
+ const face = getBlockFaceFrame(block.topology, placement.faceId)
277
+ expect(face).not.toBeNull()
278
+ const attached = s.item(placement.position, block.id)
279
+ attached.blockFaceId = placement.faceId
280
+ if (placement.faceId === 'f-top') attached.rotation = [Math.PI / 2, 0, 0]
281
+ else attached.asset.attachTo = 'wall'
282
+ const [x, , z] = placement.expected
283
+ s.room.polygon = rectangle(x - 0.25, z - 0.25, 0.5, 0.5)
284
+ const wrongRoom = s.add(
285
+ ZoneNode.parse({
286
+ parentId: s.level.id,
287
+ name: 'Wrong planar projection',
288
+ polygon: rectangle(
289
+ placement.wrongPlan[0] - 0.25,
290
+ placement.wrongPlan[1] - 0.25,
291
+ 0.5,
292
+ 0.5,
293
+ ),
294
+ }),
295
+ )
296
+
297
+ expectLevelPosition(attached, s.nodes, placement.expected)
298
+ const scope = s.scope()
299
+ expect(scope.has(attached.id)).toBe(true)
300
+ expect(scope.has(block.id)).toBe(false)
301
+ expect(scope.has(parent.id)).toBe(false)
302
+ const wrongScope = s.scope(s.nodes, s.nodes, [wrongRoom.id])
303
+ expect(wrongScope.has(block.id)).toBe(false)
304
+ expect(wrongScope.has(attached.id)).toBe(false)
305
+ })
306
+ }
307
+
308
+ test('descendants use the rotated block attachment frame even when the attachment itself is outside', () => {
309
+ const s = scene()
310
+ const block = s.add(
311
+ BlockNode.parse({
312
+ parentId: s.level.id,
313
+ position: [2, 0, 0],
314
+ topology: createBoxBlockTopology(4, 2.4, 4),
315
+ }),
316
+ )
317
+ const attached = s.item([1, 0.3, 0.2], block.id)
318
+ attached.blockFaceId = 'f-back'
319
+ attached.asset.attachTo = 'wall'
320
+ attached.rotation = [0, Math.PI / 2, 0]
321
+ attached.scale = [2, 3, 4]
322
+ const child = s.item([0, 1, -2], attached.id)
323
+ s.room.polygon = rectangle(0.75, 1.95, 0.5, 0.5)
324
+
325
+ expectLevelPosition(attached, s.nodes, [3, 1.5, 2.2])
326
+ expectLevelPosition(child, s.nodes, [1, 2.5, 2.2])
327
+ const scope = s.scope()
328
+ expect(scope.has(child.id)).toBe(true)
329
+ expect(scope.has(attached.id)).toBe(false)
330
+ expect(scope.has(block.id)).toBe(false)
331
+ })
332
+
333
+ test('a moved block attachment stays in the before/current union, not its new outside sibling', () => {
334
+ const s = scene()
335
+ const block = s.add(
336
+ BlockNode.parse({
337
+ parentId: s.level.id,
338
+ position: [2, 0, 0],
339
+ topology: createBoxBlockTopology(4, 2.4, 4),
340
+ }),
341
+ )
342
+ const attached = s.item([1, 0.3, 0.2], block.id)
343
+ attached.blockFaceId = 'f-back'
344
+ s.room.polygon = rectangle(2.75, 1.95, 0.5, 0.5)
345
+ const before = structuredClone(s.nodes)
346
+ block.position = [12, 0, 0]
347
+ const outside = s.item([1, 0.3, 0.2], block.id)
348
+ outside.blockFaceId = 'f-back'
349
+
350
+ expect(s.scope(before, before).has(attached.id)).toBe(true)
351
+ expect(s.scope().has(attached.id)).toBe(false)
352
+ const scope = s.scope(before)
353
+ expect(scope.has(attached.id)).toBe(true)
354
+ expect(scope.has(outside.id)).toBe(false)
355
+ expect(scope.has(block.id)).toBe(false)
356
+ })
357
+ })
358
+
359
+ describe('a pointed room includes spatial siblings without including its whole level', () => {
360
+ test('includes furnishing, columns, captured meshes and polygon centres, not ancestors or another room', () => {
361
+ const s = scene()
362
+ const fridge = s.item([2, 0, 2])
363
+ const column = s.add(ColumnNode.parse({ parentId: s.level.id, position: [1, 0, 1] }))
364
+ const mesh = s.add(ImportedMeshNode.parse({ parentId: s.level.id, position: [3, 0, 3] }))
365
+ const slab = s.add(SlabNode.parse({ parentId: s.level.id, polygon: rectangle(1, 1, 2, 2) }))
366
+ const outside = s.item([8, 0, 2])
367
+ const upper = s.item([2, 0, 2], s.upstairs.id)
368
+ const otherRoom = s.add(
369
+ ZoneNode.parse({ parentId: s.level.id, name: 'Overlay', polygon: rectangle() }),
370
+ )
371
+ const before = structuredClone(s.nodes)
372
+
373
+ const scope = s.scope()
374
+
375
+ for (const node of [s.room, fridge, column, mesh, slab]) expect(scope.has(node.id)).toBe(true)
376
+ for (const node of [outside, upper, otherRoom, s.level, s.upstairs, s.building, s.site])
377
+ expect(scope.has(node.id)).toBe(false)
378
+ expect(s.nodes).toEqual(before)
379
+ })
380
+
381
+ test('includes boundary rims, excludes hole interiors', () => {
382
+ const s = scene()
383
+ s.room.holes = [rectangle(1, 1, 2, 2)]
384
+ const hole = s.item([2, 0, 2])
385
+ const holeRim = s.item([1, 0, 2])
386
+ const outerRim = s.item([0, 0, 2])
387
+
388
+ const scope = s.scope()
389
+
390
+ expect(scope.has(hole.id)).toBe(false)
391
+ expect(scope.has(holeRim.id)).toBe(true)
392
+ expect(scope.has(outerRim.id)).toBe(true)
393
+ })
394
+
395
+ test('uses declared custom footprints rather than the node origin, with the snapshot context', () => {
396
+ const s = scene()
397
+ const restore = nodeRegistry._snapshot()
398
+ const schema = ItemNode.extend({ type: z.literal('test:point-footprint') })
399
+ let applies = true
400
+ let calls = 0
401
+ try {
402
+ registerNode({
403
+ kind: 'test:point-footprint',
404
+ schemaVersion: 1,
405
+ schema,
406
+ category: 'furnish',
407
+ defaults: () => ({}),
408
+ capabilities: {
409
+ floorPlaced: {
410
+ applies: () => applies,
411
+ footprint: (_node, context) => {
412
+ calls += 1
413
+ expect(context?.nodes).toBe(s.nodes)
414
+ return { dimensions: [1, 1, 1], rotation: [0, 0, 0], position: [2, 0, 2] }
415
+ },
416
+ },
417
+ },
418
+ })
419
+ // Registered extension kinds are not represented in core's static builtin union.
420
+ const custom = s.add(
421
+ schema.parse({
422
+ ...s.item([8, 0, 8]),
423
+ id: 'item_custom_scope',
424
+ type: 'test:point-footprint',
425
+ }) as unknown as AnyNode,
426
+ )
427
+ expect(s.scope().has(custom.id)).toBe(true)
428
+ const resolved = calls
429
+ applies = false
430
+ expect(s.scope().has(custom.id)).toBe(false)
431
+ expect(calls).toBe(resolved)
432
+ } finally {
433
+ restore()
434
+ }
435
+ })
436
+
437
+ test('keeps descendants of a scoped member even when their own centre is outside', () => {
438
+ const s = scene()
439
+ const table = s.item([2, 0, 2])
440
+ const child = s.item([20, 1, 20], table.id)
441
+ const nested = s.item([20, 1, 20], child.id)
442
+ expect(s.scope().has(nested.id)).toBe(true)
443
+ })
444
+
445
+ test('composes nested local position and rotation when the parent centre is outside', () => {
446
+ const s = scene()
447
+ const table = s.item([6, 0, 2])
448
+ table.rotation = [0, Math.PI / 2, 0]
449
+ const child = s.item([0, 1, -3], table.id)
450
+
451
+ const scope = s.scope()
452
+
453
+ expect(scope.has(table.id)).toBe(false)
454
+ expect(scope.has(child.id)).toBe(true)
455
+ })
456
+
457
+ test('compares level-local coordinates despite a translated and rotated building', () => {
458
+ const s = scene()
459
+ s.building.position = [100, 0, 100]
460
+ s.building.rotation = [0, Math.PI / 2, 0]
461
+ const item = s.item([2, 0, 2])
462
+ expect(s.scope().has(item.id)).toBe(true)
463
+ })
464
+ })
465
+
466
+ describe('room bounding walls and their hosts', () => {
467
+ test('ignores invalid stored boundary kinds and walls on another level', () => {
468
+ const s = scene()
469
+ const upperWall = s.add(WallNode.parse({ parentId: s.upstairs.id, start: [8, 0], end: [8, 4] }))
470
+ s.room.boundaryWallIds = [s.level.id as never, upperWall.id]
471
+ const upperChild = s.item([2, 1, 0], upperWall.id)
472
+ const outside = s.item([8, 0, 8])
473
+
474
+ const scope = s.scope()
475
+
476
+ for (const node of [s.level, upperWall, upperChild, outside])
477
+ expect(scope.has(node.id)).toBe(false)
478
+ })
479
+
480
+ test('includes stored inset boundaries, explicit wallId-only openings and descendants', () => {
481
+ const s = scene()
482
+ s.room.polygon = rectangle(0.2, 0.2, 3.6, 3.6)
483
+ const wall = s.add(WallNode.parse({ parentId: s.level.id, start: [0, 0], end: [4, 0] }))
484
+ s.room.boundaryWallIds = [wall.id]
485
+ const door = s.add(
486
+ DoorNode.parse({ parentId: s.level.id, wallId: wall.id, position: [2, 1, 0] }),
487
+ )
488
+ const picture = s.item([2, 1, 0], wall.id)
489
+ picture.wallId = wall.id
490
+ const nested = s.item([20, 1, 20], picture.id)
491
+
492
+ const scope = s.scope()
493
+
494
+ for (const node of [wall, door, picture, nested]) expect(scope.has(node.id)).toBe(true)
495
+ })
496
+
497
+ test('infers bounding walls for a hand-drawn inset room without stored associations', () => {
498
+ const s = scene()
499
+ s.room.polygon = rectangle(0.2, 0.2, 3.6, 3.6)
500
+ const polygon = rectangle()
501
+ const walls = polygon.map((start, index) =>
502
+ s.add(
503
+ WallNode.parse({
504
+ parentId: s.level.id,
505
+ start,
506
+ end: polygon[(index + 1) % polygon.length],
507
+ }),
508
+ ),
509
+ )
510
+ const remote = s.add(WallNode.parse({ parentId: s.level.id, start: [10, 0], end: [10, 4] }))
511
+
512
+ const scope = s.scope()
513
+
514
+ for (const wall of walls) expect(scope.has(wall.id)).toBe(true)
515
+ expect(scope.has(remote.id)).toBe(false)
516
+ })
517
+
518
+ test('a party wall belongs to both rooms and separators retain their children', () => {
519
+ const s = scene()
520
+ const neighbor = s.add(
521
+ ZoneNode.parse({ parentId: s.level.id, name: 'Living', polygon: rectangle(4, 0) }),
522
+ )
523
+ const party = s.add(WallNode.parse({ parentId: s.level.id, start: [4, 0], end: [4, 4] }))
524
+ const separator = s.add(
525
+ SeparatorNode.parse({ parentId: s.level.id, start: [0, 0], end: [4, 0] }),
526
+ )
527
+ s.room.boundarySeparatorIds = [separator.id]
528
+ const child = s.item([20, 0, 20], separator.id)
529
+
530
+ expect(s.scope().has(party.id)).toBe(true)
531
+ expect(s.scope(s.nodes, s.nodes, [neighbor.id]).has(party.id)).toBe(true)
532
+ expect(s.scope().has(child.id)).toBe(true)
533
+ })
534
+
535
+ test('does not mistake a remote wall-hosted local station for a room coordinate', () => {
536
+ const s = scene()
537
+ const remote = s.add(WallNode.parse({ parentId: s.level.id, start: [8, 0], end: [8, 4] }))
538
+ const picture = s.item([2, 1, 0], remote.id)
539
+ picture.wallId = remote.id
540
+ expect(s.scope().has(picture.id)).toBe(false)
541
+ })
542
+
543
+ test('uses the curved wall station for a hosted member, not its chord', () => {
544
+ const s = scene()
545
+ const wall = s.add(
546
+ WallNode.parse({ parentId: s.level.id, start: [6, -2], end: [6, 6], curveOffset: 4 }),
547
+ )
548
+ const middle = getWallCurveFrameAt(wall, 0.5).point
549
+ s.room.polygon = rectangle(middle.x - 0.5, middle.y - 0.5, 1, 1)
550
+ const picture = s.item([getWallCurveLength(wall) / 2, 1, 0], wall.id)
551
+ picture.wallId = wall.id
552
+ expect(s.scope().has(picture.id)).toBe(true)
553
+ })
554
+ })
555
+
556
+ describe('membership before or after the write', () => {
557
+ test('retains moved-out members and recognizes moved-in members', () => {
558
+ const s = scene()
559
+ const out = s.item([2, 0, 2])
560
+ const incoming = s.item([8, 0, 2])
561
+ const before = structuredClone(s.nodes)
562
+ out.position = [8, 0, 2]
563
+ incoming.position = [2, 0, 2]
564
+ const scope = s.scope(before)
565
+ expect(scope.has(out.id)).toBe(true)
566
+ expect(scope.has(incoming.id)).toBe(true)
567
+ })
568
+
569
+ test('retains a deleted member and a member reparented to another level', () => {
570
+ const s = scene()
571
+ const deleted = s.item([2, 0, 2])
572
+ const moved = s.item([2, 0, 2])
573
+ const before = structuredClone(s.nodes)
574
+ delete s.nodes[deleted.id]
575
+ moved.parentId = s.upstairs.id
576
+ s.level.children = s.level.children.filter((id) => id !== moved.id && id !== deleted.id)
577
+ s.upstairs.children.push(moved.id)
578
+ const scope = s.scope(before)
579
+ expect(scope.has(deleted.id)).toBe(true)
580
+ expect(scope.has(moved.id)).toBe(true)
581
+ })
582
+
583
+ test('uses each snapshot room footprint and retains a deleted zone', () => {
584
+ const s = scene()
585
+ const oldMember = s.item([2, 0, 2])
586
+ const newMember = s.item([10, 0, 2])
587
+ const before = structuredClone(s.nodes)
588
+ s.room.polygon = rectangle(8, 0)
589
+ expect(s.scope(before).has(oldMember.id)).toBe(true)
590
+ expect(s.scope(before).has(newMember.id)).toBe(true)
591
+ delete s.nodes[s.room.id]
592
+ expect(s.scope(before).has(oldMember.id)).toBe(true)
593
+ })
594
+
595
+ test('does not mix before-only membership with children created outside afterward', () => {
596
+ const s = scene()
597
+ const table = s.item([2, 0, 2])
598
+ const before = structuredClone(s.nodes)
599
+ table.position = [8, 0, 8]
600
+ const outsideChild = s.item([0, 1, 0], table.id)
601
+ const scope = s.scope(before)
602
+ expect(scope.has(table.id)).toBe(true)
603
+ expect(scope.has(outsideChild.id)).toBe(false)
604
+ })
605
+
606
+ test('retains ordinary target ancestry before reparenting and terminates on cycles', () => {
607
+ const s = scene()
608
+ const target = s.item([8, 0, 8])
609
+ const child = s.item([1, 1, 1], target.id)
610
+ const before = structuredClone(s.nodes)
611
+ target.children = []
612
+ child.parentId = s.level.id
613
+ expect(pointTargetScope([target.id], before, s.nodes).has(child.id)).toBe(true)
614
+ target.parentId = child.id
615
+ child.parentId = target.id
616
+ target.children = [child.id]
617
+ child.children = [target.id]
618
+ expect(new Set(pointTargetScope([target.id], s.nodes, s.nodes))).toEqual(
619
+ new Set([target.id, child.id]),
620
+ )
621
+ })
622
+ })