@pascal-app/core 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 (339) hide show
  1. package/README.md +17 -10
  2. package/dist/events/bus.d.ts +21 -2
  3. package/dist/events/bus.d.ts.map +1 -1
  4. package/dist/hooks/spatial-grid/floor-placed-elevation.d.ts +17 -1
  5. package/dist/hooks/spatial-grid/floor-placed-elevation.d.ts.map +1 -1
  6. package/dist/hooks/spatial-grid/floor-placed-elevation.js +61 -8
  7. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +116 -66
  8. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
  9. package/dist/hooks/spatial-grid/spatial-grid-manager.js +316 -441
  10. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts +46 -1
  11. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
  12. package/dist/hooks/spatial-grid/spatial-grid-sync.js +193 -12
  13. package/dist/hooks/spatial-grid/support-host-id.d.ts +3 -0
  14. package/dist/hooks/spatial-grid/support-host-id.d.ts.map +1 -0
  15. package/dist/hooks/spatial-grid/support-host-id.js +2 -0
  16. package/dist/hooks/spatial-grid/support-host-patch.d.ts +51 -0
  17. package/dist/hooks/spatial-grid/support-host-patch.d.ts.map +1 -0
  18. package/dist/hooks/spatial-grid/support-host-patch.js +164 -0
  19. package/dist/index.d.ts +23 -11
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +22 -10
  22. package/dist/lib/measurement-geometry.d.ts +4 -0
  23. package/dist/lib/measurement-geometry.d.ts.map +1 -1
  24. package/dist/lib/measurement-geometry.js +27 -10
  25. package/dist/lib/slab-polygon.d.ts.map +1 -1
  26. package/dist/lib/slab-polygon.js +33 -1
  27. package/dist/lib/space-detection.d.ts +15 -4
  28. package/dist/lib/space-detection.d.ts.map +1 -1
  29. package/dist/lib/space-detection.js +174 -88
  30. package/dist/lib/terrain-brush.d.ts +163 -0
  31. package/dist/lib/terrain-brush.d.ts.map +1 -0
  32. package/dist/lib/terrain-brush.js +359 -0
  33. package/dist/lib/terrain-codec.d.ts +51 -0
  34. package/dist/lib/terrain-codec.d.ts.map +1 -0
  35. package/dist/lib/terrain-codec.js +167 -0
  36. package/dist/lib/terrain-field.d.ts +150 -0
  37. package/dist/lib/terrain-field.d.ts.map +1 -0
  38. package/dist/lib/terrain-field.js +248 -0
  39. package/dist/lib/terrain-raycast.d.ts +44 -0
  40. package/dist/lib/terrain-raycast.d.ts.map +1 -0
  41. package/dist/lib/terrain-raycast.js +188 -0
  42. package/dist/lib/terrain-source.d.ts +60 -0
  43. package/dist/lib/terrain-source.d.ts.map +1 -0
  44. package/dist/lib/terrain-source.js +90 -0
  45. package/dist/lib/terrain-support.d.ts +60 -0
  46. package/dist/lib/terrain-support.d.ts.map +1 -0
  47. package/dist/lib/terrain-support.js +150 -0
  48. package/dist/lib/zone-quantities.d.ts +1 -1
  49. package/dist/lib/zone-quantities.d.ts.map +1 -1
  50. package/dist/lib/zone-quantities.js +311 -66
  51. package/dist/material-library.d.ts +9 -1
  52. package/dist/material-library.d.ts.map +1 -1
  53. package/dist/material-library.js +56 -4
  54. package/dist/registry/handles.d.ts +16 -1
  55. package/dist/registry/handles.d.ts.map +1 -1
  56. package/dist/registry/index.d.ts +1 -1
  57. package/dist/registry/index.d.ts.map +1 -1
  58. package/dist/registry/subtree.d.ts.map +1 -1
  59. package/dist/registry/subtree.js +5 -2
  60. package/dist/registry/types.d.ts +64 -1
  61. package/dist/registry/types.d.ts.map +1 -1
  62. package/dist/schema/index.d.ts +6 -3
  63. package/dist/schema/index.d.ts.map +1 -1
  64. package/dist/schema/index.js +6 -3
  65. package/dist/schema/nodes/cabinet.d.ts +2 -0
  66. package/dist/schema/nodes/cabinet.d.ts.map +1 -1
  67. package/dist/schema/nodes/cabinet.js +4 -2
  68. package/dist/schema/nodes/ceiling.d.ts +1 -1
  69. package/dist/schema/nodes/ceiling.d.ts.map +1 -1
  70. package/dist/schema/nodes/ceiling.js +7 -1
  71. package/dist/schema/nodes/column.d.ts +1 -0
  72. package/dist/schema/nodes/column.d.ts.map +1 -1
  73. package/dist/schema/nodes/column.js +2 -0
  74. package/dist/schema/nodes/construction-dimension.d.ts +195 -0
  75. package/dist/schema/nodes/construction-dimension.d.ts.map +1 -0
  76. package/dist/schema/nodes/construction-dimension.js +151 -0
  77. package/dist/schema/nodes/door.d.ts +29 -0
  78. package/dist/schema/nodes/door.d.ts.map +1 -1
  79. package/dist/schema/nodes/door.js +20 -0
  80. package/dist/schema/nodes/duct-terminal.d.ts +1 -0
  81. package/dist/schema/nodes/duct-terminal.d.ts.map +1 -1
  82. package/dist/schema/nodes/duct-terminal.js +2 -0
  83. package/dist/schema/nodes/fence.d.ts +2 -0
  84. package/dist/schema/nodes/fence.d.ts.map +1 -1
  85. package/dist/schema/nodes/fence.js +8 -0
  86. package/dist/schema/nodes/hvac-equipment.d.ts +1 -0
  87. package/dist/schema/nodes/hvac-equipment.d.ts.map +1 -1
  88. package/dist/schema/nodes/hvac-equipment.js +2 -0
  89. package/dist/schema/nodes/item.d.ts +1 -0
  90. package/dist/schema/nodes/item.d.ts.map +1 -1
  91. package/dist/schema/nodes/item.js +13 -0
  92. package/dist/schema/nodes/level.d.ts +2 -1
  93. package/dist/schema/nodes/level.d.ts.map +1 -1
  94. package/dist/schema/nodes/level.js +12 -33
  95. package/dist/schema/nodes/shelf.d.ts +1 -0
  96. package/dist/schema/nodes/shelf.d.ts.map +1 -1
  97. package/dist/schema/nodes/shelf.js +2 -0
  98. package/dist/schema/nodes/site.d.ts +9 -0
  99. package/dist/schema/nodes/site.d.ts.map +1 -1
  100. package/dist/schema/nodes/site.js +8 -6
  101. package/dist/schema/nodes/slab.d.ts +5 -0
  102. package/dist/schema/nodes/slab.d.ts.map +1 -1
  103. package/dist/schema/nodes/slab.js +14 -2
  104. package/dist/schema/nodes/spawn.d.ts +1 -0
  105. package/dist/schema/nodes/spawn.d.ts.map +1 -1
  106. package/dist/schema/nodes/spawn.js +2 -0
  107. package/dist/schema/nodes/stair.d.ts +3 -1
  108. package/dist/schema/nodes/stair.d.ts.map +1 -1
  109. package/dist/schema/nodes/stair.js +8 -1
  110. package/dist/schema/nodes/structural-grid.d.ts +27 -0
  111. package/dist/schema/nodes/structural-grid.d.ts.map +1 -0
  112. package/dist/schema/nodes/structural-grid.js +17 -0
  113. package/dist/schema/nodes/wall.d.ts +5 -2
  114. package/dist/schema/nodes/wall.d.ts.map +1 -1
  115. package/dist/schema/nodes/wall.js +13 -4
  116. package/dist/schema/nodes/window.d.ts +29 -0
  117. package/dist/schema/nodes/window.d.ts.map +1 -1
  118. package/dist/schema/nodes/window.js +18 -0
  119. package/dist/schema/nodes/zone.d.ts +20 -0
  120. package/dist/schema/nodes/zone.d.ts.map +1 -1
  121. package/dist/schema/nodes/zone.js +15 -0
  122. package/dist/schema/terrain.d.ts +28 -0
  123. package/dist/schema/terrain.d.ts.map +1 -0
  124. package/dist/schema/terrain.js +30 -0
  125. package/dist/schema/types.d.ts +211 -3
  126. package/dist/schema/types.d.ts.map +1 -1
  127. package/dist/schema/types.js +4 -0
  128. package/dist/services/__fixtures__/wall-plane-top-boundary-repro.d.ts +80 -0
  129. package/dist/services/__fixtures__/wall-plane-top-boundary-repro.d.ts.map +1 -0
  130. package/dist/services/__fixtures__/wall-plane-top-boundary-repro.js +89 -0
  131. package/dist/services/index.d.ts +2 -1
  132. package/dist/services/index.d.ts.map +1 -1
  133. package/dist/services/index.js +2 -1
  134. package/dist/services/level-height.d.ts +8 -9
  135. package/dist/services/level-height.d.ts.map +1 -1
  136. package/dist/services/level-height.js +48 -16
  137. package/dist/services/storey.d.ts +103 -0
  138. package/dist/services/storey.d.ts.map +1 -0
  139. package/dist/services/storey.js +296 -0
  140. package/dist/store/actions/node-actions.d.ts.map +1 -1
  141. package/dist/store/actions/node-actions.js +47 -3
  142. package/dist/store/history-control.d.ts +20 -0
  143. package/dist/store/history-control.d.ts.map +1 -1
  144. package/dist/store/history-control.js +110 -13
  145. package/dist/store/use-live-terrain.d.ts +65 -0
  146. package/dist/store/use-live-terrain.d.ts.map +1 -0
  147. package/dist/store/use-live-terrain.js +31 -0
  148. package/dist/store/use-live-transforms.d.ts +8 -0
  149. package/dist/store/use-live-transforms.d.ts.map +1 -1
  150. package/dist/store/use-scene.d.ts +31 -2
  151. package/dist/store/use-scene.d.ts.map +1 -1
  152. package/dist/store/use-scene.js +638 -8
  153. package/dist/systems/elevator/elevator-opening-system.d.ts +1 -0
  154. package/dist/systems/elevator/elevator-opening-system.d.ts.map +1 -1
  155. package/dist/systems/elevator/elevator-opening-system.js +27 -21
  156. package/dist/systems/elevator/elevator-service.d.ts +0 -2
  157. package/dist/systems/elevator/elevator-service.d.ts.map +1 -1
  158. package/dist/systems/elevator/elevator-service.js +2 -24
  159. package/dist/systems/slab/slab-placement.d.ts +11 -0
  160. package/dist/systems/slab/slab-placement.d.ts.map +1 -0
  161. package/dist/systems/slab/slab-placement.js +14 -0
  162. package/dist/systems/slab/slab-support.d.ts +137 -0
  163. package/dist/systems/slab/slab-support.d.ts.map +1 -0
  164. package/dist/systems/slab/slab-support.js +557 -0
  165. package/dist/systems/stair/stair-opening-preview.d.ts +1060 -935
  166. package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -1
  167. package/dist/systems/stair/stair-opening-sync.d.ts.map +1 -1
  168. package/dist/systems/stair/stair-opening-sync.js +18 -15
  169. package/dist/systems/stair/stair-opening-system.d.ts.map +1 -1
  170. package/dist/systems/stair/stair-opening-system.js +32 -4
  171. package/dist/systems/stair/stair-rise.d.ts +22 -0
  172. package/dist/systems/stair/stair-rise.d.ts.map +1 -0
  173. package/dist/systems/stair/stair-rise.js +79 -0
  174. package/dist/systems/wall/wall-curve.d.ts +10 -0
  175. package/dist/systems/wall/wall-curve.d.ts.map +1 -1
  176. package/dist/systems/wall/wall-curve.js +1 -1
  177. package/dist/systems/wall/wall-mitering.js +6 -6
  178. package/dist/systems/wall/wall-top.d.ts +40 -0
  179. package/dist/systems/wall/wall-top.d.ts.map +1 -0
  180. package/dist/systems/wall/wall-top.js +46 -0
  181. package/dist/utils/clone-scene-graph.d.ts.map +1 -1
  182. package/dist/utils/clone-scene-graph.js +34 -3
  183. package/dist/utils/heal-scene-graph.d.ts +1 -1
  184. package/dist/utils/heal-scene-graph.d.ts.map +1 -1
  185. package/dist/utils/heal-scene-graph.js +25 -9
  186. package/dist/validation/validate-build-json.d.ts +2 -0
  187. package/dist/validation/validate-build-json.d.ts.map +1 -1
  188. package/dist/validation/validate-build-json.js +59 -3
  189. package/package.json +2 -2
  190. package/dist/hooks/scene-registry/scene-registry.test.d.ts +0 -2
  191. package/dist/hooks/scene-registry/scene-registry.test.d.ts.map +0 -1
  192. package/dist/hooks/scene-registry/scene-registry.test.js +0 -26
  193. package/dist/hooks/spatial-grid/floor-placed-elevation.test.d.ts +0 -2
  194. package/dist/hooks/spatial-grid/floor-placed-elevation.test.d.ts.map +0 -1
  195. package/dist/hooks/spatial-grid/floor-placed-elevation.test.js +0 -366
  196. package/dist/hooks/spatial-grid/wall-slab-overlap.test.d.ts +0 -2
  197. package/dist/hooks/spatial-grid/wall-slab-overlap.test.d.ts.map +0 -1
  198. package/dist/hooks/spatial-grid/wall-slab-overlap.test.js +0 -363
  199. package/dist/lib/measurement-geometry.test.d.ts +0 -2
  200. package/dist/lib/measurement-geometry.test.d.ts.map +0 -1
  201. package/dist/lib/measurement-geometry.test.js +0 -102
  202. package/dist/lib/slab-polygon.test.d.ts +0 -2
  203. package/dist/lib/slab-polygon.test.d.ts.map +0 -1
  204. package/dist/lib/slab-polygon.test.js +0 -635
  205. package/dist/lib/space-detection-pause.test.d.ts +0 -2
  206. package/dist/lib/space-detection-pause.test.d.ts.map +0 -1
  207. package/dist/lib/space-detection-pause.test.js +0 -42
  208. package/dist/lib/space-detection.test.d.ts +0 -2
  209. package/dist/lib/space-detection.test.d.ts.map +0 -1
  210. package/dist/lib/space-detection.test.js +0 -376
  211. package/dist/lib/zone-quantities.test.d.ts +0 -2
  212. package/dist/lib/zone-quantities.test.d.ts.map +0 -1
  213. package/dist/lib/zone-quantities.test.js +0 -82
  214. package/dist/registry/registry.test.d.ts +0 -2
  215. package/dist/registry/registry.test.d.ts.map +0 -1
  216. package/dist/registry/registry.test.js +0 -196
  217. package/dist/registry/relations-resolver.test.d.ts +0 -2
  218. package/dist/registry/relations-resolver.test.d.ts.map +0 -1
  219. package/dist/registry/relations-resolver.test.js +0 -183
  220. package/dist/registry/scene-api.test.d.ts +0 -2
  221. package/dist/registry/scene-api.test.d.ts.map +0 -1
  222. package/dist/registry/scene-api.test.js +0 -183
  223. package/dist/registry/subtree.test.d.ts +0 -2
  224. package/dist/registry/subtree.test.d.ts.map +0 -1
  225. package/dist/registry/subtree.test.js +0 -163
  226. package/dist/schema/asset-url.test.d.ts +0 -2
  227. package/dist/schema/asset-url.test.d.ts.map +0 -1
  228. package/dist/schema/asset-url.test.js +0 -134
  229. package/dist/schema/material.test.d.ts +0 -2
  230. package/dist/schema/material.test.d.ts.map +0 -1
  231. package/dist/schema/material.test.js +0 -43
  232. package/dist/schema/nodes/measurement.test.d.ts +0 -2
  233. package/dist/schema/nodes/measurement.test.d.ts.map +0 -1
  234. package/dist/schema/nodes/measurement.test.js +0 -139
  235. package/dist/schema/nodes/ridge-vent-defaults.test.d.ts +0 -2
  236. package/dist/schema/nodes/ridge-vent-defaults.test.d.ts.map +0 -1
  237. package/dist/schema/nodes/ridge-vent-defaults.test.js +0 -205
  238. package/dist/schema/nodes/roof-segment-shape.test.d.ts +0 -2
  239. package/dist/schema/nodes/roof-segment-shape.test.d.ts.map +0 -1
  240. package/dist/schema/nodes/roof-segment-shape.test.js +0 -175
  241. package/dist/schema/nodes/roof-segment-surface.test.d.ts +0 -2
  242. package/dist/schema/nodes/roof-segment-surface.test.d.ts.map +0 -1
  243. package/dist/schema/nodes/roof-segment-surface.test.js +0 -48
  244. package/dist/schema/nodes/roof-segment-trim.test.d.ts +0 -2
  245. package/dist/schema/nodes/roof-segment-trim.test.d.ts.map +0 -1
  246. package/dist/schema/nodes/roof-segment-trim.test.js +0 -107
  247. package/dist/schema/nodes/roof-segment-walls.test.d.ts +0 -2
  248. package/dist/schema/nodes/roof-segment-walls.test.d.ts.map +0 -1
  249. package/dist/schema/nodes/roof-segment-walls.test.js +0 -92
  250. package/dist/schema/nodes/wall.test.d.ts +0 -2
  251. package/dist/schema/nodes/wall.test.d.ts.map +0 -1
  252. package/dist/schema/nodes/wall.test.js +0 -208
  253. package/dist/services/alignment-anchors.test.d.ts +0 -2
  254. package/dist/services/alignment-anchors.test.d.ts.map +0 -1
  255. package/dist/services/alignment-anchors.test.js +0 -383
  256. package/dist/services/alignment.test.d.ts +0 -2
  257. package/dist/services/alignment.test.d.ts.map +0 -1
  258. package/dist/services/alignment.test.js +0 -92
  259. package/dist/services/drag-session.test.d.ts +0 -2
  260. package/dist/services/drag-session.test.d.ts.map +0 -1
  261. package/dist/services/drag-session.test.js +0 -219
  262. package/dist/services/hosting.test.d.ts +0 -2
  263. package/dist/services/hosting.test.d.ts.map +0 -1
  264. package/dist/services/hosting.test.js +0 -230
  265. package/dist/services/movement.test.d.ts +0 -2
  266. package/dist/services/movement.test.d.ts.map +0 -1
  267. package/dist/services/movement.test.js +0 -133
  268. package/dist/services/opening-guides.test.d.ts +0 -2
  269. package/dist/services/opening-guides.test.d.ts.map +0 -1
  270. package/dist/services/opening-guides.test.js +0 -208
  271. package/dist/services/port-connectivity.test.d.ts +0 -2
  272. package/dist/services/port-connectivity.test.d.ts.map +0 -1
  273. package/dist/services/port-connectivity.test.js +0 -297
  274. package/dist/services/riser-diagram.test.d.ts +0 -2
  275. package/dist/services/riser-diagram.test.d.ts.map +0 -1
  276. package/dist/services/riser-diagram.test.js +0 -66
  277. package/dist/services/single-undo-dance.test.d.ts +0 -2
  278. package/dist/services/single-undo-dance.test.d.ts.map +0 -1
  279. package/dist/services/single-undo-dance.test.js +0 -210
  280. package/dist/services/snap.test.d.ts +0 -2
  281. package/dist/services/snap.test.d.ts.map +0 -1
  282. package/dist/services/snap.test.js +0 -137
  283. package/dist/services/system-graph.test.d.ts +0 -2
  284. package/dist/services/system-graph.test.d.ts.map +0 -1
  285. package/dist/services/system-graph.test.js +0 -138
  286. package/dist/services/validate-dwv.test.d.ts +0 -2
  287. package/dist/services/validate-dwv.test.d.ts.map +0 -1
  288. package/dist/services/validate-dwv.test.js +0 -109
  289. package/dist/store/actions/node-mutation-sanitize.test.d.ts +0 -2
  290. package/dist/store/actions/node-mutation-sanitize.test.d.ts.map +0 -1
  291. package/dist/store/actions/node-mutation-sanitize.test.js +0 -147
  292. package/dist/store/actions/reparent.test.d.ts +0 -2
  293. package/dist/store/actions/reparent.test.d.ts.map +0 -1
  294. package/dist/store/actions/reparent.test.js +0 -212
  295. package/dist/store/actions/ridge-vent-update.test.d.ts +0 -2
  296. package/dist/store/actions/ridge-vent-update.test.d.ts.map +0 -1
  297. package/dist/store/actions/ridge-vent-update.test.js +0 -264
  298. package/dist/store/use-scene-dirty-tracking.test.d.ts +0 -2
  299. package/dist/store/use-scene-dirty-tracking.test.d.ts.map +0 -1
  300. package/dist/store/use-scene-dirty-tracking.test.js +0 -68
  301. package/dist/store/use-scene-elevator-migration.test.d.ts +0 -2
  302. package/dist/store/use-scene-elevator-migration.test.d.ts.map +0 -1
  303. package/dist/store/use-scene-elevator-migration.test.js +0 -109
  304. package/dist/store/use-scene-measurement-roundtrip.test.d.ts +0 -2
  305. package/dist/store/use-scene-measurement-roundtrip.test.d.ts.map +0 -1
  306. package/dist/store/use-scene-measurement-roundtrip.test.js +0 -97
  307. package/dist/store/use-scene-plugins.test.d.ts +0 -2
  308. package/dist/store/use-scene-plugins.test.d.ts.map +0 -1
  309. package/dist/store/use-scene-plugins.test.js +0 -51
  310. package/dist/store/use-scene-wall-slot-migration.test.d.ts +0 -2
  311. package/dist/store/use-scene-wall-slot-migration.test.d.ts.map +0 -1
  312. package/dist/store/use-scene-wall-slot-migration.test.js +0 -210
  313. package/dist/store/use-scene-window-migration.test.d.ts +0 -2
  314. package/dist/store/use-scene-window-migration.test.d.ts.map +0 -1
  315. package/dist/store/use-scene-window-migration.test.js +0 -85
  316. package/dist/systems/elevator/elevator-opening-sync.test.d.ts +0 -2
  317. package/dist/systems/elevator/elevator-opening-sync.test.d.ts.map +0 -1
  318. package/dist/systems/elevator/elevator-opening-sync.test.js +0 -146
  319. package/dist/systems/elevator/elevator-runtime.test.d.ts +0 -2
  320. package/dist/systems/elevator/elevator-runtime.test.d.ts.map +0 -1
  321. package/dist/systems/elevator/elevator-runtime.test.js +0 -64
  322. package/dist/systems/fence/fence-spline.test.d.ts +0 -2
  323. package/dist/systems/fence/fence-spline.test.d.ts.map +0 -1
  324. package/dist/systems/fence/fence-spline.test.js +0 -136
  325. package/dist/systems/stair/stair-opening-preview.test.d.ts +0 -2
  326. package/dist/systems/stair/stair-opening-preview.test.d.ts.map +0 -1
  327. package/dist/systems/stair/stair-opening-preview.test.js +0 -80
  328. package/dist/systems/stair/stair-opening-sync.test.d.ts +0 -2
  329. package/dist/systems/stair/stair-opening-sync.test.d.ts.map +0 -1
  330. package/dist/systems/stair/stair-opening-sync.test.js +0 -485
  331. package/dist/systems/wall/wall-mitering.test.d.ts +0 -2
  332. package/dist/systems/wall/wall-mitering.test.d.ts.map +0 -1
  333. package/dist/systems/wall/wall-mitering.test.js +0 -63
  334. package/dist/utils/clone-scene-graph.test.d.ts +0 -2
  335. package/dist/utils/clone-scene-graph.test.d.ts.map +0 -1
  336. package/dist/utils/clone-scene-graph.test.js +0 -62
  337. package/dist/utils/heal-scene-graph.test.d.ts +0 -2
  338. package/dist/utils/heal-scene-graph.test.d.ts.map +0 -1
  339. package/dist/utils/heal-scene-graph.test.js +0 -88
