@pascal-app/editor 1.1.0 → 1.1.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 (260) hide show
  1. package/package.json +9 -15
  2. package/src/components/editor/action-menu-button.tsx +1 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +1 -1
  4. package/src/components/editor/custom-camera-controls.tsx +11 -11
  5. package/src/components/editor/editor-layout-v2.tsx +1 -1
  6. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  7. package/src/components/editor/first-person-controls.tsx +20 -1981
  8. package/src/components/editor/floating-action-menu.tsx +1 -1
  9. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  10. package/src/components/editor/floor-region-controls.tsx +6 -2
  11. package/src/components/editor/floorplan-navigation-presentation.test.ts +0 -40
  12. package/src/components/editor/floorplan-navigation-presentation.ts +0 -66
  13. package/src/components/editor/floorplan-panel.tsx +26 -22
  14. package/src/components/editor/floorplan-wall-typing-keys.ts +1 -1
  15. package/src/components/editor/follow-pascal.test.tsx +1 -2
  16. package/src/components/editor/follow-pascal.tsx +1 -1
  17. package/src/components/editor/footprint-height-handle.tsx +1 -1
  18. package/src/components/editor/group-actions.ts +1 -2
  19. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  20. package/src/components/editor/group-move-3d.ts +1 -2
  21. package/src/components/editor/group-rotate-handle.tsx +1 -2
  22. package/src/components/editor/handles/use-handle-drag.ts +1 -2
  23. package/src/components/editor/index.tsx +24 -14
  24. package/src/components/editor/inspector-extension-actions.tsx +1 -1
  25. package/src/components/editor/node-arrow-handles.tsx +1 -1
  26. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -1
  27. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  28. package/src/components/editor/room-controls.tsx +7 -4
  29. package/src/components/editor/room-handles-2d.tsx +2 -3
  30. package/src/components/editor/room-handles.tsx +1 -2
  31. package/src/components/editor/room-highlight.tsx +6 -105
  32. package/src/components/editor/room-labels.test.ts +8 -822
  33. package/src/components/editor/room-labels.tsx +40 -1671
  34. package/src/components/editor/room-webgpu-materials.test.ts +1 -1
  35. package/src/components/editor/selection-manager.tsx +5 -3
  36. package/src/components/editor/site-edge-labels.tsx +1 -1
  37. package/src/components/editor/snapshot-capture-overlay.tsx +2 -5
  38. package/src/components/editor/use-floorplan-background-placement.ts +2 -1
  39. package/src/components/editor/views/plugins-view.tsx +1 -1
  40. package/src/components/editor/views/view-bar.tsx +9 -9
  41. package/src/components/editor/wall-measurement-label.tsx +1 -1
  42. package/src/components/editor/wall-move-side-handles.tsx +1 -1
  43. package/src/components/editor/wall-pull-grip.tsx +2 -2
  44. package/src/components/editor-2d/floorplan-group-move.tsx +2 -3
  45. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1 -1
  46. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +1 -1
  47. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +1 -2
  48. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +0 -37
  49. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +0 -30
  50. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +13 -3
  51. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +1 -2
  52. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +8 -10
  53. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +25 -33
  54. package/src/components/systems/roof/roof-edit-system.tsx +4 -3
  55. package/src/components/tools/distribution/distribution-run-tool.tsx +1 -1
  56. package/src/components/tools/item/placement-history.test.tsx +1 -1
  57. package/src/components/tools/item/use-placement-coordinator.tsx +2 -3
  58. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +6 -2
  59. package/src/components/tools/registry/move-registry-node-tool.tsx +1 -2
  60. package/src/components/tools/shared/cursor-sphere.tsx +1 -1
  61. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  62. package/src/components/tools/shared/placement-box.tsx +1 -1
  63. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  64. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  65. package/src/components/ui/action-menu/camera-actions.tsx +2 -2
  66. package/src/components/ui/action-menu/control-modes.tsx +1 -1
  67. package/src/components/ui/action-menu/follow-pascal-pill.tsx +1 -1
  68. package/src/components/ui/action-menu/index.tsx +3 -3
  69. package/src/components/ui/action-menu/measurement-control.tsx +3 -3
  70. package/src/components/ui/action-menu/placement.ts +0 -18
  71. package/src/components/ui/action-menu/point-ask-button.tsx +1 -1
  72. package/src/components/ui/action-menu/view-toggles.tsx +5 -5
  73. package/src/components/ui/controls/finish-swatch.tsx +1 -1
  74. package/src/components/ui/controls/material-paint-panel.tsx +2 -2
  75. package/src/components/ui/controls/scene-material-list.tsx +2 -2
  76. package/src/components/ui/controls/select-control.tsx +1 -1
  77. package/src/components/ui/controls/terrain-sculpt-panel.tsx +1 -1
  78. package/src/components/ui/floating-level-selector.tsx +3 -3
  79. package/src/components/ui/helpers/contextual-helper-panel.tsx +1 -1
  80. package/src/components/ui/helpers/helper-manager.test.tsx +1 -1
  81. package/src/components/ui/helpers/helper-manager.tsx +1 -1
  82. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  83. package/src/components/ui/panels/collections/collections-popover.tsx +2 -2
  84. package/src/components/ui/panels/floor-foundation-panel.tsx +2 -2
  85. package/src/components/ui/panels/floor-opening-panel.tsx +4 -4
  86. package/src/components/ui/panels/node-display.ts +1 -1
  87. package/src/components/ui/panels/panel-manager.tsx +1 -1
  88. package/src/components/ui/panels/panel-wrapper.tsx +1 -1
  89. package/src/components/ui/panels/parametric-inspector.tsx +1 -1
  90. package/src/components/ui/panels/room-construction-rows.tsx +6 -6
  91. package/src/components/ui/panels/room-name-options.ts +1 -5
  92. package/src/components/ui/panels/room-panel.tsx +6 -8
  93. package/src/components/ui/primitives/color-dot.tsx +2 -2
  94. package/src/components/ui/primitives/opacity-control.tsx +2 -2
  95. package/src/components/ui/primitives/sidebar.tsx +3 -3
  96. package/src/components/ui/right-stack.tsx +1 -1
  97. package/src/components/ui/sidebar/icon-rail.tsx +1 -1
  98. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  99. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  100. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +1 -1
  101. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +2 -2
  102. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +1 -1
  103. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -1
  104. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +1 -1
  106. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -2
  107. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +1 -1
  108. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  109. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +46 -7
  110. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +1 -1
  111. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +14 -5
  112. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -2
  113. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +3 -3
  114. package/src/components/ui/sidebar/tab-bar.tsx +1 -1
  115. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +1 -1
  116. package/src/components/ui/wall-type/wall-type-menu.tsx +1 -1
  117. package/src/components/viewer-overlay.tsx +1 -2
  118. package/src/hooks/use-auto-frame.ts +1 -2
  119. package/src/hooks/use-keyboard.ts +1 -2
  120. package/src/hooks/use-selected-room.ts +5 -7
  121. package/src/i18n/floor-footprint-name.ts +1 -1
  122. package/src/i18n/locales/en/messages.po +180 -182
  123. package/src/i18n/locales/es/messages.po +180 -182
  124. package/src/i18n/locales/fr/messages.po +180 -182
  125. package/src/i18n/locales/ja/messages.po +180 -182
  126. package/src/i18n/locales/zh-CN/messages.po +180 -182
  127. package/src/i18n/locales.test.ts +1 -1
  128. package/src/index.tsx +109 -131
  129. package/src/lib/below-grade.test.tsx +4 -4
  130. package/src/lib/camera-facing.test.ts +1 -1
  131. package/src/lib/camera-facing.ts +2 -3
  132. package/src/lib/editor-level-display.ts +1 -1
  133. package/src/lib/floor-footprints.test.ts +2 -2
  134. package/src/lib/floor-opening-draft.ts +1 -1
  135. package/src/lib/floorplan/annotation-visibility.test.ts +1 -1
  136. package/src/lib/floorplan/annotation-visibility.ts +1 -1
  137. package/src/lib/floorplan/floorplan-export.test.ts +1 -1
  138. package/src/lib/floorplan/floorplan-export.tsx +8 -11
  139. package/src/lib/floorplan/floorplan-extension.ts +4 -157
  140. package/src/lib/floorplan/floorplan-mode.ts +2 -1
  141. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +1 -1
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +8 -6
  143. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +3 -4
  144. package/src/lib/floorplan/geometry.ts +2 -112
  145. package/src/lib/floorplan/index.ts +0 -9
  146. package/src/lib/floorplan/items.ts +2 -1
  147. package/src/lib/floorplan/schedules.ts +6 -3
  148. package/src/lib/floorplan/sheet-export.ts +7 -3
  149. package/src/lib/floorplan/site-plan/site-parts.ts +7 -1
  150. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +1 -1
  151. package/src/lib/follow-pascal-presentation.ts +1 -1
  152. package/src/lib/glb-export.ts +1 -1
  153. package/src/lib/material-paint.ts +1 -1
  154. package/src/lib/measurements.test.ts +2 -1
  155. package/src/lib/mezzanine-selection.test.ts +1 -1
  156. package/src/lib/paint-region-mode.ts +1 -1
  157. package/src/lib/point-ask/session.ts +1 -2
  158. package/src/lib/roof-duplication.test.ts +100 -0
  159. package/src/lib/roof-duplication.ts +30 -5
  160. package/src/lib/room-handle-drag.ts +2 -12
  161. package/src/lib/room-name-catalog.test.ts +2 -1
  162. package/src/lib/room-name-messages.test.ts +2 -1
  163. package/src/lib/room-name-messages.ts +1 -118
  164. package/src/lib/room-name-storage.test.ts +2 -1
  165. package/src/lib/room-selection-commands.ts +2 -2
  166. package/src/lib/room-selection.test.ts +6 -6
  167. package/src/lib/room-transform-session.ts +1 -2
  168. package/src/lib/room-zone-routing.ts +2 -2
  169. package/src/lib/selection-routing.test.ts +0 -28
  170. package/src/lib/selection-routing.ts +1 -40
  171. package/src/lib/sfx-bus.ts +8 -32
  172. package/src/lib/sfx-player.test.ts +72 -1
  173. package/src/lib/sfx-player.ts +49 -337
  174. package/src/lib/viewer-selection.test.ts +38 -10
  175. package/src/lib/wall-pull.test.ts +1 -1
  176. package/src/lib/wall-pull.ts +1 -27
  177. package/src/store/use-editor.tsx +17 -26
  178. package/src/store/use-floorplan-annotation-visibility.ts +5 -5
  179. package/src/store/use-wall-region-handles-selection.ts +1 -2
  180. package/src/components/editor/__bench__/room-label-placement.bench.ts +0 -108
  181. package/src/components/editor/first-person/build-collider-world.test.ts +0 -596
  182. package/src/components/editor/first-person/build-collider-world.ts +0 -549
  183. package/src/components/editor/first-person/ground-holes.test.ts +0 -128
  184. package/src/components/editor/first-person/ground-holes.ts +0 -130
  185. package/src/components/editor/first-person/mechanism-targets.test.ts +0 -162
  186. package/src/components/editor/first-person/mechanism-targets.ts +0 -88
  187. package/src/components/editor/first-person/terrain-collider.test.ts +0 -132
  188. package/src/components/editor/first-person/terrain-collider.ts +0 -212
  189. package/src/components/editor/floorplan-camera-sync.test.ts +0 -249
  190. package/src/components/editor/floorplan-camera-sync.ts +0 -213
  191. package/src/components/editor/localized-html.tsx +0 -22
  192. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +0 -236
  193. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +0 -708
  194. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +0 -385
  195. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +0 -670
  196. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +0 -89
  197. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +0 -106
  198. package/src/components/editor-2d/svg-paths.ts +0 -124
  199. package/src/components/ui/action-menu/action-button.tsx +0 -89
  200. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +0 -42
  201. package/src/components/ui/primitives/button.tsx +0 -69
  202. package/src/components/ui/primitives/dropdown-menu.tsx +0 -232
  203. package/src/components/ui/primitives/popover.tsx +0 -42
  204. package/src/components/ui/primitives/switch.tsx +0 -29
  205. package/src/components/ui/primitives/tooltip.tsx +0 -57
  206. package/src/components/ui/scene-loader.tsx +0 -73
  207. package/src/components/viewer/floorplan-compass-button.tsx +0 -67
  208. package/src/components/viewer/floorplan-preview-geometry.test.ts +0 -107
  209. package/src/components/viewer/floorplan-preview-geometry.ts +0 -281
  210. package/src/components/viewer/floorplan-preview-navigation.test.ts +0 -43
  211. package/src/components/viewer/floorplan-preview-navigation.ts +0 -62
  212. package/src/components/viewer/floorplan-preview-visibility.test.ts +0 -35
  213. package/src/components/viewer/floorplan-preview-visibility.ts +0 -19
  214. package/src/components/viewer/floorplan-preview.tsx +0 -1065
  215. package/src/components/viewer/render-settings-menu.tsx +0 -294
  216. package/src/components/viewer/sky-control.tsx +0 -710
  217. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +0 -140
  218. package/src/components/viewer/use-viewer-ui-sounds.test.ts +0 -15
  219. package/src/components/viewer/use-viewer-ui-sounds.ts +0 -46
  220. package/src/components/viewer/viewer-controls-bar.tsx +0 -496
  221. package/src/components/viewer/viewer-hover-label.tsx +0 -42
  222. package/src/components/viewer/viewer-rooms.tsx +0 -248
  223. package/src/components/viewer/viewer-scene-header.tsx +0 -286
  224. package/src/components/viewer/viewer-selection-manager.tsx +0 -124
  225. package/src/components/viewer/viewer-stage-modes.test.ts +0 -38
  226. package/src/components/viewer/viewer-stage-modes.ts +0 -49
  227. package/src/components/viewer/viewer-stage-switcher.tsx +0 -97
  228. package/src/components/viewer/viewer-stage.test.tsx +0 -47
  229. package/src/components/viewer/viewer-stage.tsx +0 -221
  230. package/src/components/walkthrough-hud.tsx +0 -193
  231. package/src/hooks/use-mobile.ts +0 -19
  232. package/src/i18n/level-name.ts +0 -39
  233. package/src/i18n/scene-theme-name.ts +0 -16
  234. package/src/lib/below-grade.ts +0 -133
  235. package/src/lib/camera-pose.test.ts +0 -237
  236. package/src/lib/camera-pose.ts +0 -172
  237. package/src/lib/floorplan/floorplan-extension.test.ts +0 -42
  238. package/src/lib/floorplan/floorplan-readonly.test.ts +0 -47
  239. package/src/lib/floorplan/floorplan-readonly.ts +0 -95
  240. package/src/lib/floorplan/marks.test.ts +0 -213
  241. package/src/lib/floorplan/marks.ts +0 -238
  242. package/src/lib/measurements.ts +0 -8
  243. package/src/lib/node-display-label.ts +0 -37
  244. package/src/lib/room-assembly-overlay.test.ts +0 -275
  245. package/src/lib/room-assembly-overlay.ts +0 -265
  246. package/src/lib/room-selection.ts +0 -417
  247. package/src/lib/scene-bounds.test.ts +0 -327
  248. package/src/lib/scene-bounds.ts +0 -233
  249. package/src/lib/typing-target.ts +0 -9
  250. package/src/lib/viewer-selection.ts +0 -595
  251. package/src/lib/zone-content.ts +0 -133
  252. package/src/store/camera-pose-store.test.ts +0 -29
  253. package/src/store/camera-pose-store.ts +0 -27
  254. package/src/store/navigation-sync-pose-store.test.ts +0 -34
  255. package/src/store/navigation-sync-pose-store.ts +0 -27
  256. package/src/store/use-audio.test.ts +0 -146
  257. package/src/store/use-audio.tsx +0 -169
  258. package/src/store/use-first-person-hud.ts +0 -40
  259. package/src/store/use-follow-pascal.test.ts +0 -62
  260. package/src/store/use-follow-pascal.ts +0 -53
