@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
@@ -0,0 +1,301 @@
1
+ // Sculpt-mode helpers that are pure enough to test without a canvas: sizing the
2
+ // field to a site, resolving the flatten target, and the commit.
3
+ //
4
+ // The tool component owns pointer plumbing; everything here is a function of
5
+ // data, which is what lets the interesting decisions (grid extent, undo
6
+ // granularity, when a stroke is worth persisting) be asserted in unit tests
7
+ // instead of e2e.
8
+
9
+ import {
10
+ type BrushSettings,
11
+ commitTerrainField,
12
+ createTerrainField,
13
+ DEFAULT_TERRAIN_SPACING,
14
+ type HeightPatch,
15
+ isDatumField,
16
+ minBrushRadius,
17
+ pointInPolygon2D,
18
+ quantize,
19
+ runAsSingleSceneHistoryStep,
20
+ type SiteNode,
21
+ sampleTarget,
22
+ type TerrainField,
23
+ type TerrainVerb,
24
+ terrainFieldOf,
25
+ useLiveTerrain,
26
+ useScene,
27
+ } from '@pascal-app/core'
28
+
29
+ /**
30
+ * Field sizes we allow, all `2ⁿ+1` so a future LOD halving lands on existing
31
+ * samples instead of interpolating a seam.
32
+ */
33
+ const FIELD_SIZES = [33, 65, 129, 257] as const
34
+
35
+ /**
36
+ * Sample count a fresh field gets, chosen to cover the site polygon at roughly
37
+ * `DEFAULT_TERRAIN_SPACING` and never exceeding 257² (66 049 samples — about
38
+ * four furnished rooms' worth of vertices).
39
+ *
40
+ * Sizing from the polygon rather than a fixed extent matters because the extent
41
+ * is baked at creation: the field is the ground for the whole session, and a
42
+ * grid that stops short of the lot line leaves a visible cliff where the terrain
43
+ * mesh ends and the flat fill resumes. Padding by one spacing keeps the boundary
44
+ * line itself on interpolated ground rather than on the clamped edge row.
45
+ */
46
+ export function fieldExtentForSite(site: Pick<SiteNode, 'polygon'> | null | undefined): {
47
+ origin: [number, number]
48
+ cols: number
49
+ rows: number
50
+ spacing: number
51
+ } {
52
+ const points = site?.polygon?.points ?? []
53
+ if (points.length < 3) {
54
+ const half = ((65 - 1) / 2) * DEFAULT_TERRAIN_SPACING
55
+ return { origin: [-half, -half], cols: 65, rows: 65, spacing: DEFAULT_TERRAIN_SPACING }
56
+ }
57
+
58
+ let minX = Number.POSITIVE_INFINITY
59
+ let minZ = Number.POSITIVE_INFINITY
60
+ let maxX = Number.NEGATIVE_INFINITY
61
+ let maxZ = Number.NEGATIVE_INFINITY
62
+ for (const [x, z] of points) {
63
+ if (x < minX) minX = x
64
+ if (x > maxX) maxX = x
65
+ if (z < minZ) minZ = z
66
+ if (z > maxZ) maxZ = z
67
+ }
68
+
69
+ // The grid is square: a rectangular field would need two spacings or two
70
+ // sample counts, and both make every downstream index calculation branchier
71
+ // for no visual gain.
72
+ const pad = DEFAULT_TERRAIN_SPACING * 2
73
+ const span = Math.max(maxX - minX, maxZ - minZ) + pad * 2
74
+ const centerX = (minX + maxX) / 2
75
+ const centerZ = (minZ + maxZ) / 2
76
+
77
+ const wanted = Math.ceil(span / DEFAULT_TERRAIN_SPACING) + 1
78
+ const size = FIELD_SIZES.find((candidate) => candidate >= wanted) ?? FIELD_SIZES.at(-1)!
79
+ // Spacing stretches when the lot is bigger than 257 samples can cover at the
80
+ // default: coarser ground beats ground that stops at the property line.
81
+ const spacing = Math.max(DEFAULT_TERRAIN_SPACING, span / (size - 1))
82
+ const half = ((size - 1) / 2) * spacing
83
+
84
+ return {
85
+ origin: [centerX - half, centerZ - half],
86
+ cols: size,
87
+ rows: size,
88
+ spacing,
89
+ }
90
+ }
91
+
92
+ /**
93
+ * The field a sculpt stroke on `site` should start from: its existing terrain,
94
+ * or a fresh flat one sized to the lot.
95
+ *
96
+ * Not `terrainFieldForEdit` — that takes explicit options, and the whole point
97
+ * here is that the tool must not have to know how to size a grid.
98
+ */
99
+ export function sculptFieldForSite(site: SiteNode): TerrainField {
100
+ return terrainFieldOf(site) ?? createTerrainField(fieldExtentForSite(site))
101
+ }
102
+
103
+ /** Whether an XZ point belongs to the editable property footprint. */
104
+ export function terrainPointInsideSite(
105
+ site: Pick<SiteNode, 'polygon'>,
106
+ x: number,
107
+ z: number,
108
+ ): boolean {
109
+ const polygon = site.polygon?.points ?? []
110
+ return polygon.length >= 3 && pointInPolygon2D([x, z], polygon, { includeBoundary: true })
111
+ }
112
+
113
+ /**
114
+ * Keep a rectangular brush patch inside the property footprint.
115
+ *
116
+ * The heightfield stays padded and square for compact storage and predictable
117
+ * partial uploads, but that implementation extent is not editable land. Samples
118
+ * outside the polygon retain their current values even when a brush overlaps the
119
+ * property line.
120
+ */
121
+ export function clipTerrainPatchToSite(
122
+ field: TerrainField,
123
+ patch: HeightPatch,
124
+ site: Pick<SiteNode, 'polygon'>,
125
+ ): HeightPatch {
126
+ let heights: Int16Array | null = null
127
+
128
+ for (let row = 0; row < patch.rows; row += 1) {
129
+ for (let col = 0; col < patch.cols; col += 1) {
130
+ const fieldCol = patch.col0 + col
131
+ const fieldRow = patch.row0 + row
132
+ const x = field.origin[0] + fieldCol * field.spacing
133
+ const z = field.origin[1] + fieldRow * field.spacing
134
+ if (terrainPointInsideSite(site, x, z)) continue
135
+
136
+ const patchIndex = row * patch.cols + col
137
+ const previous = field.heights[fieldRow * field.cols + fieldCol] ?? 0
138
+ if ((patch.heights[patchIndex] ?? 0) === previous) continue
139
+ heights ??= patch.heights.slice()
140
+ heights[patchIndex] = previous
141
+ }
142
+ }
143
+
144
+ return heights ? { ...patch, heights } : patch
145
+ }
146
+
147
+ /**
148
+ * The site node the sculpt tool acts on, read straight from the scene.
149
+ *
150
+ * The tool and panel get this from a subscribed selector; the keyboard handler
151
+ * cannot (it is one imperative listener, not a component), and open-coding
152
+ * "root[0], is it a site?" a third time is how the three drift apart.
153
+ */
154
+ export function activeSiteNode(): SiteNode | null {
155
+ const { nodes, rootNodeIds } = useScene.getState()
156
+ const root = rootNodeIds[0]
157
+ const node = root ? nodes[root] : undefined
158
+ return node?.type === 'site' ? (node as SiteNode) : null
159
+ }
160
+
161
+ /**
162
+ * Largest brush radius offered, in metres.
163
+ *
164
+ * A taste call, unlike the minimum: 20 m covers a building pad in one pass and is
165
+ * about where a bigger brush stops being aimable and starts being `flattenSite`.
166
+ */
167
+ export const MAX_BRUSH_RADIUS = 20
168
+
169
+ /**
170
+ * The legal brush-radius range for `site`, low end first.
171
+ *
172
+ * Exists because two controls set this value — the panel's Size slider and the
173
+ * `[`/`]` keys — and both used to carry their own hardcoded `0.5`/`20`, with a
174
+ * comment in the keyboard handler asserting they matched. They did, but nothing
175
+ * held them to it, and the shared minimum was wrong anyway: it was a flat 0.5 m
176
+ * while the real floor scales with the field's spacing, which
177
+ * `fieldExtentForSite` stretches on large lots. Below that floor a drag paints
178
+ * nothing at all (see `MIN_BRUSH_RADIUS_IN_SPACINGS`).
179
+ *
180
+ * The max is clamped to stay above the min so the range never inverts on a lot
181
+ * big enough that the minimum passes 20 m — the brush ends up pinned to one
182
+ * usable size, which is honest, rather than to an empty range.
183
+ */
184
+ export function brushRadiusRange(site: SiteNode | null | undefined): [number, number] {
185
+ // Spacing, not the field: this runs on every render of the panel, and
186
+ // `sculptFieldForSite` would decode or allocate up to 257² samples to read one
187
+ // number off the result. An existing field's spacing is on the node; a lot with
188
+ // no terrain yet gets the spacing it *would* be given.
189
+ const spacing = site
190
+ ? (site.terrain?.spacing ?? fieldExtentForSite(site).spacing)
191
+ : DEFAULT_TERRAIN_SPACING
192
+ const min = minBrushRadius({ spacing })
193
+ return [min, Math.max(min, MAX_BRUSH_RADIUS)]
194
+ }
195
+
196
+ /** `radius` clamped into `brushRadiusRange(site)`. */
197
+ export function clampBrushRadius(site: SiteNode | null | undefined, radius: number): number {
198
+ const [min, max] = brushRadiusRange(site)
199
+ return Math.min(max, Math.max(min, radius))
200
+ }
201
+
202
+ /**
203
+ * The absolute height a `flatten` stroke aims at.
204
+ *
205
+ * `null` target means the user never picked one, so the first click samples the
206
+ * ground under the cursor — flatten stays usable without ever opening a number
207
+ * field, and "flatten this pad to match that corner" is a two-step gesture.
208
+ */
209
+ export function resolveFlattenTarget(
210
+ field: TerrainField,
211
+ explicitTarget: number | null,
212
+ x: number,
213
+ z: number,
214
+ ): number {
215
+ return explicitTarget ?? sampleTarget(field, x, z)
216
+ }
217
+
218
+ export type SculptCommit = {
219
+ verb: TerrainVerb
220
+ settings: BrushSettings
221
+ }
222
+
223
+ /**
224
+ * Drop any stroke in flight on `site` before a write that replaces the whole field.
225
+ *
226
+ * Every reader prefers the live stroke over the persisted terrain (that is what
227
+ * makes a drag consistent), so a lot-wide write landing *under* a live stroke is
228
+ * invisible: the ground keeps showing the stroke, and the stroke's own commit —
229
+ * computed from a snapshot taken before the write — then overwrites it. The user
230
+ * sees a button that did nothing, with no error and nothing in history to undo.
231
+ *
232
+ * Reachable because the brush and these controls are usable at the same moment.
233
+ * On touch it takes no timing at all: one finger holds a stroke while the other
234
+ * taps the panel. With a mouse a stroke stays live across a wheel zoom's 500 ms
235
+ * `cameraDragging` window, and a held pointer that leaves the canvas keeps its
236
+ * capture until release.
237
+ *
238
+ * Abandoning rather than committing first: the pending dabs are the thing the
239
+ * user is replacing, so keeping them would mean "level the lot, except where I
240
+ * happened to be mid-drag". Ending the live stroke without committing reverts it
241
+ * exactly, the same as Escape.
242
+ */
243
+ function abandonLiveStroke(site: Pick<SiteNode, 'id'>): void {
244
+ useLiveTerrain.getState().end(site.id)
245
+ }
246
+
247
+ /**
248
+ * Flatten the whole site to one height in a single step.
249
+ *
250
+ * The brush cannot do this well and should not have to: covering a lot with dabs
251
+ * is dozens of strokes, and any sample the brush misses leaves a ridge that is
252
+ * invisible until a slab lands on it. Sizing from `fieldExtentForSite` means the
253
+ * result covers the padded extent, so the property line stays on flat ground too.
254
+ */
255
+ export function flattenSite(site: SiteNode, metres: number): void {
256
+ abandonLiveStroke(site)
257
+ const field = sculptFieldForSite(site)
258
+ const value = quantize(field, metres)
259
+ const heights = new Int16Array(field.heights.length)
260
+ heights.fill(value)
261
+ commitStroke(site.id, { ...field, heights })
262
+ }
263
+
264
+ /**
265
+ * Drop a site's terrain entirely, back to the implicit flat ground.
266
+ *
267
+ * Distinct from flattening to 0: this removes the `terrain` field, so the scene
268
+ * returns to the exact state it had before terrain was ever touched rather than
269
+ * carrying a field of zeroes. Undo-able like any other sculpt.
270
+ */
271
+ export function resetSiteTerrain(site: SiteNode): void {
272
+ // Before the `site.terrain` guard, not after: a stroke on a site that has no
273
+ // persisted terrain yet is exactly the case that reaches the early return, and
274
+ // leaving it live would keep showing sculpted ground the scene graph has none of.
275
+ abandonLiveStroke(site)
276
+ if (!site.terrain) return
277
+ runAsSingleSceneHistoryStep(useScene, () => {
278
+ useScene.getState().updateNode(site.id, { terrain: undefined })
279
+ })
280
+ }
281
+
282
+ /**
283
+ * Persist a finished stroke as exactly one undo step.
284
+ *
285
+ * `runAsSingleSceneHistoryStep` collapses the write, which matters because a
286
+ * stroke is one user action even though it produced dozens of dabs — without it,
287
+ * undo would walk back through the stroke dab by dab, which is both surprising
288
+ * and unbounded in length.
289
+ *
290
+ * A stroke that leaves the field at the datum writes `undefined` rather than
291
+ * ~11 KB of base64 zeroes: sculpting up and then flattening back down should
292
+ * return the scene to the state it would have had if terrain were never touched,
293
+ * not merely to a visually identical one.
294
+ */
295
+ export function commitStroke(siteId: SiteNode['id'], field: TerrainField): void {
296
+ runAsSingleSceneHistoryStep(useScene, () => {
297
+ useScene.getState().updateNode(siteId, {
298
+ terrain: isDatumField(field) ? undefined : commitTerrainField(field),
299
+ })
300
+ })
301
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { TerrainVerb } from '@pascal-app/core'
3
+ import { brushRingColor } from './terrain-verb-color'
4
+
5
+ /**
6
+ * The brush ring is the only thing at the point of action that can say what the
7
+ * next drag will do. Sculpt is a *sustained* mode, so the verb a user armed a
8
+ * minute ago is still armed — and the ring's geometry is identical for all four
9
+ * verbs, which leaves colour as the only channel carrying that state.
10
+ *
11
+ * These assert the property rather than the palette: which hex is taste, but "no
12
+ * two states look alike" is correctness, and it is the thing that silently breaks
13
+ * when a fifth verb is added (terrace is planned).
14
+ */
15
+
16
+ const VERBS: TerrainVerb[] = ['raise', 'lower', 'flatten', 'smooth']
17
+
18
+ describe('brushRingColor', () => {
19
+ test('no two verbs share a colour', () => {
20
+ const colors = VERBS.map((verb) => brushRingColor(verb, false))
21
+ expect(new Set(colors).size).toBe(VERBS.length)
22
+ })
23
+
24
+ test('raise and lower are the pair that must never be confused', () => {
25
+ // The original bug: one amber ring for both, so the only way to know which way
26
+ // the ground would move was to look away from the cursor at the panel.
27
+ expect(brushRingColor('raise', false)).not.toBe(brushRingColor('lower', false))
28
+ })
29
+
30
+ test('flatten and smooth differ too', () => {
31
+ // Both are "neither up nor down", but one drives ground to a target height you
32
+ // can set wrongly and the other only softens what is there.
33
+ expect(brushRingColor('flatten', false)).not.toBe(brushRingColor('smooth', false))
34
+ })
35
+
36
+ test('sampling overrides every verb with one distinct colour', () => {
37
+ // While the eyedropper is armed the click does not apply the verb at all, so
38
+ // the ring must not read as any of them.
39
+ const sampled = VERBS.map((verb) => brushRingColor(verb, true))
40
+ expect(new Set(sampled).size).toBe(1)
41
+ for (const verb of VERBS) {
42
+ expect(brushRingColor(verb, true)).not.toBe(brushRingColor(verb, false))
43
+ }
44
+ })
45
+
46
+ test('every verb resolves to a colour, so a new verb cannot fall through', () => {
47
+ for (const verb of VERBS) {
48
+ expect(brushRingColor(verb, false)).toMatch(/^#[0-9a-f]{6}$/)
49
+ }
50
+ })
51
+ })
@@ -0,0 +1,58 @@
1
+ // The verb→colour mapping, shared by the brush ring on the canvas and the verb
2
+ // picker in the panel.
3
+ //
4
+ // Its own module because the two consumers cannot import each other: the ring is an
5
+ // R3F component (`useFrame`, `three`) and the panel is a plain sidebar surface that
6
+ // must not drag a render loop into its module graph. Duplicating four hex strings
7
+ // across them would be the version that silently drifts — and drift here is not
8
+ // cosmetic, since the whole point is that the two surfaces agree.
9
+
10
+ import type { TerrainVerb } from '@pascal-app/core'
11
+
12
+ /**
13
+ * One colour per verb, because the brush ring's geometry is identical for all four.
14
+ *
15
+ * A single colour made raise and lower visually indistinguishable: same circle, same
16
+ * amber, opposite effect — so the only way to know which way the next drag moves the
17
+ * ground was to look away from the cursor at the panel. The verb is also the one piece
18
+ * of sculpt state a user loses track of between strokes, since the mode is sustained
19
+ * and an intent set a minute ago is still armed.
20
+ *
21
+ * Values are the repo's existing palette (`ui/primitives/color-dot.tsx`) used with its
22
+ * established meanings — green for additive, red for destructive (`#ef4444` is already
23
+ * the delete cursor badge and the 2D delete stroke), blue and violet for the two
24
+ * operations that move ground neither up nor down. Amber is deliberately retired rather
25
+ * than kept for one verb: reusing the old colour for `raise` alone would leave every
26
+ * existing screenshot ambiguous.
27
+ *
28
+ * All four differ, including flatten vs smooth. Giving those two the same blue would
29
+ * reproduce the gap this closes, one pair over: both are "neither up nor down", but one
30
+ * drives ground to a *target height* you can set wrongly and the other only softens
31
+ * what is already there — confusing them costs a stroke.
32
+ */
33
+ export const TERRAIN_VERB_COLOR: Record<TerrainVerb, string> = {
34
+ raise: '#22c55e',
35
+ lower: '#ef4444',
36
+ flatten: '#3b82f6',
37
+ smooth: '#a855f7',
38
+ }
39
+
40
+ /**
41
+ * Picking a height, not applying a verb — so the ring must not read as any of them.
42
+ * The eyedropper is a one-shot arm whose UI lives in the panel; the cursor is the only
43
+ * thing at the point of action that can say "this click samples rather than sculpts".
44
+ */
45
+ export const TERRAIN_SAMPLING_COLOR = '#f59e0b'
46
+
47
+ /**
48
+ * The brush ring's colour for a given brush state.
49
+ *
50
+ * A function rather than a lookup at the call site because sampling *outranks* the
51
+ * verb — while the eyedropper is armed the click does not apply the verb at all, so
52
+ * showing the verb's colour would promise the wrong action. Keeping that precedence in
53
+ * one place also makes it assertable: "no two states look alike" is a property, and one
54
+ * worth checking rather than re-eyeballing every time a verb is added.
55
+ */
56
+ export function brushRingColor(verb: TerrainVerb, sampling: boolean): string {
57
+ return sampling ? TERRAIN_SAMPLING_COLOR : TERRAIN_VERB_COLOR[verb]
58
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { Mode } from '../store/use-editor'
3
+ import { editorOwnsOneFingerDrag } from './touch-gesture-priority'
4
+
5
+ /**
6
+ * One finger on a touch screen is one pointer, so the camera and the editor
7
+ * cannot both have it. These lock which side wins, because the failure is silent:
8
+ * the loser's handlers simply never fire.
9
+ */
10
+ describe('editorOwnsOneFingerDrag', () => {
11
+ const ALL_MODES: Mode[] = [
12
+ 'select',
13
+ 'edit',
14
+ 'delete',
15
+ 'build',
16
+ 'material-paint',
17
+ 'terrain-sculpt',
18
+ ]
19
+
20
+ test('the camera keeps one finger when nothing is going on', () => {
21
+ expect(editorOwnsOneFingerDrag({ mode: 'select', activeGesture: false })).toBe(false)
22
+ })
23
+
24
+ test('both brush modes claim it with no transient gesture at all', () => {
25
+ // The regression: a brush mode arms nothing that the transient terms can see,
26
+ // so before this the camera orbited under a one-finger sculpt or paint drag.
27
+ expect(editorOwnsOneFingerDrag({ mode: 'terrain-sculpt', activeGesture: false })).toBe(true)
28
+ expect(editorOwnsOneFingerDrag({ mode: 'material-paint', activeGesture: false })).toBe(true)
29
+ })
30
+
31
+ test('a live gesture claims it in every mode', () => {
32
+ for (const mode of ALL_MODES) {
33
+ expect(editorOwnsOneFingerDrag({ mode, activeGesture: true })).toBe(true)
34
+ }
35
+ })
36
+
37
+ test('the non-brush modes leave it to the camera when idle', () => {
38
+ // Asserted as the complement of the brush set rather than a list, so adding a
39
+ // third brush mode cannot quietly land here as "camera wins".
40
+ const idle = ALL_MODES.filter(
41
+ (mode) => !editorOwnsOneFingerDrag({ mode, activeGesture: false }),
42
+ )
43
+ expect(idle).toEqual(['select', 'edit', 'delete', 'build'])
44
+ })
45
+ })
@@ -0,0 +1,30 @@
1
+ import type { Mode } from '../store/use-editor'
2
+ import { isBrushMode } from '../store/use-editor'
3
+
4
+ /**
5
+ * Whether the editor — rather than the camera — owns a one-finger touch drag.
6
+ *
7
+ * On touch there is exactly one pointer, so the camera and the editor cannot both
8
+ * have it: `custom-camera-controls` maps one finger to `TOUCH_ROTATE` by default
9
+ * (orbiting a phone with one thumb is the common case) and to `NONE` while an
10
+ * editor gesture is live, which lets the tool's own pointer handlers through.
11
+ * Two fingers always pinch and three always orbit, so the camera is never
12
+ * unreachable — that is what makes yielding one finger safe.
13
+ *
14
+ * Extracted from the component because the terms are not obviously exhaustive and
15
+ * one was missing. A *brush mode* owns the drag exactly the way an armed tool
16
+ * does, but none of the other terms notice it: entering paint or sculpt **clears**
17
+ * `tool`, and the scope it holds is `painting`/`sculpting`, not `handle-drag`. So
18
+ * on a tablet a one-finger drag over the ground orbited the camera instead of
19
+ * sculpting — and set `cameraDragging`, which the sculpt tool gates pointer-down
20
+ * on, so the stroke could not start either. The brush was simply inert on touch,
21
+ * in both brush modes.
22
+ *
23
+ * `activeGesture` is the OR of the transient signals the component already reads
24
+ * (an armed tool, a moving node, an endpoint reshape, a handle drag, marquee
25
+ * box-select); they stay in the component because each is its own subscription,
26
+ * while what is worth pinning down here is that a sustained *mode* counts too.
27
+ */
28
+ export function editorOwnsOneFingerDrag(args: { mode: Mode; activeGesture: boolean }): boolean {
29
+ return args.activeGesture || isBrushMode(args.mode)
30
+ }
@@ -23,7 +23,7 @@ export function isOperableWindowType(windowType: string | undefined) {
23
23
  )
24
24
  }
25
25
 
26
- function getDisplayedWindowValue(windowId: AnyNodeId, nodeValue: number | undefined) {
26
+ export function getDisplayedWindowValue(windowId: AnyNodeId, nodeValue: number | undefined) {
27
27
  const interactive = useInteractive.getState()
28
28
  const runtimeValue = interactive.windows[windowId]?.operationState
29
29
  if (runtimeValue !== undefined) return runtimeValue
@@ -0,0 +1,29 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import type { CameraPose } from '@pascal-app/core'
3
+ import { cameraPoseStore, publishCameraPose, subscribeCameraPose } from './camera-pose-store'
4
+
5
+ const pose: CameraPose = {
6
+ position: [3, 4, 5],
7
+ projection: 'perspective',
8
+ target: [0, 1, 0],
9
+ viewWidth: 12,
10
+ }
11
+
12
+ afterEach(() => {
13
+ cameraPoseStore.setState({ pose: null })
14
+ })
15
+
16
+ describe('camera pose store', () => {
17
+ test('replays the latest pose and streams later poses to focused subscribers', () => {
18
+ publishCameraPose(pose)
19
+ const received: CameraPose[] = []
20
+ const unsubscribe = subscribeCameraPose((nextPose) => received.push(nextPose))
21
+ const nextPose = { ...pose, viewWidth: 8 }
22
+
23
+ publishCameraPose(nextPose)
24
+ unsubscribe()
25
+ publishCameraPose({ ...pose, viewWidth: 4 })
26
+
27
+ expect(received).toEqual([pose, nextPose])
28
+ })
29
+ })
@@ -0,0 +1,27 @@
1
+ import type { CameraPose } from '@pascal-app/core'
2
+ import { subscribeWithSelector } from 'zustand/middleware'
3
+ import { createStore } from 'zustand/vanilla'
4
+
5
+ type CameraPoseState = {
6
+ pose: CameraPose | null
7
+ }
8
+
9
+ export const cameraPoseStore = createStore<CameraPoseState>()(
10
+ subscribeWithSelector<CameraPoseState>(() => ({ pose: null })),
11
+ )
12
+
13
+ export function publishCameraPose(pose: CameraPose) {
14
+ cameraPoseStore.setState({ pose })
15
+ }
16
+
17
+ export function subscribeCameraPose(listener: (pose: CameraPose) => void) {
18
+ const currentPose = cameraPoseStore.getState().pose
19
+ if (currentPose) listener(currentPose)
20
+
21
+ return cameraPoseStore.subscribe(
22
+ (state) => state.pose,
23
+ (pose) => {
24
+ if (pose) listener(pose)
25
+ },
26
+ )
27
+ }