@pascal-app/editor 0.9.2 → 1.0.0-beta.1

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 (203) hide show
  1. package/package.json +9 -7
  2. package/src/components/editor/bake-exporter.tsx +3 -1
  3. package/src/components/editor/bake-thumbnail.tsx +95 -0
  4. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  5. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  6. package/src/components/editor/custom-camera-controls.tsx +307 -212
  7. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  8. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
  10. package/src/components/editor/first-person/build-collider-world.ts +20 -0
  11. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  12. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  13. package/src/components/editor/first-person-controls.tsx +295 -132
  14. package/src/components/editor/floating-action-menu.tsx +59 -65
  15. package/src/components/editor/floorplan-background-selection.test.ts +16 -2
  16. package/src/components/editor/floorplan-background-selection.ts +4 -18
  17. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  18. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  20. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  21. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  22. package/src/components/editor/floorplan-panel.tsx +931 -861
  23. package/src/components/editor/group-actions.ts +155 -15
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-rotate-handle.tsx +1 -1
  26. package/src/components/editor/group-selection-box-3d.tsx +1 -1
  27. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  28. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  29. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  30. package/src/components/editor/handles/preview-overrides.ts +13 -0
  31. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  32. package/src/components/editor/handles/resize-snap.ts +23 -0
  33. package/src/components/editor/handles/use-handle-drag.ts +17 -7
  34. package/src/components/editor/index.tsx +42 -9
  35. package/src/components/editor/measurement-pill.tsx +6 -1
  36. package/src/components/editor/node-arrow-handles.tsx +51 -15
  37. package/src/components/editor/quick-measurement-card.tsx +9 -3
  38. package/src/components/editor/quick-measurement-hud.tsx +7 -1
  39. package/src/components/editor/selection-manager.tsx +3 -1
  40. package/src/components/editor/site-edge-labels.tsx +24 -2
  41. package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
  42. package/src/components/editor/thumbnail-generator.tsx +66 -294
  43. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  44. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  45. package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
  46. package/src/components/editor/wall-measurement-label.tsx +8 -16
  47. package/src/components/editor/wall-move-side-handles.tsx +193 -18
  48. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  49. package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
  52. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  53. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  54. package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
  56. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
  59. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  60. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  62. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  63. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  64. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  65. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  66. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  67. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
  68. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
  69. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
  70. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
  72. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  73. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
  74. package/src/components/tools/fence/fence-drafting.ts +27 -0
  75. package/src/components/tools/item/use-draft-node.ts +131 -72
  76. package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
  77. package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
  78. package/src/components/tools/roof/roof-tool.tsx +16 -0
  79. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  80. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  81. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  82. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  83. package/src/components/tools/site/site-boundary-editor.tsx +13 -6
  84. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  85. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  86. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  89. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  90. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  91. package/src/components/tools/stair/stair-tool.tsx +98 -47
  92. package/src/components/tools/tool-manager.tsx +67 -9
  93. package/src/components/tools/wall/wall-drafting.test.ts +118 -1
  94. package/src/components/tools/wall/wall-drafting.ts +128 -9
  95. package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
  96. package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
  97. package/src/components/tools/zone/zone-tool.tsx +16 -0
  98. package/src/components/ui/action-menu/control-modes.tsx +4 -1
  99. package/src/components/ui/action-menu/measurement-control.tsx +107 -28
  100. package/src/components/ui/command-palette/editor-commands.tsx +14 -1
  101. package/src/components/ui/controls/material-paint-panel.tsx +7 -1
  102. package/src/components/ui/controls/material-picker.tsx +86 -7
  103. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  104. package/src/components/ui/floating-level-selector.tsx +73 -19
  105. package/src/components/ui/helpers/helper-manager.tsx +46 -0
  106. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  107. package/src/components/ui/panels/panel-manager.tsx +14 -1
  108. package/src/components/ui/panels/parametric-inspector.tsx +4 -2
  109. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  110. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
  112. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
  113. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  114. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
  115. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  116. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  117. package/src/components/viewer-overlay.tsx +20 -655
  118. package/src/components/walkthrough-hud.tsx +111 -0
  119. package/src/hooks/use-grid-events.ts +24 -0
  120. package/src/hooks/use-keyboard.ts +53 -30
  121. package/src/index.tsx +137 -4
  122. package/src/lib/active-placement-surface.test.ts +24 -0
  123. package/src/lib/active-placement-surface.ts +8 -1
  124. package/src/lib/camera-pose.test.ts +237 -0
  125. package/src/lib/camera-pose.ts +172 -0
  126. package/src/lib/direct-manipulation.test.ts +83 -0
  127. package/src/lib/direct-manipulation.ts +9 -0
  128. package/src/lib/door-interaction.ts +1 -1
  129. package/src/lib/elevation-guides.test.ts +107 -0
  130. package/src/lib/elevation-guides.ts +236 -0
  131. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  132. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  133. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  134. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  135. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  136. package/src/lib/floorplan/floorplan-export.tsx +745 -131
  137. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  138. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  139. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  140. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  141. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  144. package/src/lib/floorplan/geometry.ts +2 -3
  145. package/src/lib/fresh-planar-placement.test.ts +44 -1
  146. package/src/lib/fresh-planar-placement.ts +21 -4
  147. package/src/lib/glb-export.test.ts +116 -1
  148. package/src/lib/glb-export.ts +204 -7
  149. package/src/lib/ground-surface.ts +95 -0
  150. package/src/lib/history.test.ts +96 -0
  151. package/src/lib/history.ts +22 -0
  152. package/src/lib/interaction/overlay-policy.test.ts +2 -1
  153. package/src/lib/interaction/scope.ts +40 -8
  154. package/src/lib/level-duplication.test.ts +2 -1
  155. package/src/lib/measurements.test.ts +5 -0
  156. package/src/lib/measurements.ts +12 -1
  157. package/src/lib/quick-action-feedback.ts +33 -0
  158. package/src/lib/quick-action-nodes.test.ts +94 -0
  159. package/src/lib/quick-action-nodes.ts +47 -0
  160. package/src/lib/scene-clipboard.test.ts +130 -1
  161. package/src/lib/scene-clipboard.ts +237 -13
  162. package/src/lib/selection-routing.test.ts +16 -1
  163. package/src/lib/selection-routing.ts +5 -0
  164. package/src/lib/sfx-bus.ts +33 -2
  165. package/src/lib/sfx-player.test.ts +148 -6
  166. package/src/lib/sfx-player.ts +156 -8
  167. package/src/lib/site-boundary.test.ts +53 -0
  168. package/src/lib/site-boundary.ts +27 -0
  169. package/src/lib/snapping-mode.test.ts +29 -2
  170. package/src/lib/snapping-mode.ts +15 -3
  171. package/src/lib/stair-duplication.test.ts +103 -0
  172. package/src/lib/stair-duplication.ts +57 -85
  173. package/src/lib/stair-levels.ts +2 -0
  174. package/src/lib/terrain-sculpt.test.ts +429 -0
  175. package/src/lib/terrain-sculpt.ts +301 -0
  176. package/src/lib/terrain-verb-color.test.ts +51 -0
  177. package/src/lib/terrain-verb-color.ts +58 -0
  178. package/src/lib/touch-gesture-priority.test.ts +45 -0
  179. package/src/lib/touch-gesture-priority.ts +30 -0
  180. package/src/lib/window-interaction.ts +1 -1
  181. package/src/store/camera-pose-store.test.ts +29 -0
  182. package/src/store/camera-pose-store.ts +27 -0
  183. package/src/store/live-draft-preview-stores.test.ts +136 -0
  184. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  185. package/src/store/navigation-sync-pose-store.ts +27 -0
  186. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  187. package/src/store/use-delete-confirmation.ts +27 -0
  188. package/src/store/use-drawing-view.test.ts +23 -0
  189. package/src/store/use-drawing-view.ts +84 -0
  190. package/src/store/use-editor.tsx +184 -15
  191. package/src/store/use-elevation-guides.ts +40 -0
  192. package/src/store/use-fence-curve-draft.ts +38 -8
  193. package/src/store/use-first-person-hud.ts +40 -0
  194. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  195. package/src/store/use-floorplan-draft-preview.ts +54 -4
  196. package/src/store/use-floorplan-mode.ts +110 -0
  197. package/src/store/use-interaction-scope.test.ts +23 -3
  198. package/src/store/use-interaction-scope.ts +8 -0
  199. package/src/store/use-measurement-draft.test.ts +29 -0
  200. package/src/store/use-measurement-draft.ts +33 -6
  201. package/src/store/use-path-draft-preview.ts +90 -0
  202. package/src/store/use-stair-build-preview.ts +12 -2
  203. package/src/lib/floorplan/selection-tool.ts +0 -271