@@ -1,212 +0,0 @@
1
- /**
2
- * The sculpted ground as walkthrough collision geometry.
3
- *
4
- * The first-person collider world is a single merged BVH mesh, and before terrain
5
- * the ground contributed one flat 2 km box to it. That box is wrong in both
6
- * directions on a sculpted site: it holds the player up at the datum inside an
7
- * excavation, and buries them inside a hill. This module replaces it.
8
- *
9
- * Two properties it has to hold, because the whole point is that walking agrees
10
- * with every other consumer of the field:
11
- *
12
- * - **The surface is the field**, sampled at every vertex — the same
13
- * `heightAtSample`/`normalAt` the rendered mesh uses (`terrain-geometry.ts`),
14
- * so what you see is what you stand on. It is deliberately not a decimated
15
- * proxy at realistic field sizes.
16
- * - **It extends past the field**, because `heightAt` clamps to the border
17
- * outside it. A collider that stopped at the field edge would drop the player
18
- * into the void one step past the site, which is exactly the bug the flat box
19
- * existed to prevent. The skirt extrudes each border sample outward at its own
20
- * height, so the collider and `heightAt` agree everywhere, not just inside.
21
- *
22
- * Non-indexed triangles with position + normal, matching what the other collider
23
- * contributors emit — `mergeGeometries` requires every input to agree on both.
24
- */
25
-
26
- import { heightAtSample, normalAt, type TerrainField } from '@pascal-app/core'
27
- import * as THREE from 'three'
28
-
29
- /**
30
- * How far past the field the ground keeps holding the player, in metres. Matches
31
- * the flat box's half-extent so a site's collider bounds do not change size when
32
- * it gains terrain.
33
- */
34
- const SKIRT_REACH = 1000
35
-
36
- /**
37
- * Cell budget before the collider decimates.
38
- *
39
- * 66k cells is a 257² field — the largest anything realistic produces, and ~131k
40
- * triangles, which the BVH builds in well under a frame's worth of budget. The
41
- * schema allows up to 4097² (a decode-bomb guard, not a real terrain); at that
42
- * size an exact collider would be 100M triangles, so past the budget this steps
43
- * the sample grid and the collider becomes a faithful-but-coarser version of the
44
- * same surface rather than an out-of-memory crash.
45
- */
46
- const MAX_COLLIDER_CELLS = 66_000
47
-
48
- function colliderStride(field: TerrainField): number {
49
- const cells = Math.max(1, (field.cols - 1) * (field.rows - 1))
50
- if (cells <= MAX_COLLIDER_CELLS) return 1
51
- return Math.ceil(Math.sqrt(cells / MAX_COLLIDER_CELLS))
52
- }
53
-
54
- /** The sample indices the collider walks: every `stride`-th, always including the border. */
55
- function sampleLine(count: number, stride: number): number[] {
56
- const line: number[] = []
57
- for (let i = 0; i < count - 1; i += stride) line.push(i)
58
- line.push(count - 1)
59
- return line
60
- }
61
-
62
- type Vertex = { x: number; y: number; z: number }
63
-
64
- class TriangleSink {
65
- private readonly positions: number[] = []
66
- private readonly normals: number[] = []
67
-
68
- constructor(private readonly field: TerrainField) {}
69
-
70
- private push(v: Vertex): void {
71
- this.positions.push(v.x, v.y, v.z)
72
- const [nx, ny, nz] = normalAt(this.field, v.x, v.z)
73
- this.normals.push(nx, ny, nz)
74
- }
75
-
76
- /**
77
- * One cell, given its four corners named by axis order. Winding matches
78
- * `buildTerrainMesh`: `(p00, p01, p10)` and `(p10, p01, p11)` faces +Y, which is
79
- * what the spawn resolver's `normal.dot(UP) > 0.2` floor test needs.
80
- */
81
- quad(p00: Vertex, p10: Vertex, p01: Vertex, p11: Vertex): void {
82
- this.push(p00)
83
- this.push(p01)
84
- this.push(p10)
85
- this.push(p10)
86
- this.push(p01)
87
- this.push(p11)
88
- }
89
-
90
- build(): THREE.BufferGeometry | null {
91
- if (this.positions.length === 0) return null
92
- const geometry = new THREE.BufferGeometry()
93
- geometry.setAttribute(
94
- 'position',
95
- new THREE.BufferAttribute(new Float32Array(this.positions), 3),
96
- )
97
- geometry.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(this.normals), 3))
98
- return geometry
99
- }
100
- }
101
-
102
- /**
103
- * Terrain collision geometry in the site's own frame — the caller applies the
104
- * site's world matrix, exactly as it did for the flat box it replaces.
105
- */
106
- export function createTerrainColliderGeometry(field: TerrainField): THREE.BufferGeometry | null {
107
- if (field.cols < 2 || field.rows < 2) return null
108
-
109
- const stride = colliderStride(field)
110
- const cols = sampleLine(field.cols, stride)
111
- const rows = sampleLine(field.rows, stride)
112
- const sink = new TriangleSink(field)
113
-
114
- const xAt = (col: number) => field.origin[0] + col * field.spacing
115
- const zAt = (row: number) => field.origin[1] + row * field.spacing
116
- const at = (col: number, row: number): Vertex => ({
117
- x: xAt(col),
118
- y: heightAtSample(field, col, row),
119
- z: zAt(row),
120
- })
121
-
122
- for (let ri = 0; ri < rows.length - 1; ri++) {
123
- const r0 = rows[ri] as number
124
- const r1 = rows[ri + 1] as number
125
- for (let ci = 0; ci < cols.length - 1; ci++) {
126
- const c0 = cols[ci] as number
127
- const c1 = cols[ci + 1] as number
128
- sink.quad(at(c0, r0), at(c1, r0), at(c0, r1), at(c1, r1))
129
- }
130
- }
131
-
132
- // The skirt: each border sample extruded outward at its own height, so stepping
133
- // off the field lands on the border height rather than on nothing. This is the
134
- // collider spelling of `heightAt`'s edge clamp.
135
- const firstCol = 0
136
- const lastCol = field.cols - 1
137
- const firstRow = 0
138
- const lastRow = field.rows - 1
139
- const westX = xAt(firstCol) - SKIRT_REACH
140
- const eastX = xAt(lastCol) + SKIRT_REACH
141
- const northZ = zAt(firstRow) - SKIRT_REACH
142
- const southZ = zAt(lastRow) + SKIRT_REACH
143
-
144
- for (let ci = 0; ci < cols.length - 1; ci++) {
145
- const c0 = cols[ci] as number
146
- const c1 = cols[ci + 1] as number
147
- const north0 = at(c0, firstRow)
148
- const north1 = at(c1, firstRow)
149
- sink.quad(
150
- { x: north0.x, y: north0.y, z: northZ },
151
- { x: north1.x, y: north1.y, z: northZ },
152
- north0,
153
- north1,
154
- )
155
- const south0 = at(c0, lastRow)
156
- const south1 = at(c1, lastRow)
157
- sink.quad(
158
- south0,
159
- south1,
160
- { x: south0.x, y: south0.y, z: southZ },
161
- {
162
- x: south1.x,
163
- y: south1.y,
164
- z: southZ,
165
- },
166
- )
167
- }
168
-
169
- for (let ri = 0; ri < rows.length - 1; ri++) {
170
- const r0 = rows[ri] as number
171
- const r1 = rows[ri + 1] as number
172
- const west0 = at(firstCol, r0)
173
- const west1 = at(firstCol, r1)
174
- sink.quad(
175
- { x: westX, y: west0.y, z: west0.z },
176
- west0,
177
- { x: westX, y: west1.y, z: west1.z },
178
- west1,
179
- )
180
- const east0 = at(lastCol, r0)
181
- const east1 = at(lastCol, r1)
182
- sink.quad(east0, { x: eastX, y: east0.y, z: east0.z }, east1, {
183
- x: eastX,
184
- y: east1.y,
185
- z: east1.z,
186
- })
187
- }
188
-
189
- // Corner quads, flat at the corner sample's height — the diagonal region no edge
190
- // strip covers, and where `heightAt` also reports the corner value.
191
- const corners: Array<[Vertex, number, number]> = [
192
- [at(firstCol, firstRow), westX, northZ],
193
- [at(lastCol, firstRow), eastX, northZ],
194
- [at(firstCol, lastRow), westX, southZ],
195
- [at(lastCol, lastRow), eastX, southZ],
196
- ]
197
- for (const [corner, farX, farZ] of corners) {
198
- const minX = Math.min(corner.x, farX)
199
- const maxX = Math.max(corner.x, farX)
200
- const minZ = Math.min(corner.z, farZ)
201
- const maxZ = Math.max(corner.z, farZ)
202
- const y = corner.y
203
- sink.quad(
204
- { x: minX, y, z: minZ },
205
- { x: maxX, y, z: minZ },
206
- { x: minX, y, z: maxZ },
207
- { x: maxX, y, z: maxZ },
208
- )
209
- }
210
-
211
- return sink.build()
212
- }
@@ -1,249 +0,0 @@
1
- import { describe, expect, test } from 'bun:test'
2
- import type { CameraPose } from '@pascal-app/core'
3
- import { publishInitialCameraPose } from '../../lib/camera-pose'
4
- import type { NavigationSyncPose } from '../../store/use-editor'
5
- import {
6
- cameraPoseToFloorplanNavigationPose,
7
- createFloorplanCameraNavigationChannel,
8
- createFloorplanCameraSyncBridge,
9
- floorplanNavigationPoseToCameraPose,
10
- } from './floorplan-camera-sync'
11
-
12
- const cameraPose: CameraPose = {
13
- fov: 50,
14
- position: [3, 4, 4],
15
- projection: 'perspective',
16
- target: [0, 1, 0],
17
- viewWidth: 12,
18
- }
19
-
20
- function cameraPoseAtAzimuth(
21
- azimuth: number,
22
- target: [number, number, number] = [0, 1, 0],
23
- ): CameraPose {
24
- const horizontalDistance = 5
25
- return {
26
- ...cameraPose,
27
- position: [
28
- target[0] + Math.sin(azimuth) * horizontalDistance,
29
- 4,
30
- target[2] + Math.cos(azimuth) * horizontalDistance,
31
- ],
32
- target,
33
- }
34
- }
35
-
36
- describe('floorplan camera sync', () => {
37
- test('derives the floor-plan navigation pose from the generic camera pose', () => {
38
- expect(
39
- cameraPoseToFloorplanNavigationPose({
40
- position: [10, 5, 0],
41
- projection: 'perspective',
42
- target: [0, 0, 0],
43
- viewWidth: 20,
44
- }),
45
- ).toEqual({
46
- source: '3d',
47
- target: [0, 0, 0],
48
- azimuth: Math.PI / 2,
49
- viewWidth: 20,
50
- })
51
- })
52
-
53
- test('applies a floor-plan pose while preserving the generic camera elevation and projection', () => {
54
- expect(
55
- floorplanNavigationPoseToCameraPose(
56
- {
57
- source: '2d',
58
- revision: 4,
59
- target: [10, 2, 20],
60
- azimuth: Math.PI / 2,
61
- viewWidth: 8,
62
- },
63
- cameraPose,
64
- ),
65
- ).toEqual({
66
- fov: 50,
67
- position: [15, 5, 20],
68
- projection: 'perspective',
69
- target: [10, 2, 20],
70
- viewWidth: 8,
71
- })
72
- })
73
-
74
- test('owns two-way synchronization without echoing tiny camera changes', () => {
75
- const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
76
- const applied: CameraPose[] = []
77
- const bridge = createFloorplanCameraSyncBridge({
78
- applyCameraPose: (pose) => applied.push(pose),
79
- publishNavigationPose: (pose) => published.push(pose),
80
- })
81
-
82
- bridge.receiveCameraPose(cameraPose)
83
- bridge.receiveCameraPose({
84
- ...cameraPose,
85
- position: [3.0001, 4, 4],
86
- })
87
- bridge.receiveNavigationPose({
88
- source: '2d',
89
- revision: 1,
90
- target: [10, 2, 20],
91
- azimuth: Math.PI / 2,
92
- viewWidth: 8,
93
- })
94
-
95
- expect(published).toHaveLength(1)
96
- expect(published[0]?.source).toBe('3d')
97
- expect(applied).toHaveLength(1)
98
- expect(applied[0]).toMatchObject({
99
- fov: 50,
100
- projection: 'perspective',
101
- target: [10, 2, 20],
102
- viewWidth: 8,
103
- })
104
- expect(applied[0]?.position[0]).toBeCloseTo(15)
105
- expect(applied[0]?.position[1]).toBeCloseTo(5)
106
- expect(applied[0]?.position[2]).toBeCloseTo(20)
107
- })
108
-
109
- test('publishes sub-degree camera rotation for a real-time compass', () => {
110
- const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
111
- const bridge = createFloorplanCameraSyncBridge({
112
- applyCameraPose: () => {},
113
- publishNavigationPose: (pose) => published.push(pose),
114
- })
115
-
116
- bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
117
- bridge.receiveCameraPose(cameraPoseAtAzimuth((0.9 * Math.PI) / 180))
118
- expect(published).toHaveLength(2)
119
- expect(published[1]?.azimuth).toBeCloseTo((0.9 * Math.PI) / 180)
120
- })
121
-
122
- test('keeps a sub-degree camera rotation when it arrives with a pan', () => {
123
- const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
124
- const bridge = createFloorplanCameraSyncBridge({
125
- applyCameraPose: () => {},
126
- publishNavigationPose: (pose) => published.push(pose),
127
- })
128
-
129
- bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
130
- bridge.receiveCameraPose(cameraPoseAtAzimuth((0.5 * Math.PI) / 180, [1, 1, 0]))
131
-
132
- expect(published).toHaveLength(2)
133
- expect(published[1]).toMatchObject({
134
- target: [1, 1, 0],
135
- })
136
- expect(published[1]?.azimuth).toBeCloseTo((0.5 * Math.PI) / 180)
137
- })
138
-
139
- test('keeps streaming live camera headings in 3D-only mode', () => {
140
- const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
141
- const applied: CameraPose[] = []
142
- const bridge = createFloorplanCameraSyncBridge({
143
- active: false,
144
- applyCameraPose: (pose) => applied.push(pose),
145
- publishNavigationPose: (pose) => published.push(pose),
146
- })
147
- const latestPose = cameraPoseAtAzimuth(Math.PI / 2, [8, 1, 4])
148
-
149
- bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
150
- bridge.receiveCameraPose(latestPose)
151
- bridge.receiveNavigationPose({
152
- source: '2d',
153
- revision: 1,
154
- target: [10, 2, 20],
155
- azimuth: Math.PI / 2,
156
- viewWidth: 8,
157
- })
158
-
159
- expect(published).toEqual([
160
- {
161
- source: '3d',
162
- target: cameraPose.target,
163
- azimuth: 0,
164
- viewWidth: 12,
165
- },
166
- {
167
- source: '3d',
168
- target: latestPose.target,
169
- azimuth: Math.PI / 2,
170
- viewWidth: 12,
171
- },
172
- ])
173
- expect(applied).toEqual([])
174
-
175
- bridge.setActive(true)
176
- expect(published).toHaveLength(2)
177
- expect(applied).toEqual([])
178
- })
179
-
180
- test('applies the north-alignment pose used by the compass in 3D view', () => {
181
- const applied: CameraPose[] = []
182
- const bridge = createFloorplanCameraSyncBridge({
183
- applyCameraPose: (pose) => applied.push(pose),
184
- publishNavigationPose: () => {},
185
- })
186
- const currentPose = cameraPoseAtAzimuth(Math.PI / 2, [8, 1, 4])
187
-
188
- bridge.receiveCameraPose(currentPose)
189
- bridge.receiveNavigationPose({
190
- source: '2d',
191
- revision: 1,
192
- target: [...currentPose.target],
193
- azimuth: 0,
194
- viewWidth: currentPose.viewWidth!,
195
- })
196
-
197
- expect(applied).toHaveLength(1)
198
- expect(applied[0]).toMatchObject({
199
- target: currentPose.target,
200
- projection: currentPose.projection,
201
- viewWidth: currentPose.viewWidth,
202
- })
203
- expect(applied[0]?.position[0]).toBeCloseTo(currentPose.target[0])
204
- expect(applied[0]?.position[1]).toBeCloseTo(currentPose.position[1])
205
- expect(applied[0]?.position[2]).toBeCloseTo(currentPose.target[2] + 5)
206
- })
207
-
208
- test('applies 2D-first navigation on initial load without a 3D interaction', () => {
209
- const applied: CameraPose[] = []
210
- const bridge = createFloorplanCameraSyncBridge({
211
- applyCameraPose: (pose) => applied.push(pose),
212
- publishNavigationPose: () => {},
213
- })
214
-
215
- bridge.receiveNavigationPose({
216
- source: '2d',
217
- revision: 1,
218
- target: [10, 2, 20],
219
- azimuth: Math.PI / 2,
220
- viewWidth: 8,
221
- })
222
- expect(applied).toEqual([])
223
-
224
- publishInitialCameraPose(() => bridge.receiveCameraPose(cameraPose))
225
- expect(applied).toHaveLength(1)
226
- })
227
-
228
- test('delivers the live camera stream through transient subscribers', () => {
229
- const channel = createFloorplanCameraNavigationChannel()
230
- const received: NavigationSyncPose[] = []
231
- const unsubscribe = channel.subscribe((pose) => received.push(pose))
232
- const input = {
233
- source: '3d' as const,
234
- target: [0, 1, 2] as [number, number, number],
235
- azimuth: 0.5,
236
- viewWidth: 12,
237
- }
238
-
239
- channel.publish(input)
240
- channel.publish({ ...input, azimuth: 0.75 })
241
- unsubscribe()
242
- channel.publish({ ...input, azimuth: 1 })
243
-
244
- expect(received).toEqual([
245
- { ...input, revision: 1 },
246
- { ...input, azimuth: 0.75, revision: 2 },
247
- ])
248
- })
249
- })
@@ -1,213 +0,0 @@
1
- 'use client'
2
-
3
- import { type CameraPose, emitter } from '@pascal-app/core'
4
- import { useEffect, useRef } from 'react'
5
- import { subscribeCameraPose } from '../../store/camera-pose-store'
6
- import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store'
7
- import type { NavigationSyncPose, NavigationSyncPoseInput } from '../../store/use-editor'
8
-
9
- const POSITION_EPSILON = 0.001
10
- const AZIMUTH_EPSILON = 1e-4
11
- const VIEW_WIDTH_EPSILON = 0.001
12
-
13
- type FloorplanNavigationSnapshot = Omit<NavigationSyncPoseInput, 'source'>
14
-
15
- function angleDeltaRadians(a: number, b: number) {
16
- return Math.atan2(Math.sin(a - b), Math.cos(a - b))
17
- }
18
-
19
- function normalizeNearZero(value: number) {
20
- return Math.abs(value) < Number.EPSILON * 10 ? 0 : value
21
- }
22
-
23
- function navigationSnapshot(pose: NavigationSyncPoseInput): FloorplanNavigationSnapshot {
24
- return {
25
- target: [...pose.target],
26
- azimuth: pose.azimuth,
27
- viewWidth: pose.viewWidth,
28
- }
29
- }
30
-
31
- function navigationSnapshotsEqual(
32
- previous: FloorplanNavigationSnapshot,
33
- next: FloorplanNavigationSnapshot,
34
- ) {
35
- return (
36
- Math.abs(previous.target[0] - next.target[0]) < POSITION_EPSILON &&
37
- Math.abs(previous.target[1] - next.target[1]) < POSITION_EPSILON &&
38
- Math.abs(previous.target[2] - next.target[2]) < POSITION_EPSILON &&
39
- Math.abs(angleDeltaRadians(previous.azimuth, next.azimuth)) < AZIMUTH_EPSILON &&
40
- Math.abs(previous.viewWidth - next.viewWidth) < VIEW_WIDTH_EPSILON
41
- )
42
- }
43
-
44
- export function cameraPoseToFloorplanNavigationPose(
45
- pose: CameraPose,
46
- ): NavigationSyncPoseInput | null {
47
- if (!(pose.viewWidth !== undefined && Number.isFinite(pose.viewWidth) && pose.viewWidth > 0)) {
48
- return null
49
- }
50
-
51
- return {
52
- source: '3d',
53
- target: [...pose.target],
54
- azimuth: Math.atan2(pose.position[0] - pose.target[0], pose.position[2] - pose.target[2]),
55
- viewWidth: pose.viewWidth,
56
- }
57
- }
58
-
59
- export function floorplanNavigationPoseToCameraPose(
60
- navigationPose: NavigationSyncPose,
61
- cameraPose: CameraPose,
62
- ): CameraPose {
63
- const offsetX = cameraPose.position[0] - cameraPose.target[0]
64
- const offsetY = cameraPose.position[1] - cameraPose.target[1]
65
- const offsetZ = cameraPose.position[2] - cameraPose.target[2]
66
- const horizontalDistance = Math.hypot(offsetX, offsetZ)
67
- const horizontalX = normalizeNearZero(Math.sin(navigationPose.azimuth) * horizontalDistance)
68
- const horizontalZ = normalizeNearZero(Math.cos(navigationPose.azimuth) * horizontalDistance)
69
- const target: [number, number, number] = [...navigationPose.target]
70
-
71
- return {
72
- ...(cameraPose.fov === undefined ? {} : { fov: cameraPose.fov }),
73
- position: [target[0] + horizontalX, target[1] + offsetY, target[2] + horizontalZ],
74
- projection: cameraPose.projection,
75
- target,
76
- viewWidth: navigationPose.viewWidth,
77
- }
78
- }
79
-
80
- export type FloorplanCameraSyncBridge = {
81
- receiveCameraPose: (pose: CameraPose) => void
82
- receiveNavigationPose: (pose: NavigationSyncPose | null) => void
83
- setActive: (active: boolean) => void
84
- }
85
-
86
- export type FloorplanCameraNavigationChannel = {
87
- publish: (pose: NavigationSyncPoseInput) => void
88
- subscribe: (listener: (pose: NavigationSyncPose) => void) => () => void
89
- }
90
-
91
- export function createFloorplanCameraNavigationChannel(): FloorplanCameraNavigationChannel {
92
- const listeners = new Set<(pose: NavigationSyncPose) => void>()
93
- let revision = 0
94
-
95
- return {
96
- publish: (pose) => {
97
- revision += 1
98
- const revisedPose = { ...pose, revision }
99
- for (const listener of listeners) {
100
- listener(revisedPose)
101
- }
102
- },
103
- subscribe: (listener) => {
104
- listeners.add(listener)
105
- return () => listeners.delete(listener)
106
- },
107
- }
108
- }
109
-
110
- const liveCameraNavigation = createFloorplanCameraNavigationChannel()
111
-
112
- export function subscribeFloorplanCameraNavigation(listener: (pose: NavigationSyncPose) => void) {
113
- return liveCameraNavigation.subscribe(listener)
114
- }
115
-
116
- export function createFloorplanCameraSyncBridge({
117
- active: initialActive = true,
118
- applyCameraPose,
119
- publishNavigationPose,
120
- }: {
121
- active?: boolean
122
- applyCameraPose: (pose: CameraPose) => void
123
- publishNavigationPose: (pose: NavigationSyncPoseInput) => void
124
- }): FloorplanCameraSyncBridge {
125
- let active = initialActive
126
- let latestCameraPose: CameraPose | null = null
127
- let pendingNavigationPose: NavigationSyncPose | null = null
128
- let lastAppliedNavigationRevision = 0
129
- let lastPublishedNavigation: FloorplanNavigationSnapshot | null = null
130
-
131
- const applyPendingNavigationPose = () => {
132
- if (!(latestCameraPose && pendingNavigationPose)) return false
133
- if (pendingNavigationPose.revision === lastAppliedNavigationRevision) {
134
- pendingNavigationPose = null
135
- return false
136
- }
137
-
138
- const appliedPose = floorplanNavigationPoseToCameraPose(pendingNavigationPose, latestCameraPose)
139
- const appliedNavigationPose = cameraPoseToFloorplanNavigationPose(appliedPose)
140
- latestCameraPose = appliedPose
141
- lastAppliedNavigationRevision = pendingNavigationPose.revision
142
- pendingNavigationPose = null
143
- if (appliedNavigationPose) {
144
- lastPublishedNavigation = navigationSnapshot(appliedNavigationPose)
145
- }
146
- applyCameraPose(appliedPose)
147
- return true
148
- }
149
-
150
- const publishCameraNavigationPose = (pose: CameraPose) => {
151
- const navigationPose = cameraPoseToFloorplanNavigationPose(pose)
152
- if (!navigationPose) return
153
- const nextSnapshot = navigationSnapshot(navigationPose)
154
- if (
155
- lastPublishedNavigation &&
156
- navigationSnapshotsEqual(lastPublishedNavigation, nextSnapshot)
157
- ) {
158
- return
159
- }
160
-
161
- lastPublishedNavigation = nextSnapshot
162
- publishNavigationPose(navigationPose)
163
- }
164
-
165
- return {
166
- receiveCameraPose: (pose) => {
167
- latestCameraPose = pose
168
- if (active && applyPendingNavigationPose()) return
169
-
170
- publishCameraNavigationPose(pose)
171
- },
172
- receiveNavigationPose: (pose) => {
173
- if (
174
- !active ||
175
- pose?.source !== '2d' ||
176
- pose.revision === lastAppliedNavigationRevision ||
177
- pose.revision === pendingNavigationPose?.revision
178
- ) {
179
- return
180
- }
181
-
182
- pendingNavigationPose = pose
183
- applyPendingNavigationPose()
184
- },
185
- setActive: (nextActive) => {
186
- if (active === nextActive) return
187
- active = nextActive
188
- if (!active) {
189
- pendingNavigationPose = null
190
- return
191
- }
192
- if (latestCameraPose) publishCameraNavigationPose(latestCameraPose)
193
- },
194
- }
195
- }
196
-
197
- export function useFloorplanCameraSyncBridge() {
198
- // The compass is portalled into the always-visible viewer area, so its
199
- // orientation-only 2D pose must still reach the camera while the floorplan
200
- // itself is hidden in 3D view.
201
- const bridgeRef = useRef<FloorplanCameraSyncBridge | null>(null)
202
- if (!bridgeRef.current) {
203
- bridgeRef.current = createFloorplanCameraSyncBridge({
204
- applyCameraPose: (pose) => emitter.emit('camera-controls:apply-pose', pose),
205
- publishNavigationPose: (pose) => liveCameraNavigation.publish(pose),
206
- })
207
- }
208
- const bridge = bridgeRef.current
209
-
210
- useEffect(() => subscribeCameraPose(bridge.receiveCameraPose), [bridge])
211
-
212
- useEffect(() => subscribeNavigationSyncPose(bridge.receiveNavigationPose), [bridge])
213
- }
@@ -1,22 +0,0 @@
1
- 'use client'
2
-
3
- import { PascalI18nProvider, usePascalI18n } from '@pascal-app/core'
4
- import { Html as DreiHtml } from '@react-three/drei'
5
- import { type ComponentProps, forwardRef } from 'react'
6
-
7
- /**
8
- * drei's `Html`, keeping the editor's i18n scope. `Html` renders its children
9
- * in a separate React root, which sees none of the surrounding context, so a
10
- * component inside it would translate with the default (English) scope instead
11
- * of the editor's `?lang=` or `<Editor locale>` one.
12
- */
13
- export const Html = forwardRef<HTMLDivElement, Omit<ComponentProps<typeof DreiHtml>, 'ref'>>(
14
- function Html({ children, ...props }, ref) {
15
- const i18n = usePascalI18n()
16
- return (
17
- <DreiHtml {...props} ref={ref}>
18
- <PascalI18nProvider i18n={i18n}>{children}</PascalI18nProvider>
19
- </DreiHtml>
20
- )
21
- },
22
- )