@@ -0,0 +1,359 @@
1
+ /**
2
+ * The sculpt brush: pointer motion → `HeightPatch`.
3
+ *
4
+ * The obvious implementation — `h += strength * falloff` every tick — is wrong,
5
+ * and it is wrong in a way that is well documented by the people who ship it.
6
+ * Dwelling in one spot accumulates without bound, overlapping dabs double-apply,
7
+ * and a fast drag deposits less material than a slow one. That is exactly the
8
+ * "hash edges and jagged" result expert Sims builders complain about and route
9
+ * around. This module implements the model the mature tools converge on instead:
10
+ *
11
+ * 1. **Freeze a snapshot at pointer-down.** Every dab is evaluated against the
12
+ * snapshot, never against the running result.
13
+ * 2. **Saturating coverage mask.** A dab raises coverage to `max(mask, falloff)`
14
+ * rather than adding to it, and the height is always
15
+ * `snapshot + delta * mask`. Re-crossing your own stroke therefore cannot
16
+ * compound — the second pass recomputes the same value from the same
17
+ * snapshot.
18
+ * 3. **Arc-length dab spacing.** `advanceStroke` walks from the previous dab to
19
+ * the new pointer position in fixed metre steps, so stroke strength is a
20
+ * function of distance travelled, not of mouse speed or frame rate.
21
+ * 4. **Cosine-bell falloff.** Of the standard sculpt kernels this is one of only
22
+ * two that are C¹ at *both* centre and rim, which is what prevents a visible
23
+ * ridge at the brush boundary.
24
+ *
25
+ * The consequence users feel: one stroke is one bounded, repeatable edit. Hold
26
+ * still and nothing runs away; press again to go further.
27
+ *
28
+ * The `TerrainStroke` returned by `beginStroke` is a mutable accumulator — the
29
+ * one mutable thing in this file, and deliberately so. It is owned by exactly
30
+ * one in-flight gesture and dies with it, which is cheaper and clearer than
31
+ * rebuilding a coverage map per pointer-move.
32
+ */
33
+ import { heightAt, heightAtSample, quantize, } from './terrain-field';
34
+ export const DEFAULT_BRUSH_SETTINGS = {
35
+ radius: 2,
36
+ strength: 0.6,
37
+ falloff: 0.6,
38
+ shape: 'round',
39
+ };
40
+ /**
41
+ * Metres a single raise/lower stroke moves the ground at full strength and full
42
+ * coverage. Bounding it per stroke is what makes the verb aimable: the user
43
+ * knows a pass is worth at most this much, and repeats for more.
44
+ */
45
+ export const RAISE_METRES_PER_STROKE = 1;
46
+ /**
47
+ * Smallest brush radius that still bites, as a multiple of the field's spacing.
48
+ *
49
+ * A dab only moves samples that fall *inside* its radius, so a brush narrower
50
+ * than the gap between samples can land entirely between four of them and change
51
+ * nothing: `advanceStroke` returns `null`, and the user drags with no mark, no
52
+ * cursor change, and no error. 1.5 rather than the ~0.71 where a round brush
53
+ * first catches a corner, because a brush that only bites when it happens to
54
+ * straddle a sample is worse than one that is honestly too small — at 1.5 the
55
+ * worst-case coverage is 0.97, so the dab lands wherever it is aimed.
56
+ *
57
+ * A *multiple* of spacing rather than a constant because spacing is not fixed:
58
+ * `fieldExtentForSite` stretches it once a lot exceeds what 257² samples cover
59
+ * at the default, so on a 300 m lot the old flat 0.5 m floor sat below half the
60
+ * sample gap and the brush silently stopped working at the bottom of its range.
61
+ */
62
+ export const MIN_BRUSH_RADIUS_IN_SPACINGS = 1.5;
63
+ /**
64
+ * Clamp a radius to what `field` can actually resolve.
65
+ *
66
+ * Lives here rather than in the editor's UI layer because it is a fact about the
67
+ * footprint math above, not a preference: every producer of a radius (slider,
68
+ * `[`/`]`, a future import preset) has to respect it or it produces a brush that
69
+ * does nothing.
70
+ */
71
+ export function minBrushRadius(field) {
72
+ return field.spacing * MIN_BRUSH_RADIUS_IN_SPACINGS;
73
+ }
74
+ /** Dabs are placed every `radius * this` metres along the pointer path. */
75
+ const DAB_SPACING_FRACTION = 0.25;
76
+ /**
77
+ * Jacobi iterations used to precompute the smooth target. Each is applied with
78
+ * λ = 0.25 on a 4-neighbour stencil, the stability bound for the discrete
79
+ * diffusion — above it the filter oscillates instead of converging.
80
+ */
81
+ const SMOOTH_ITERATIONS = 8;
82
+ const SMOOTH_LAMBDA = 0.25;
83
+ export function beginStroke(options) {
84
+ return {
85
+ verb: options.verb,
86
+ settings: { ...DEFAULT_BRUSH_SETTINGS, ...options.settings },
87
+ snapshot: options.field,
88
+ target: options.target ?? 0,
89
+ mask: new Map(),
90
+ smoothed: null,
91
+ lastX: null,
92
+ lastZ: null,
93
+ };
94
+ }
95
+ /**
96
+ * Sample the height a `flatten` stroke should aim at, from the field itself.
97
+ *
98
+ * Sampling before the stroke starts (right-click / first click, per the surveyed
99
+ * tools) is what makes "flatten this pad to match that corner" a two-step
100
+ * gesture instead of a numeric-entry chore.
101
+ */
102
+ export function sampleTarget(field, x, z) {
103
+ return heightAt(field, x, z);
104
+ }
105
+ /**
106
+ * Forget where the last dab landed, without ending the stroke.
107
+ *
108
+ * Needed because dab spacing is a true *arc length*: `advanceStroke` interpolates
109
+ * from the previous centre, so any gap in the pointer stream is bridged with a
110
+ * line of dabs when it resumes. That is exactly right for a dropped frame, and
111
+ * exactly wrong when the caller deliberately skipped a stretch — a camera zoom
112
+ * mid-stroke moves the ground under a held brush, and bridging back to a centre
113
+ * from before the zoom paints a stripe across everything in between.
114
+ *
115
+ * A stroke resumed after this deposits its next dab at the pointer, like the
116
+ * first one. The mask is untouched, so the stroke stays saturating across the
117
+ * break and the whole gesture is still one undo step.
118
+ */
119
+ export function detachStrokeAnchor(stroke) {
120
+ stroke.lastX = null;
121
+ stroke.lastZ = null;
122
+ }
123
+ /**
124
+ * Extend the stroke to the pointer's new position and return the patch to apply.
125
+ *
126
+ * Returns `null` when the motion is shorter than the dab spacing (nothing
127
+ * changed yet) or when the footprint misses the field entirely. The patch is
128
+ * absolute heights over the union of the dabs placed by *this* call, recomputed
129
+ * from the snapshot and the saturated mask — which is why applying patches in
130
+ * order converges rather than compounding.
131
+ */
132
+ export function advanceStroke(stroke, x, z) {
133
+ const dabs = dabPositions(stroke, x, z);
134
+ if (dabs.length === 0)
135
+ return null;
136
+ const field = stroke.snapshot;
137
+ let minCol = field.cols;
138
+ let minRow = field.rows;
139
+ let maxCol = -1;
140
+ let maxRow = -1;
141
+ for (const [dx, dz] of dabs) {
142
+ const range = footprint(field, dx, dz, stroke.settings.radius);
143
+ if (!range)
144
+ continue;
145
+ if (range.col0 < minCol)
146
+ minCol = range.col0;
147
+ if (range.row0 < minRow)
148
+ minRow = range.row0;
149
+ if (range.col1 > maxCol)
150
+ maxCol = range.col1;
151
+ if (range.row1 > maxRow)
152
+ maxRow = range.row1;
153
+ for (let r = range.row0; r <= range.row1; r++) {
154
+ const sampleZ = field.origin[1] + r * field.spacing;
155
+ for (let c = range.col0; c <= range.col1; c++) {
156
+ const sampleX = field.origin[0] + c * field.spacing;
157
+ const coverage = weightAt(stroke.settings, sampleX - dx, sampleZ - dz);
158
+ if (coverage <= 0)
159
+ continue;
160
+ const index = r * field.cols + c;
161
+ const previous = stroke.mask.get(index) ?? 0;
162
+ if (coverage > previous)
163
+ stroke.mask.set(index, coverage);
164
+ }
165
+ }
166
+ }
167
+ if (maxCol < minCol || maxRow < minRow)
168
+ return null;
169
+ const cols = maxCol - minCol + 1;
170
+ const rows = maxRow - minRow + 1;
171
+ const heights = new Int16Array(cols * rows);
172
+ for (let r = 0; r < rows; r++) {
173
+ const fieldRow = minRow + r;
174
+ for (let c = 0; c < cols; c++) {
175
+ const fieldCol = minCol + c;
176
+ const index = fieldRow * field.cols + fieldCol;
177
+ heights[r * cols + c] = resolveSample(stroke, index, fieldCol, fieldRow);
178
+ }
179
+ }
180
+ return { col0: minCol, row0: minRow, cols, rows, heights };
181
+ }
182
+ /**
183
+ * The quantized height a sample should hold given the stroke's current mask.
184
+ *
185
+ * Every verb has the same shape — `snapshot + (goal - snapshot) * mask *
186
+ * strength` — which is what makes them all saturating. `raise`/`lower` express
187
+ * their goal as an offset from the snapshot, `flatten` as an absolute, and
188
+ * `smooth` as the diffused snapshot.
189
+ */
190
+ function resolveSample(stroke, index, col, row) {
191
+ const field = stroke.snapshot;
192
+ const base = field.heights[index] ?? 0;
193
+ const coverage = stroke.mask.get(index) ?? 0;
194
+ if (coverage <= 0)
195
+ return base;
196
+ const amount = coverage * stroke.settings.strength;
197
+ const baseMetres = base * field.step;
198
+ switch (stroke.verb) {
199
+ case 'raise':
200
+ return quantize(field, baseMetres + RAISE_METRES_PER_STROKE * amount);
201
+ case 'lower':
202
+ return quantize(field, baseMetres - RAISE_METRES_PER_STROKE * amount);
203
+ case 'flatten':
204
+ return quantize(field, baseMetres + (stroke.target - baseMetres) * amount);
205
+ case 'smooth': {
206
+ const smoothed = smoothedSnapshot(stroke);
207
+ const goal = (smoothed[index] ?? base) * field.step;
208
+ return quantize(field, baseMetres + (goal - baseMetres) * amount);
209
+ }
210
+ default:
211
+ // `col`/`row` are carried for future verbs that need position (terrace).
212
+ void col;
213
+ void row;
214
+ return base;
215
+ }
216
+ }
217
+ /**
218
+ * The diffused snapshot every `smooth` dab lerps toward, computed once per
219
+ * stroke.
220
+ *
221
+ * Whole-field rather than stroke-local: a local blur would need its border
222
+ * samples pinned, and pinned borders are precisely where a smooth brush leaves
223
+ * the ridge it was supposed to remove. The cost is a few passes over an array
224
+ * that is 4 225 entries at the default 65² size.
225
+ */
226
+ function smoothedSnapshot(stroke) {
227
+ if (stroke.smoothed)
228
+ return stroke.smoothed;
229
+ const field = stroke.snapshot;
230
+ let current = new Int16Array(field.heights);
231
+ let next = new Int16Array(current.length);
232
+ for (let iteration = 0; iteration < SMOOTH_ITERATIONS; iteration++) {
233
+ for (let r = 0; r < field.rows; r++) {
234
+ for (let c = 0; c < field.cols; c++) {
235
+ const index = r * field.cols + c;
236
+ const centre = current[index] ?? 0;
237
+ const laplacian = sampleClamped(current, field, c - 1, r) +
238
+ sampleClamped(current, field, c + 1, r) +
239
+ sampleClamped(current, field, c, r - 1) +
240
+ sampleClamped(current, field, c, r + 1) -
241
+ 4 * centre;
242
+ next[index] = Math.round(centre + SMOOTH_LAMBDA * laplacian);
243
+ }
244
+ }
245
+ const swap = current;
246
+ current = next;
247
+ next = swap;
248
+ }
249
+ stroke.smoothed = current;
250
+ return current;
251
+ }
252
+ function sampleClamped(heights, field, col, row) {
253
+ const c = Math.max(0, Math.min(field.cols - 1, col));
254
+ const r = Math.max(0, Math.min(field.rows - 1, row));
255
+ return heights[r * field.cols + c] ?? 0;
256
+ }
257
+ /**
258
+ * Dab centres from the last one to `(x, z)`, spaced by arc length.
259
+ *
260
+ * The first dab of a stroke is placed exactly at the pointer, so a click with no
261
+ * drag still deposits one dab. After that, motion shorter than one spacing
262
+ * deposits nothing and the stroke keeps its old anchor — which is what makes the
263
+ * spacing a true arc length rather than a per-event budget.
264
+ */
265
+ function dabPositions(stroke, x, z) {
266
+ if (stroke.lastX === null || stroke.lastZ === null) {
267
+ stroke.lastX = x;
268
+ stroke.lastZ = z;
269
+ return [[x, z]];
270
+ }
271
+ const spacing = Math.max(1e-3, stroke.settings.radius * DAB_SPACING_FRACTION);
272
+ const dx = x - stroke.lastX;
273
+ const dz = z - stroke.lastZ;
274
+ const distance = Math.hypot(dx, dz);
275
+ if (distance < spacing)
276
+ return [];
277
+ const steps = Math.floor(distance / spacing);
278
+ const dabs = [];
279
+ for (let step = 1; step <= steps; step++) {
280
+ const t = (step * spacing) / distance;
281
+ dabs.push([stroke.lastX + dx * t, stroke.lastZ + dz * t]);
282
+ }
283
+ const last = dabs[dabs.length - 1];
284
+ if (last) {
285
+ stroke.lastX = last[0];
286
+ stroke.lastZ = last[1];
287
+ }
288
+ return dabs;
289
+ }
290
+ /** Sample range a dab of `radius` at `(x, z)` touches, clamped to the field. */
291
+ function footprint(field, x, z, radius) {
292
+ const col0 = Math.max(0, Math.ceil((x - radius - field.origin[0]) / field.spacing));
293
+ const row0 = Math.max(0, Math.ceil((z - radius - field.origin[1]) / field.spacing));
294
+ const col1 = Math.min(field.cols - 1, Math.floor((x + radius - field.origin[0]) / field.spacing));
295
+ const row1 = Math.min(field.rows - 1, Math.floor((z + radius - field.origin[1]) / field.spacing));
296
+ if (col1 < col0 || row1 < row0)
297
+ return null;
298
+ return { col0, row0, col1, row1 };
299
+ }
300
+ /**
301
+ * Coverage in 0–1 for a sample at offset `(dx, dz)` from a dab centre.
302
+ *
303
+ * Round uses Euclidean distance, square uses Chebyshev — the same radius means
304
+ * the square brush's inscribed circle matches the round one, so switching shape
305
+ * mid-session does not change the brush's apparent size.
306
+ */
307
+ export function weightAt(settings, dx, dz) {
308
+ const radius = Math.max(1e-6, settings.radius);
309
+ const distance = settings.shape === 'square' ? Math.max(Math.abs(dx), Math.abs(dz)) : Math.hypot(dx, dz);
310
+ const normalized = distance / radius;
311
+ if (normalized >= 1)
312
+ return 0;
313
+ const falloff = Math.min(1, Math.max(0, settings.falloff));
314
+ if (falloff <= 0)
315
+ return 1;
316
+ const inner = 1 - falloff;
317
+ if (normalized <= inner)
318
+ return 1;
319
+ const t = (normalized - inner) / falloff;
320
+ // Cosine bell: 1 at t=0, 0 at t=1, zero derivative at both ends.
321
+ return 0.5 * (1 + Math.cos(Math.PI * t));
322
+ }
323
+ /**
324
+ * The world-XZ height under a brush, for the readout the sculpt HUD shows.
325
+ * Thin, but it keeps the tool from importing `heightAt` just to render a number.
326
+ */
327
+ export function brushHeightAt(field, x, z) {
328
+ return heightAt(field, x, z);
329
+ }
330
+ /** Peak coverage the stroke reached, for tests and for the HUD's "pass" readout. */
331
+ export function maxCoverage(stroke) {
332
+ let max = 0;
333
+ for (const value of stroke.mask.values())
334
+ if (value > max)
335
+ max = value;
336
+ return max;
337
+ }
338
+ /**
339
+ * Highest sample height in metres under a world-XZ rect — the seed a `flatten`
340
+ * pad uses when the user asks for "level with the high corner" rather than
341
+ * typing a number.
342
+ */
343
+ export function highestOver(field, minX, minZ, maxX, maxZ) {
344
+ const col0 = Math.max(0, Math.floor((minX - field.origin[0]) / field.spacing));
345
+ const row0 = Math.max(0, Math.floor((minZ - field.origin[1]) / field.spacing));
346
+ const col1 = Math.min(field.cols - 1, Math.ceil((maxX - field.origin[0]) / field.spacing));
347
+ const row1 = Math.min(field.rows - 1, Math.ceil((maxZ - field.origin[1]) / field.spacing));
348
+ if (col1 < col0 || row1 < row0)
349
+ return heightAtSample(field, col0, row0);
350
+ let highest = Number.NEGATIVE_INFINITY;
351
+ for (let r = row0; r <= row1; r++) {
352
+ for (let c = col0; c <= col1; c++) {
353
+ const value = (field.heights[r * field.cols + c] ?? 0) * field.step;
354
+ if (value > highest)
355
+ highest = value;
356
+ }
357
+ }
358
+ return highest;
359
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The wire format for a `TerrainField`.
3
+ *
4
+ * The scene graph is persisted as JSON (`projects_models.scene_graph`, a jsonb
5
+ * column), so an `Int16Array` cannot be stored directly — `JSON.stringify` turns
6
+ * a typed array into an object with numeric string keys, which round-trips
7
+ * lossily and costs ~7 bytes per sample. This module is the one place the field
8
+ * crosses that boundary.
9
+ *
10
+ * Base64 of little-endian `Int16` was chosen over the alternatives:
11
+ *
12
+ * - **A plain number array** costs 4-6× the bytes and, worse, re-parses into a
13
+ * `number[]` that every consumer would then have to copy into an `Int16Array`.
14
+ * - **`Array.from(heights)`** has the same problem and silently drops the
15
+ * quantization invariant: nothing stops a hand-edited scene from putting
16
+ * `2.5` in the array, and then `isFlatOver`'s integer equality is meaningless.
17
+ * - **Compression** (RLE, deflate) is deliberately *not* here. Imported DEMs are
18
+ * noisy and compress worst, so a compressed format would fail only on the
19
+ * messiest scenes — the ones most likely to be real. Chunking (`diffToPatches`)
20
+ * is the size mitigation, not compression.
21
+ *
22
+ * Byte order is written explicitly via `DataView` rather than relying on
23
+ * `Int16Array`'s platform order. Every realistic platform is little-endian, but
24
+ * the wire format must not depend on that being true of the machine that saved
25
+ * the scene.
26
+ */
27
+ import { type TerrainData } from '../schema/terrain';
28
+ import type { TerrainField } from './terrain-field';
29
+ export declare function encodeTerrainField(field: TerrainField): TerrainData;
30
+ /**
31
+ * Rebuild a field from persisted data, or `null` if the data is unusable.
32
+ *
33
+ * Returns `null` rather than throwing, and rather than repairing: a scene whose
34
+ * terrain fails to decode must still load (with flat ground) instead of taking
35
+ * the whole project down, and silently substituting a *different* terrain would
36
+ * be worse than showing none. The caller treats `null` as "this site has no
37
+ * terrain".
38
+ *
39
+ * Metadata and payload length are strict. Repairing a truncated payload or
40
+ * inventing missing metadata would silently substitute a different surface.
41
+ */
42
+ export declare function decodeTerrainField(data: unknown): TerrainField | null;
43
+ /**
44
+ * True when every sample is at the datum — the test for "this field is not worth
45
+ * persisting".
46
+ *
47
+ * Called before writing to the node so an untouched site keeps `terrain:
48
+ * undefined` instead of carrying ~11 KB of base64 zeroes in every saved scene.
49
+ */
50
+ export declare function isDatumField(field: TerrainField): boolean;
51
+ //# sourceMappingURL=terrain-codec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"terrain-codec.d.ts","sourceRoot":"","sources":["../../src/lib/terrain-codec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,EAAoB,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAA;AACtE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAoDnD,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,YAAY,GAAG,WAAW,CAenE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,OAAO,GAAG,YAAY,GAAG,IAAI,CAwCrE;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAKzD"}
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The wire format for a `TerrainField`.
3
+ *
4
+ * The scene graph is persisted as JSON (`projects_models.scene_graph`, a jsonb
5
+ * column), so an `Int16Array` cannot be stored directly — `JSON.stringify` turns
6
+ * a typed array into an object with numeric string keys, which round-trips
7
+ * lossily and costs ~7 bytes per sample. This module is the one place the field
8
+ * crosses that boundary.
9
+ *
10
+ * Base64 of little-endian `Int16` was chosen over the alternatives:
11
+ *
12
+ * - **A plain number array** costs 4-6× the bytes and, worse, re-parses into a
13
+ * `number[]` that every consumer would then have to copy into an `Int16Array`.
14
+ * - **`Array.from(heights)`** has the same problem and silently drops the
15
+ * quantization invariant: nothing stops a hand-edited scene from putting
16
+ * `2.5` in the array, and then `isFlatOver`'s integer equality is meaningless.
17
+ * - **Compression** (RLE, deflate) is deliberately *not* here. Imported DEMs are
18
+ * noisy and compress worst, so a compressed format would fail only on the
19
+ * messiest scenes — the ones most likely to be real. Chunking (`diffToPatches`)
20
+ * is the size mitigation, not compression.
21
+ *
22
+ * Byte order is written explicitly via `DataView` rather than relying on
23
+ * `Int16Array`'s platform order. Every realistic platform is little-endian, but
24
+ * the wire format must not depend on that being true of the machine that saved
25
+ * the scene.
26
+ */
27
+ import { MAX_TERRAIN_SIDE } from '../schema/terrain';
28
+ const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
29
+ function encodeBase64(bytes) {
30
+ let out = '';
31
+ for (let i = 0; i < bytes.length; i += 3) {
32
+ const b0 = bytes[i] ?? 0;
33
+ const b1 = bytes[i + 1] ?? 0;
34
+ const b2 = bytes[i + 2] ?? 0;
35
+ const remaining = bytes.length - i;
36
+ out += BASE64_ALPHABET[b0 >> 2];
37
+ out += BASE64_ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)];
38
+ out += remaining > 1 ? BASE64_ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)] : '=';
39
+ out += remaining > 2 ? BASE64_ALPHABET[b2 & 0x3f] : '=';
40
+ }
41
+ return out;
42
+ }
43
+ function decodeBase64(text) {
44
+ // Reverse lookup is built per call rather than at module scope: decode runs
45
+ // once per scene load, and a 64-entry loop is cheaper than the indirection
46
+ // of a module-level Map for a hot path that isn't hot.
47
+ const lookup = new Int8Array(128).fill(-1);
48
+ for (let i = 0; i < BASE64_ALPHABET.length; i++) {
49
+ lookup[BASE64_ALPHABET.charCodeAt(i)] = i;
50
+ }
51
+ let clean = '';
52
+ for (let i = 0; i < text.length; i++) {
53
+ const code = text.charCodeAt(i);
54
+ if (code === 61 /* = */)
55
+ break;
56
+ if (code > 127 || lookup[code] === -1)
57
+ return null;
58
+ clean += text[i];
59
+ }
60
+ if (clean.length % 4 === 1)
61
+ return null;
62
+ const byteLength = Math.floor((clean.length * 3) / 4);
63
+ const bytes = new Uint8Array(byteLength);
64
+ let out = 0;
65
+ for (let i = 0; i < clean.length; i += 4) {
66
+ const c0 = lookup[clean.charCodeAt(i)] ?? 0;
67
+ const c1 = lookup[clean.charCodeAt(i + 1)] ?? 0;
68
+ const c2 = i + 2 < clean.length ? (lookup[clean.charCodeAt(i + 2)] ?? 0) : 0;
69
+ const c3 = i + 3 < clean.length ? (lookup[clean.charCodeAt(i + 3)] ?? 0) : 0;
70
+ if (out < byteLength)
71
+ bytes[out++] = (c0 << 2) | (c1 >> 4);
72
+ if (out < byteLength)
73
+ bytes[out++] = ((c1 & 0x0f) << 4) | (c2 >> 2);
74
+ if (out < byteLength)
75
+ bytes[out++] = ((c2 & 0x03) << 6) | c3;
76
+ }
77
+ return bytes;
78
+ }
79
+ export function encodeTerrainField(field) {
80
+ const bytes = new Uint8Array(field.heights.length * 2);
81
+ const view = new DataView(bytes.buffer);
82
+ for (let i = 0; i < field.heights.length; i++) {
83
+ view.setInt16(i * 2, field.heights[i] ?? 0, true);
84
+ }
85
+ return {
86
+ type: 'heightfield',
87
+ origin: [field.origin[0], field.origin[1]],
88
+ spacing: field.spacing,
89
+ cols: field.cols,
90
+ rows: field.rows,
91
+ step: field.step,
92
+ heights: encodeBase64(bytes),
93
+ };
94
+ }
95
+ /**
96
+ * Rebuild a field from persisted data, or `null` if the data is unusable.
97
+ *
98
+ * Returns `null` rather than throwing, and rather than repairing: a scene whose
99
+ * terrain fails to decode must still load (with flat ground) instead of taking
100
+ * the whole project down, and silently substituting a *different* terrain would
101
+ * be worse than showing none. The caller treats `null` as "this site has no
102
+ * terrain".
103
+ *
104
+ * Metadata and payload length are strict. Repairing a truncated payload or
105
+ * inventing missing metadata would silently substitute a different surface.
106
+ */
107
+ export function decodeTerrainField(data) {
108
+ if (!data || typeof data !== 'object')
109
+ return null;
110
+ const d = data;
111
+ if (d.type !== 'heightfield')
112
+ return null;
113
+ if (!Number.isInteger(d.cols) || !Number.isInteger(d.rows))
114
+ return null;
115
+ const cols = d.cols;
116
+ const rows = d.rows;
117
+ if (cols < 1 || rows < 1)
118
+ return null;
119
+ if (cols > MAX_TERRAIN_SIDE || rows > MAX_TERRAIN_SIDE)
120
+ return null;
121
+ if (typeof d.heights !== 'string')
122
+ return null;
123
+ if (!Number.isFinite(d.spacing) || d.spacing <= 0)
124
+ return null;
125
+ if (!Number.isFinite(d.step) || d.step <= 0)
126
+ return null;
127
+ if (!Array.isArray(d.origin) ||
128
+ d.origin.length !== 2 ||
129
+ !Number.isFinite(d.origin[0]) ||
130
+ !Number.isFinite(d.origin[1])) {
131
+ return null;
132
+ }
133
+ const bytes = decodeBase64(d.heights);
134
+ if (!bytes)
135
+ return null;
136
+ if (encodeBase64(bytes) !== d.heights)
137
+ return null;
138
+ if (bytes.byteLength !== cols * rows * 2)
139
+ return null;
140
+ const heights = new Int16Array(cols * rows);
141
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
142
+ for (let i = 0; i < heights.length; i++) {
143
+ heights[i] = view.getInt16(i * 2, true);
144
+ }
145
+ return {
146
+ origin: [d.origin[0], d.origin[1]],
147
+ spacing: d.spacing,
148
+ cols,
149
+ rows,
150
+ step: d.step,
151
+ heights,
152
+ };
153
+ }
154
+ /**
155
+ * True when every sample is at the datum — the test for "this field is not worth
156
+ * persisting".
157
+ *
158
+ * Called before writing to the node so an untouched site keeps `terrain:
159
+ * undefined` instead of carrying ~11 KB of base64 zeroes in every saved scene.
160
+ */
161
+ export function isDatumField(field) {
162
+ for (let i = 0; i < field.heights.length; i++) {
163
+ if (field.heights[i] !== 0)
164
+ return false;
165
+ }
166
+ return true;
167
+ }