@@ -1,15 +1,13 @@
1
1
  import {
2
+ type AnyNode,
2
3
  type AnyNodeId,
3
- generateId,
4
4
  type StairNode,
5
- StairNode as StairNodeSchema,
6
5
  type StairSegmentNode,
7
- StairSegmentNode as StairSegmentNodeSchema,
8
- sceneRegistry,
9
6
  useScene,
10
7
  } from '@pascal-app/core'
11
8
  import { useViewer } from '@pascal-app/viewer'
12
9
  import useEditor from '../store/use-editor'
10
+ import { commitFreshPlacementSubtree, createFreshPlacementSubtree } from './fresh-planar-placement'
13
11
 
14
12
  type DuplicateStairOptions = {
15
13
  mode?: 'select' | 'move'
@@ -22,47 +20,19 @@ type DuplicateStairResult = {
22
20
  segmentIds: StairSegmentNode['id'][]
23
21
  }
24
22
 
25
- const MOVE_REGISTRY_RETRY_LIMIT = 12
26
-
27
- function stripDuplicateFlags(metadata: unknown) {
28
- if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
29
- return metadata
30
- }
31
-
32
- const nextMeta = { ...(metadata as Record<string, unknown>) }
33
- delete nextMeta.isNew
34
- delete nextMeta.isTransient
35
- return nextMeta
36
- }
37
-
38
- function moveStairWhenRegistered(stairId: StairNode['id'], attempt = 0) {
39
- const latestStair = useScene.getState().nodes[stairId as AnyNodeId]
40
- if (latestStair?.type !== 'stair') {
41
- return
42
- }
43
-
44
- if (sceneRegistry.nodes.has(stairId)) {
45
- useEditor.getState().setMovingNode(latestStair)
46
- useViewer.getState().setSelection({ selectedIds: [] })
47
- return
48
- }
49
-
50
- if (attempt >= MOVE_REGISTRY_RETRY_LIMIT) {
51
- console.warn(`Duplicated stair "${stairId}" did not register before move mode started`)
52
- return
53
- }
54
-
55
- requestAnimationFrame(() => moveStairWhenRegistered(stairId, attempt + 1))
56
- }
57
-
23
+ /**
24
+ * Duplicates a stair through the shared fresh-subtree placement path.
25
+ *
26
+ * The draft passed to the move tool is the exact node stored in the scene,
27
+ * so its geometry, descendants, selection identity, and eventual commit all
28
+ * use one fresh ID graph.
29
+ */
58
30
  export function duplicateStairSubtree(
59
31
  sourceStairId: AnyNodeId,
60
32
  options: DuplicateStairOptions = {},
61
33
  ): DuplicateStairResult {
62
34
  const { mode = 'move', offset = [1, 0, 1], parentId: explicitParentId } = options
63
-
64
- const scene = useScene.getState()
65
- const sourceStair = scene.nodes[sourceStairId]
35
+ const sourceStair = useScene.getState().nodes[sourceStairId]
66
36
 
67
37
  if (sourceStair?.type !== 'stair') {
68
38
  throw new Error(`Node "${sourceStairId}" is not a stair`)
@@ -73,54 +43,56 @@ export function duplicateStairSubtree(
73
43
  throw new Error(`Stair "${sourceStairId}" is missing a parent level`)
74
44
  }
75
45
 
76
- const stairClone = StairNodeSchema.parse({
77
- ...structuredClone(sourceStair),
78
- id: generateId('stair'),
79
- parentId,
80
- children: [],
81
- position: [
82
- sourceStair.position[0] + offset[0],
83
- sourceStair.position[1] + offset[1],
84
- sourceStair.position[2] + offset[2],
85
- ] as StairNode['position'],
86
- metadata: stripDuplicateFlags(sourceStair.metadata),
87
- })
88
-
89
- const segmentClones: StairSegmentNode[] = []
90
- for (const childId of sourceStair.children ?? []) {
91
- const childNode = scene.nodes[childId as AnyNodeId]
92
- if (childNode?.type !== 'stair-segment') {
93
- continue
46
+ const temporal = useScene.temporal.getState()
47
+ const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
48
+ if (wasTracking) temporal.pause()
49
+
50
+ let draftId: AnyNodeId | null = null
51
+ try {
52
+ draftId = createFreshPlacementSubtree(sourceStairId, {
53
+ parentId,
54
+ position: [
55
+ sourceStair.position[0] + offset[0],
56
+ sourceStair.position[1] + offset[1],
57
+ sourceStair.position[2] + offset[2],
58
+ ],
59
+ } as Partial<AnyNode>)
60
+ const draft = draftId ? useScene.getState().nodes[draftId] : null
61
+ if (draft?.type !== 'stair') {
62
+ throw new Error(`Duplicated stair "${sourceStairId}" was not created`)
94
63
  }
95
64
 
96
- const childClone = StairSegmentNodeSchema.parse({
97
- ...structuredClone(childNode),
98
- id: generateId('sseg'),
99
- parentId: stairClone.id,
100
- metadata: stripDuplicateFlags(childNode.metadata),
101
- })
102
- segmentClones.push(childClone)
103
- }
104
-
105
- scene.createNodes([
106
- { node: stairClone, parentId },
107
- ...segmentClones.map((segment) => ({ node: segment, parentId: stairClone.id as AnyNodeId })),
108
- ])
109
-
110
- const createdStair = useScene.getState().nodes[stairClone.id as AnyNodeId]
111
- if (createdStair?.type !== 'stair') {
112
- throw new Error(`Duplicated stair "${stairClone.id}" was not created`)
113
- }
65
+ if (mode === 'select') {
66
+ const committedId = commitFreshPlacementSubtree(draft.id as AnyNodeId, {})
67
+ const committed = committedId ? useScene.getState().nodes[committedId] : null
68
+ if (committed?.type !== 'stair') {
69
+ throw new Error(`Duplicated stair "${sourceStairId}" could not be committed`)
70
+ }
71
+ if (wasTracking) temporal.resume()
72
+ useViewer.getState().setSelection({ selectedIds: [committed.id] })
73
+ return {
74
+ stair: committed,
75
+ segmentIds: stairSegmentIds(committed),
76
+ }
77
+ }
114
78
 
115
- if (mode === 'select') {
116
- useViewer.getState().setSelection({ selectedIds: [createdStair.id] })
117
- } else {
118
- useViewer.getState().setSelection({ selectedIds: [createdStair.id] })
119
- requestAnimationFrame(() => moveStairWhenRegistered(createdStair.id))
79
+ useViewer.getState().setSelection({ selectedIds: [] })
80
+ useEditor.getState().setMovingNode(draft)
81
+ return {
82
+ stair: draft,
83
+ segmentIds: stairSegmentIds(draft),
84
+ }
85
+ } catch (error) {
86
+ if (draftId && useScene.getState().nodes[draftId]) {
87
+ useScene.getState().deleteNode(draftId)
88
+ }
89
+ if (wasTracking) temporal.resume()
90
+ throw error
120
91
  }
92
+ }
121
93
 
122
- return {
123
- stair: createdStair,
124
- segmentIds: segmentClones.map((segment) => segment.id),
125
- }
94
+ function stairSegmentIds(stair: StairNode): StairSegmentNode['id'][] {
95
+ return (stair.children ?? []).filter((childId): childId is StairSegmentNode['id'] => {
96
+ return useScene.getState().nodes[childId as AnyNodeId]?.type === 'stair-segment'
97
+ })
126
98
  }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  type AnyNodeId,
4
+ DEFAULT_LEVEL_HEIGHT,
4
5
  LevelNode,
5
6
  type LevelNode as LevelNodeType,
6
7
  resolveBuildingForLevel,
@@ -154,6 +155,7 @@ export function resolveStairDestinationLevel({
154
155
  if (createMissing && buildingId) {
155
156
  const createdLevel = LevelNode.parse({
156
157
  children: [],
158
+ height: DEFAULT_LEVEL_HEIGHT,
157
159
  level: fromLevel.level + 1,
158
160
  parentId: buildingId,
159
161
  })
@@ -0,0 +1,429 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ createTerrainField,
4
+ DEFAULT_TERRAIN_SPACING,
5
+ heightAt,
6
+ minBrushRadius,
7
+ type SiteNode,
8
+ terrainFieldOf,
9
+ useLiveTerrain,
10
+ useScene,
11
+ } from '@pascal-app/core'
12
+ import {
13
+ brushRadiusRange,
14
+ clampBrushRadius,
15
+ clipTerrainPatchToSite,
16
+ fieldExtentForSite,
17
+ flattenSite,
18
+ resetSiteTerrain,
19
+ resolveFlattenTarget,
20
+ sculptFieldForSite,
21
+ terrainPointInsideSite,
22
+ } from './terrain-sculpt'
23
+
24
+ // `updateNode` batches its dirty-node flush through rAF, which bun's runtime does
25
+ // not provide. Running the callback synchronously is what the other scene-writing
26
+ // unit tests do (`handle-drag-history.test.ts`).
27
+ type RafFn = (callback: (time: number) => void) => number
28
+ ;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (callback) => {
29
+ callback(0)
30
+ return 0
31
+ }
32
+ ;(globalThis as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= () => {}
33
+
34
+ function site(points: Array<[number, number]>): SiteNode {
35
+ return {
36
+ id: 'site_1',
37
+ type: 'site',
38
+ children: [],
39
+ polygon: { type: 'polygon', points },
40
+ } as unknown as SiteNode
41
+ }
42
+
43
+ describe('fieldExtentForSite', () => {
44
+ test('covers the whole polygon with padding to spare', () => {
45
+ const extent = fieldExtentForSite(
46
+ site([
47
+ [-10, -10],
48
+ [10, -10],
49
+ [10, 10],
50
+ [-10, 10],
51
+ ]),
52
+ )
53
+ const maxX = extent.origin[0] + (extent.cols - 1) * extent.spacing
54
+ const maxZ = extent.origin[1] + (extent.rows - 1) * extent.spacing
55
+
56
+ // Strictly outside the lot on all four sides: the field must not end at the
57
+ // property line, or the terrain mesh stops exactly where the boundary is
58
+ // drawn and leaves a visible cliff.
59
+ expect(extent.origin[0]).toBeLessThan(-10)
60
+ expect(extent.origin[1]).toBeLessThan(-10)
61
+ expect(maxX).toBeGreaterThan(10)
62
+ expect(maxZ).toBeGreaterThan(10)
63
+ })
64
+
65
+ test('is square and 2ⁿ+1 so a future LOD halving lands on real samples', () => {
66
+ for (const half of [4, 12, 30, 80]) {
67
+ const extent = fieldExtentForSite(
68
+ site([
69
+ [-half, -half],
70
+ [half, -half],
71
+ [half, half],
72
+ [-half, half],
73
+ ]),
74
+ )
75
+ expect(extent.cols).toBe(extent.rows)
76
+ expect([33, 65, 129, 257]).toContain(extent.cols)
77
+ }
78
+ })
79
+
80
+ test('centres on the lot even when the lot is off-origin', () => {
81
+ const extent = fieldExtentForSite(
82
+ site([
83
+ [100, 40],
84
+ [120, 40],
85
+ [120, 60],
86
+ [100, 60],
87
+ ]),
88
+ )
89
+ const centerX = extent.origin[0] + ((extent.cols - 1) / 2) * extent.spacing
90
+ const centerZ = extent.origin[1] + ((extent.rows - 1) / 2) * extent.spacing
91
+ expect(centerX).toBeCloseTo(110, 6)
92
+ expect(centerZ).toBeCloseTo(50, 6)
93
+ })
94
+
95
+ test('holds the default spacing while the lot fits in 257 samples', () => {
96
+ const extent = fieldExtentForSite(
97
+ site([
98
+ [-20, -20],
99
+ [20, -20],
100
+ [20, 20],
101
+ [-20, 20],
102
+ ]),
103
+ )
104
+ expect(extent.spacing).toBeCloseTo(DEFAULT_TERRAIN_SPACING, 6)
105
+ })
106
+
107
+ test('stretches spacing rather than stopping short on a huge lot', () => {
108
+ // A 400 m lot needs 801 samples at 0.5 m. Capping the sample count and
109
+ // coarsening is the right trade: coarse ground everywhere beats fine ground
110
+ // that ends mid-lot.
111
+ const extent = fieldExtentForSite(
112
+ site([
113
+ [-200, -200],
114
+ [200, -200],
115
+ [200, 200],
116
+ [-200, 200],
117
+ ]),
118
+ )
119
+ expect(extent.cols).toBe(257)
120
+ expect(extent.spacing).toBeGreaterThan(DEFAULT_TERRAIN_SPACING)
121
+ const maxX = extent.origin[0] + (extent.cols - 1) * extent.spacing
122
+ expect(maxX).toBeGreaterThan(200)
123
+ })
124
+
125
+ test('a degenerate polygon still yields a usable field', () => {
126
+ for (const points of [[], [[0, 0]] as Array<[number, number]>]) {
127
+ const extent = fieldExtentForSite(site(points as Array<[number, number]>))
128
+ expect(extent.cols).toBe(65)
129
+ expect(extent.spacing).toBeCloseTo(DEFAULT_TERRAIN_SPACING, 6)
130
+ }
131
+ })
132
+
133
+ test('a null site does not throw', () => {
134
+ expect(fieldExtentForSite(null).cols).toBe(65)
135
+ expect(fieldExtentForSite(undefined).cols).toBe(65)
136
+ })
137
+ })
138
+
139
+ describe('sculptFieldForSite', () => {
140
+ test('a virgin site gets a fresh flat field sized to its lot', () => {
141
+ const target = site([
142
+ [-10, -10],
143
+ [10, -10],
144
+ [10, 10],
145
+ [-10, 10],
146
+ ])
147
+ const field = sculptFieldForSite(target)
148
+ expect(field.cols).toBe(fieldExtentForSite(target).cols)
149
+ expect(heightAt(field, 0, 0)).toBeCloseTo(0, 6)
150
+ })
151
+ })
152
+
153
+ describe('site footprint', () => {
154
+ const concaveSite = site([
155
+ [0, 0],
156
+ [4, 0],
157
+ [4, 2],
158
+ [2, 2],
159
+ [2, 4],
160
+ [0, 4],
161
+ ])
162
+
163
+ test('includes the boundary but excludes the padded field and concave notch', () => {
164
+ expect(terrainPointInsideSite(concaveSite, 0, 2)).toBe(true)
165
+ expect(terrainPointInsideSite(concaveSite, 1, 3)).toBe(true)
166
+ expect(terrainPointInsideSite(concaveSite, 3, 3)).toBe(false)
167
+ expect(terrainPointInsideSite(concaveSite, -1, 2)).toBe(false)
168
+ })
169
+
170
+ test('a brush patch preserves samples outside the property polygon', () => {
171
+ const field = createTerrainField({ cols: 5, rows: 5, spacing: 1, origin: [0, 0] })
172
+ const heights = new Int16Array(25)
173
+ heights.fill(100)
174
+ const clipped = clipTerrainPatchToSite(
175
+ field,
176
+ { col0: 0, row0: 0, cols: 5, rows: 5, heights },
177
+ concaveSite,
178
+ )
179
+
180
+ expect(clipped.heights[1 * 5 + 1]).toBe(100)
181
+ expect(clipped.heights[3 * 5 + 3]).toBe(0)
182
+ expect(clipped.heights[2 * 5 + 0]).toBe(100)
183
+ expect(clipped.heights[2 * 5 + 4]).toBe(100)
184
+ })
185
+ })
186
+
187
+ describe('brushRadiusRange', () => {
188
+ const hugeLot = site([
189
+ [-200, -200],
190
+ [200, -200],
191
+ [200, 200],
192
+ [-200, 200],
193
+ ])
194
+ const smallLot = site([
195
+ [-10, -10],
196
+ [10, -10],
197
+ [10, 10],
198
+ [-10, 10],
199
+ ])
200
+
201
+ test('the low end is the field floor, not a constant', () => {
202
+ // The bug: both the slider and the `[`/`]` keys hardcoded 0.5 m. On a lot big
203
+ // enough that `fieldExtentForSite` stretches spacing, 0.5 m is under the
204
+ // sample gap and the brush silently paints nothing. The floor has to move
205
+ // with the lot.
206
+ const [smallMin] = brushRadiusRange(smallLot)
207
+ const [hugeMin] = brushRadiusRange(hugeLot)
208
+ expect(smallMin).toBeCloseTo(minBrushRadius({ spacing: DEFAULT_TERRAIN_SPACING }), 6)
209
+ expect(hugeMin).toBeGreaterThan(smallMin)
210
+ expect(hugeMin).toBeCloseTo(minBrushRadius(sculptFieldForSite(hugeLot)), 6)
211
+ })
212
+
213
+ test('reads the spacing of terrain that already exists', () => {
214
+ // A lot whose field was created earlier (or imported from a DEM at its own
215
+ // resolution) must be measured by *that* field, not by what a fresh one
216
+ // would get — otherwise the floor is wrong for exactly the sites that have
217
+ // been sculpted.
218
+ const existing = { ...smallLot, terrain: { spacing: 4 } } as unknown as SiteNode
219
+ const [min] = brushRadiusRange(existing)
220
+ expect(min).toBeCloseTo(minBrushRadius({ spacing: 4 }), 6)
221
+ })
222
+
223
+ test('a site with no lot yet still yields a usable range', () => {
224
+ for (const target of [null, undefined]) {
225
+ const [min, max] = brushRadiusRange(target)
226
+ expect(min).toBeGreaterThan(0)
227
+ expect(max).toBeGreaterThan(min)
228
+ }
229
+ })
230
+
231
+ test('the range never inverts', () => {
232
+ // Reachable: a lot coarse enough that the floor passes MAX_BRUSH_RADIUS would
233
+ // otherwise give min > max, and a slider whose min exceeds its max clamps
234
+ // every value to a different number depending on which bound is applied last.
235
+ for (const spacing of [0.5, 4, 20, 100]) {
236
+ const target = { ...smallLot, terrain: { spacing } } as unknown as SiteNode
237
+ const [min, max] = brushRadiusRange(target)
238
+ expect(max).toBeGreaterThanOrEqual(min)
239
+ expect(clampBrushRadius(target, 1)).toBeGreaterThanOrEqual(min)
240
+ expect(clampBrushRadius(target, 1e6)).toBeLessThanOrEqual(max)
241
+ }
242
+ })
243
+ })
244
+
245
+ describe('clampBrushRadius', () => {
246
+ const lot = site([
247
+ [-10, -10],
248
+ [10, -10],
249
+ [10, 10],
250
+ [-10, 10],
251
+ ])
252
+
253
+ test('holds a radius already in range untouched', () => {
254
+ expect(clampBrushRadius(lot, 5)).toBe(5)
255
+ })
256
+
257
+ test('the `[`/`]` ladder stays inside the range and reverses', () => {
258
+ // The keys step multiplicatively (×1.25) and quantize to decimetres, which is
259
+ // where a ratchet would hide: if `]` then `[` did not return, the pair would
260
+ // walk the radius away from where the user left it. Only the clamp at the top
261
+ // is allowed to be one-way.
262
+ const step = (radius: number, up: boolean) =>
263
+ clampBrushRadius(lot, Math.round(radius * (up ? 1.25 : 1 / 1.25) * 10) / 10)
264
+ const [min, max] = brushRadiusRange(lot)
265
+
266
+ let radius = min
267
+ const visited: number[] = []
268
+ for (let i = 0; i < 40 && radius < max; i++) {
269
+ const next = step(radius, true)
270
+ if (next === radius) break
271
+ visited.push(next)
272
+ radius = next
273
+ }
274
+ // The ladder has to actually traverse the range, or `]` is a dead key
275
+ // somewhere in the middle.
276
+ expect(visited.at(-1)).toBe(max)
277
+ expect(visited.length).toBeGreaterThan(8)
278
+
279
+ for (const value of visited) {
280
+ expect(value).toBeGreaterThanOrEqual(min)
281
+ expect(value).toBeLessThanOrEqual(max)
282
+ // Below the top, up-then-down returns. At the ceiling it cannot: one step
283
+ // down from the max is a genuine 20% reduction, which is correct.
284
+ if (step(value, true) !== max) expect(step(step(value, true), false)).toBe(value)
285
+ }
286
+ })
287
+ })
288
+
289
+ describe('resolveFlattenTarget', () => {
290
+ test('an explicit target wins over the ground under the cursor', () => {
291
+ const field = createTerrainField({ cols: 33, rows: 33, spacing: 0.5, origin: [-8, -8] })
292
+ expect(resolveFlattenTarget(field, 3.25, 0, 0)).toBeCloseTo(3.25, 6)
293
+ })
294
+
295
+ test('with no explicit target the first click samples the ground', () => {
296
+ // This is what makes flatten usable without ever opening a number field.
297
+ const field = createTerrainField({ cols: 33, rows: 33, spacing: 0.5, origin: [-8, -8] })
298
+ const raised = { ...field, heights: field.heights.slice() }
299
+ // Sample [16,16] is world (0,0) — put it at 1.5 m.
300
+ raised.heights[16 * 33 + 16] = Math.round(1.5 / raised.step)
301
+ expect(resolveFlattenTarget(raised, null, 0, 0)).toBeCloseTo(1.5, 6)
302
+ })
303
+
304
+ test('an explicit target of 0 is honoured, not treated as absent', () => {
305
+ // `?? ` rather than `||` — flatten-to-datum is the single most common
306
+ // explicit target, and `||` would silently resample instead.
307
+ const field = createTerrainField({ cols: 33, rows: 33, spacing: 0.5, origin: [-8, -8] })
308
+ const raised = { ...field, heights: field.heights.slice() }
309
+ raised.heights[16 * 33 + 16] = Math.round(2 / raised.step)
310
+ expect(resolveFlattenTarget(raised, 0, 0, 0)).toBe(0)
311
+ })
312
+ })
313
+
314
+ /**
315
+ * What Escape mid-stroke actually promises.
316
+ *
317
+ * The tool's `tool:cancel` handler ends the live stroke *without* calling
318
+ * `commitStroke`, which is the whole mechanism: the live stroke is what every reader
319
+ * prefers over the persisted terrain, so dropping it un-committed reverts the scene
320
+ * by construction. The half worth asserting is that it reverts to the *right* place —
321
+ * a site sculpted before the abandoned stroke must keep that earlier work — and that
322
+ * the abandon leaves no live stroke behind for the next reader to pick up.
323
+ */
324
+ describe('abandoning a stroke — the Escape contract', () => {
325
+ const siteId = 'site_cancel' as SiteNode['id']
326
+
327
+ function fieldAt(metres: number): ReturnType<typeof createTerrainField> {
328
+ const field = createTerrainField({ cols: 33, rows: 33, spacing: 0.5, origin: [-8, -8] })
329
+ const heights = field.heights.slice()
330
+ heights.fill(Math.round(metres / field.step))
331
+ return { ...field, heights }
332
+ }
333
+
334
+ test('ends the live stroke and commits nothing', () => {
335
+ const committed = fieldAt(1.5)
336
+ useLiveTerrain.getState().begin(siteId, committed)
337
+ useLiveTerrain.getState().advance(siteId, fieldAt(9), null as never)
338
+ expect(useLiveTerrain.getState().fieldOf(siteId)).toBeDefined()
339
+
340
+ // Exactly what the tool's `onCancel` does: end, no `commitStroke`.
341
+ useLiveTerrain.getState().end(siteId)
342
+
343
+ // No stroke left armed — a leaked one shows ground the scene graph never had.
344
+ expect(useLiveTerrain.getState().strokes.has(siteId)).toBe(false)
345
+ expect(useLiveTerrain.getState().fieldOf(siteId)).toBeUndefined()
346
+ })
347
+
348
+ test('the snapshot taken at begin is never mutated by the dabs it survives', () => {
349
+ // The anti-compounding invariant, from the cancel side: if a dab could write
350
+ // through to the snapshot, Escape would revert to a *partially* sculpted field
351
+ // rather than the baseline, and the user's earlier work would drift every stroke.
352
+ const committed = fieldAt(1.5)
353
+ const baseline = Array.from(committed.heights)
354
+ useLiveTerrain.getState().begin(siteId, committed)
355
+ useLiveTerrain.getState().advance(siteId, fieldAt(9), null as never)
356
+ useLiveTerrain.getState().end(siteId)
357
+ expect(Array.from(committed.heights)).toEqual(baseline)
358
+ })
359
+ })
360
+
361
+ /**
362
+ * The lot-wide writes have to win over a stroke in flight.
363
+ *
364
+ * Both replace the entire field, and every reader prefers the live stroke over the
365
+ * persisted terrain — so landing one *under* a stroke is invisible twice over: the
366
+ * ground keeps showing the stroke, and the stroke's commit (computed from a snapshot
367
+ * predating the write) then overwrites it. A button that silently does nothing.
368
+ *
369
+ * Not a hypothetical race. On touch one finger holds a stroke while the other taps
370
+ * the panel; with a mouse a stroke survives a wheel zoom's 500 ms `cameraDragging`
371
+ * window and a held pointer that has left the canvas.
372
+ */
373
+ describe('lot-wide terrain writes vs a stroke in flight', () => {
374
+ const lotSiteId = 'site_lotwide' as SiteNode['id']
375
+
376
+ function siteNodeWithTerrain(terrain: SiteNode['terrain']): SiteNode {
377
+ return {
378
+ id: lotSiteId,
379
+ type: 'site',
380
+ children: [],
381
+ polygon: {
382
+ type: 'polygon',
383
+ points: [
384
+ [-10, -10],
385
+ [10, -10],
386
+ [10, 10],
387
+ [-10, 10],
388
+ ],
389
+ },
390
+ terrain,
391
+ } as unknown as SiteNode
392
+ }
393
+
394
+ beforeEach(() => {
395
+ useLiveTerrain.getState().endAll()
396
+ useScene.setState({ nodes: {}, rootNodeIds: [], dirtyNodes: new Set() } as never)
397
+ useScene.temporal.getState().clear()
398
+ useScene.temporal.getState().resume()
399
+ })
400
+
401
+ test('levelling the lot drops the stroke instead of hiding behind it', () => {
402
+ const site = siteNodeWithTerrain(undefined)
403
+ useScene.setState({ nodes: { [lotSiteId]: site }, rootNodeIds: [lotSiteId] } as never)
404
+ useLiveTerrain.getState().begin(lotSiteId, sculptFieldForSite(site))
405
+
406
+ flattenSite(site, 3)
407
+
408
+ // The assertion that fails without the abandon: with a stroke still live,
409
+ // `terrainFieldOf` — and so the mesh, the raycast and the next stroke's
410
+ // snapshot — would keep reading the pre-flatten ground.
411
+ expect(useLiveTerrain.getState().fieldOf(lotSiteId)).toBeUndefined()
412
+ const written = useScene.getState().nodes[lotSiteId] as SiteNode
413
+ expect(heightAt(terrainFieldOf(written) as never, 0, 0)).toBeCloseTo(3, 6)
414
+ })
415
+
416
+ test('clearing terrain drops the stroke even when there is nothing to clear', () => {
417
+ // The early-return path: a first-ever stroke on a virgin site leaves
418
+ // `site.terrain` undefined, so `resetSiteTerrain` returns before writing —
419
+ // and a live stroke surviving that shows ground the scene graph has none of.
420
+ const site = siteNodeWithTerrain(undefined)
421
+ useScene.setState({ nodes: { [lotSiteId]: site }, rootNodeIds: [lotSiteId] } as never)
422
+ useLiveTerrain.getState().begin(lotSiteId, sculptFieldForSite(site))
423
+
424
+ resetSiteTerrain(site)
425
+
426
+ expect(useLiveTerrain.getState().fieldOf(lotSiteId)).toBeUndefined()
427
+ expect(terrainFieldOf(useScene.getState().nodes[lotSiteId] as SiteNode)).toBeNull()
428
+ })
429
+ })