@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.
- package/README.md +17 -10
- package/dist/events/bus.d.ts +21 -2
- package/dist/events/bus.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/floor-placed-elevation.d.ts +17 -1
- package/dist/hooks/spatial-grid/floor-placed-elevation.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/floor-placed-elevation.js +61 -8
- package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +116 -66
- package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/spatial-grid-manager.js +316 -441
- package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts +46 -1
- package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/spatial-grid-sync.js +193 -12
- package/dist/hooks/spatial-grid/support-host-id.d.ts +3 -0
- package/dist/hooks/spatial-grid/support-host-id.d.ts.map +1 -0
- package/dist/hooks/spatial-grid/support-host-id.js +2 -0
- package/dist/hooks/spatial-grid/support-host-patch.d.ts +51 -0
- package/dist/hooks/spatial-grid/support-host-patch.d.ts.map +1 -0
- package/dist/hooks/spatial-grid/support-host-patch.js +164 -0
- package/dist/index.d.ts +23 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -10
- package/dist/lib/measurement-geometry.d.ts +4 -0
- package/dist/lib/measurement-geometry.d.ts.map +1 -1
- package/dist/lib/measurement-geometry.js +27 -10
- package/dist/lib/slab-polygon.d.ts.map +1 -1
- package/dist/lib/slab-polygon.js +33 -1
- package/dist/lib/space-detection.d.ts +15 -4
- package/dist/lib/space-detection.d.ts.map +1 -1
- package/dist/lib/space-detection.js +174 -88
- package/dist/lib/terrain-brush.d.ts +163 -0
- package/dist/lib/terrain-brush.d.ts.map +1 -0
- package/dist/lib/terrain-brush.js +359 -0
- package/dist/lib/terrain-codec.d.ts +51 -0
- package/dist/lib/terrain-codec.d.ts.map +1 -0
- package/dist/lib/terrain-codec.js +167 -0
- package/dist/lib/terrain-field.d.ts +150 -0
- package/dist/lib/terrain-field.d.ts.map +1 -0
- package/dist/lib/terrain-field.js +248 -0
- package/dist/lib/terrain-raycast.d.ts +44 -0
- package/dist/lib/terrain-raycast.d.ts.map +1 -0
- package/dist/lib/terrain-raycast.js +188 -0
- package/dist/lib/terrain-source.d.ts +60 -0
- package/dist/lib/terrain-source.d.ts.map +1 -0
- package/dist/lib/terrain-source.js +90 -0
- package/dist/lib/terrain-support.d.ts +60 -0
- package/dist/lib/terrain-support.d.ts.map +1 -0
- package/dist/lib/terrain-support.js +150 -0
- package/dist/lib/zone-quantities.d.ts +1 -1
- package/dist/lib/zone-quantities.d.ts.map +1 -1
- package/dist/lib/zone-quantities.js +311 -66
- package/dist/material-library.d.ts +9 -1
- package/dist/material-library.d.ts.map +1 -1
- package/dist/material-library.js +56 -4
- package/dist/registry/handles.d.ts +16 -1
- package/dist/registry/handles.d.ts.map +1 -1
- package/dist/registry/index.d.ts +1 -1
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/subtree.d.ts.map +1 -1
- package/dist/registry/subtree.js +5 -2
- package/dist/registry/types.d.ts +64 -1
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/schema/index.d.ts +6 -3
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +6 -3
- package/dist/schema/nodes/cabinet.d.ts +2 -0
- package/dist/schema/nodes/cabinet.d.ts.map +1 -1
- package/dist/schema/nodes/cabinet.js +4 -2
- package/dist/schema/nodes/ceiling.d.ts +1 -1
- package/dist/schema/nodes/ceiling.d.ts.map +1 -1
- package/dist/schema/nodes/ceiling.js +7 -1
- package/dist/schema/nodes/column.d.ts +1 -0
- package/dist/schema/nodes/column.d.ts.map +1 -1
- package/dist/schema/nodes/column.js +2 -0
- package/dist/schema/nodes/construction-dimension.d.ts +195 -0
- package/dist/schema/nodes/construction-dimension.d.ts.map +1 -0
- package/dist/schema/nodes/construction-dimension.js +151 -0
- package/dist/schema/nodes/door.d.ts +29 -0
- package/dist/schema/nodes/door.d.ts.map +1 -1
- package/dist/schema/nodes/door.js +20 -0
- package/dist/schema/nodes/duct-terminal.d.ts +1 -0
- package/dist/schema/nodes/duct-terminal.d.ts.map +1 -1
- package/dist/schema/nodes/duct-terminal.js +2 -0
- package/dist/schema/nodes/fence.d.ts +2 -0
- package/dist/schema/nodes/fence.d.ts.map +1 -1
- package/dist/schema/nodes/fence.js +8 -0
- package/dist/schema/nodes/hvac-equipment.d.ts +1 -0
- package/dist/schema/nodes/hvac-equipment.d.ts.map +1 -1
- package/dist/schema/nodes/hvac-equipment.js +2 -0
- package/dist/schema/nodes/item.d.ts +1 -0
- package/dist/schema/nodes/item.d.ts.map +1 -1
- package/dist/schema/nodes/item.js +13 -0
- package/dist/schema/nodes/level.d.ts +2 -1
- package/dist/schema/nodes/level.d.ts.map +1 -1
- package/dist/schema/nodes/level.js +12 -33
- package/dist/schema/nodes/shelf.d.ts +1 -0
- package/dist/schema/nodes/shelf.d.ts.map +1 -1
- package/dist/schema/nodes/shelf.js +2 -0
- package/dist/schema/nodes/site.d.ts +9 -0
- package/dist/schema/nodes/site.d.ts.map +1 -1
- package/dist/schema/nodes/site.js +8 -6
- package/dist/schema/nodes/slab.d.ts +5 -0
- package/dist/schema/nodes/slab.d.ts.map +1 -1
- package/dist/schema/nodes/slab.js +14 -2
- package/dist/schema/nodes/spawn.d.ts +1 -0
- package/dist/schema/nodes/spawn.d.ts.map +1 -1
- package/dist/schema/nodes/spawn.js +2 -0
- package/dist/schema/nodes/stair.d.ts +3 -1
- package/dist/schema/nodes/stair.d.ts.map +1 -1
- package/dist/schema/nodes/stair.js +8 -1
- package/dist/schema/nodes/structural-grid.d.ts +27 -0
- package/dist/schema/nodes/structural-grid.d.ts.map +1 -0
- package/dist/schema/nodes/structural-grid.js +17 -0
- package/dist/schema/nodes/wall.d.ts +5 -2
- package/dist/schema/nodes/wall.d.ts.map +1 -1
- package/dist/schema/nodes/wall.js +13 -4
- package/dist/schema/nodes/window.d.ts +29 -0
- package/dist/schema/nodes/window.d.ts.map +1 -1
- package/dist/schema/nodes/window.js +18 -0
- package/dist/schema/nodes/zone.d.ts +20 -0
- package/dist/schema/nodes/zone.d.ts.map +1 -1
- package/dist/schema/nodes/zone.js +15 -0
- package/dist/schema/terrain.d.ts +28 -0
- package/dist/schema/terrain.d.ts.map +1 -0
- package/dist/schema/terrain.js +30 -0
- package/dist/schema/types.d.ts +211 -3
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js +4 -0
- package/dist/services/__fixtures__/wall-plane-top-boundary-repro.d.ts +80 -0
- package/dist/services/__fixtures__/wall-plane-top-boundary-repro.d.ts.map +1 -0
- package/dist/services/__fixtures__/wall-plane-top-boundary-repro.js +89 -0
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +2 -1
- package/dist/services/level-height.d.ts +8 -9
- package/dist/services/level-height.d.ts.map +1 -1
- package/dist/services/level-height.js +48 -16
- package/dist/services/storey.d.ts +103 -0
- package/dist/services/storey.d.ts.map +1 -0
- package/dist/services/storey.js +296 -0
- package/dist/store/actions/node-actions.d.ts.map +1 -1
- package/dist/store/actions/node-actions.js +47 -3
- package/dist/store/history-control.d.ts +20 -0
- package/dist/store/history-control.d.ts.map +1 -1
- package/dist/store/history-control.js +110 -13
- package/dist/store/use-live-terrain.d.ts +65 -0
- package/dist/store/use-live-terrain.d.ts.map +1 -0
- package/dist/store/use-live-terrain.js +31 -0
- package/dist/store/use-live-transforms.d.ts +8 -0
- package/dist/store/use-live-transforms.d.ts.map +1 -1
- package/dist/store/use-scene.d.ts +31 -2
- package/dist/store/use-scene.d.ts.map +1 -1
- package/dist/store/use-scene.js +638 -8
- package/dist/systems/elevator/elevator-opening-system.d.ts +1 -0
- package/dist/systems/elevator/elevator-opening-system.d.ts.map +1 -1
- package/dist/systems/elevator/elevator-opening-system.js +27 -21
- package/dist/systems/elevator/elevator-service.d.ts +0 -2
- package/dist/systems/elevator/elevator-service.d.ts.map +1 -1
- package/dist/systems/elevator/elevator-service.js +2 -24
- package/dist/systems/slab/slab-placement.d.ts +11 -0
- package/dist/systems/slab/slab-placement.d.ts.map +1 -0
- package/dist/systems/slab/slab-placement.js +14 -0
- package/dist/systems/slab/slab-support.d.ts +137 -0
- package/dist/systems/slab/slab-support.d.ts.map +1 -0
- package/dist/systems/slab/slab-support.js +557 -0
- package/dist/systems/stair/stair-opening-preview.d.ts +1060 -935
- package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -1
- package/dist/systems/stair/stair-opening-sync.d.ts.map +1 -1
- package/dist/systems/stair/stair-opening-sync.js +18 -15
- package/dist/systems/stair/stair-opening-system.d.ts.map +1 -1
- package/dist/systems/stair/stair-opening-system.js +32 -4
- package/dist/systems/stair/stair-rise.d.ts +22 -0
- package/dist/systems/stair/stair-rise.d.ts.map +1 -0
- package/dist/systems/stair/stair-rise.js +79 -0
- package/dist/systems/wall/wall-curve.d.ts +10 -0
- package/dist/systems/wall/wall-curve.d.ts.map +1 -1
- package/dist/systems/wall/wall-curve.js +1 -1
- package/dist/systems/wall/wall-mitering.js +6 -6
- package/dist/systems/wall/wall-top.d.ts +40 -0
- package/dist/systems/wall/wall-top.d.ts.map +1 -0
- package/dist/systems/wall/wall-top.js +46 -0
- package/dist/utils/clone-scene-graph.d.ts.map +1 -1
- package/dist/utils/clone-scene-graph.js +34 -3
- package/dist/utils/heal-scene-graph.d.ts +1 -1
- package/dist/utils/heal-scene-graph.d.ts.map +1 -1
- package/dist/utils/heal-scene-graph.js +25 -9
- package/dist/validation/validate-build-json.d.ts +2 -0
- package/dist/validation/validate-build-json.d.ts.map +1 -1
- package/dist/validation/validate-build-json.js +59 -3
- package/package.json +2 -2
- package/dist/hooks/scene-registry/scene-registry.test.d.ts +0 -2
- package/dist/hooks/scene-registry/scene-registry.test.d.ts.map +0 -1
- package/dist/hooks/scene-registry/scene-registry.test.js +0 -26
- package/dist/hooks/spatial-grid/floor-placed-elevation.test.d.ts +0 -2
- package/dist/hooks/spatial-grid/floor-placed-elevation.test.d.ts.map +0 -1
- package/dist/hooks/spatial-grid/floor-placed-elevation.test.js +0 -366
- package/dist/hooks/spatial-grid/wall-slab-overlap.test.d.ts +0 -2
- package/dist/hooks/spatial-grid/wall-slab-overlap.test.d.ts.map +0 -1
- package/dist/hooks/spatial-grid/wall-slab-overlap.test.js +0 -363
- package/dist/lib/measurement-geometry.test.d.ts +0 -2
- package/dist/lib/measurement-geometry.test.d.ts.map +0 -1
- package/dist/lib/measurement-geometry.test.js +0 -102
- package/dist/lib/slab-polygon.test.d.ts +0 -2
- package/dist/lib/slab-polygon.test.d.ts.map +0 -1
- package/dist/lib/slab-polygon.test.js +0 -635
- package/dist/lib/space-detection-pause.test.d.ts +0 -2
- package/dist/lib/space-detection-pause.test.d.ts.map +0 -1
- package/dist/lib/space-detection-pause.test.js +0 -42
- package/dist/lib/space-detection.test.d.ts +0 -2
- package/dist/lib/space-detection.test.d.ts.map +0 -1
- package/dist/lib/space-detection.test.js +0 -376
- package/dist/lib/zone-quantities.test.d.ts +0 -2
- package/dist/lib/zone-quantities.test.d.ts.map +0 -1
- package/dist/lib/zone-quantities.test.js +0 -82
- package/dist/registry/registry.test.d.ts +0 -2
- package/dist/registry/registry.test.d.ts.map +0 -1
- package/dist/registry/registry.test.js +0 -196
- package/dist/registry/relations-resolver.test.d.ts +0 -2
- package/dist/registry/relations-resolver.test.d.ts.map +0 -1
- package/dist/registry/relations-resolver.test.js +0 -183
- package/dist/registry/scene-api.test.d.ts +0 -2
- package/dist/registry/scene-api.test.d.ts.map +0 -1
- package/dist/registry/scene-api.test.js +0 -183
- package/dist/registry/subtree.test.d.ts +0 -2
- package/dist/registry/subtree.test.d.ts.map +0 -1
- package/dist/registry/subtree.test.js +0 -163
- package/dist/schema/asset-url.test.d.ts +0 -2
- package/dist/schema/asset-url.test.d.ts.map +0 -1
- package/dist/schema/asset-url.test.js +0 -134
- package/dist/schema/material.test.d.ts +0 -2
- package/dist/schema/material.test.d.ts.map +0 -1
- package/dist/schema/material.test.js +0 -43
- package/dist/schema/nodes/measurement.test.d.ts +0 -2
- package/dist/schema/nodes/measurement.test.d.ts.map +0 -1
- package/dist/schema/nodes/measurement.test.js +0 -139
- package/dist/schema/nodes/ridge-vent-defaults.test.d.ts +0 -2
- package/dist/schema/nodes/ridge-vent-defaults.test.d.ts.map +0 -1
- package/dist/schema/nodes/ridge-vent-defaults.test.js +0 -205
- package/dist/schema/nodes/roof-segment-shape.test.d.ts +0 -2
- package/dist/schema/nodes/roof-segment-shape.test.d.ts.map +0 -1
- package/dist/schema/nodes/roof-segment-shape.test.js +0 -175
- package/dist/schema/nodes/roof-segment-surface.test.d.ts +0 -2
- package/dist/schema/nodes/roof-segment-surface.test.d.ts.map +0 -1
- package/dist/schema/nodes/roof-segment-surface.test.js +0 -48
- package/dist/schema/nodes/roof-segment-trim.test.d.ts +0 -2
- package/dist/schema/nodes/roof-segment-trim.test.d.ts.map +0 -1
- package/dist/schema/nodes/roof-segment-trim.test.js +0 -107
- package/dist/schema/nodes/roof-segment-walls.test.d.ts +0 -2
- package/dist/schema/nodes/roof-segment-walls.test.d.ts.map +0 -1
- package/dist/schema/nodes/roof-segment-walls.test.js +0 -92
- package/dist/schema/nodes/wall.test.d.ts +0 -2
- package/dist/schema/nodes/wall.test.d.ts.map +0 -1
- package/dist/schema/nodes/wall.test.js +0 -208
- package/dist/services/alignment-anchors.test.d.ts +0 -2
- package/dist/services/alignment-anchors.test.d.ts.map +0 -1
- package/dist/services/alignment-anchors.test.js +0 -383
- package/dist/services/alignment.test.d.ts +0 -2
- package/dist/services/alignment.test.d.ts.map +0 -1
- package/dist/services/alignment.test.js +0 -92
- package/dist/services/drag-session.test.d.ts +0 -2
- package/dist/services/drag-session.test.d.ts.map +0 -1
- package/dist/services/drag-session.test.js +0 -219
- package/dist/services/hosting.test.d.ts +0 -2
- package/dist/services/hosting.test.d.ts.map +0 -1
- package/dist/services/hosting.test.js +0 -230
- package/dist/services/movement.test.d.ts +0 -2
- package/dist/services/movement.test.d.ts.map +0 -1
- package/dist/services/movement.test.js +0 -133
- package/dist/services/opening-guides.test.d.ts +0 -2
- package/dist/services/opening-guides.test.d.ts.map +0 -1
- package/dist/services/opening-guides.test.js +0 -208
- package/dist/services/port-connectivity.test.d.ts +0 -2
- package/dist/services/port-connectivity.test.d.ts.map +0 -1
- package/dist/services/port-connectivity.test.js +0 -297
- package/dist/services/riser-diagram.test.d.ts +0 -2
- package/dist/services/riser-diagram.test.d.ts.map +0 -1
- package/dist/services/riser-diagram.test.js +0 -66
- package/dist/services/single-undo-dance.test.d.ts +0 -2
- package/dist/services/single-undo-dance.test.d.ts.map +0 -1
- package/dist/services/single-undo-dance.test.js +0 -210
- package/dist/services/snap.test.d.ts +0 -2
- package/dist/services/snap.test.d.ts.map +0 -1
- package/dist/services/snap.test.js +0 -137
- package/dist/services/system-graph.test.d.ts +0 -2
- package/dist/services/system-graph.test.d.ts.map +0 -1
- package/dist/services/system-graph.test.js +0 -138
- package/dist/services/validate-dwv.test.d.ts +0 -2
- package/dist/services/validate-dwv.test.d.ts.map +0 -1
- package/dist/services/validate-dwv.test.js +0 -109
- package/dist/store/actions/node-mutation-sanitize.test.d.ts +0 -2
- package/dist/store/actions/node-mutation-sanitize.test.d.ts.map +0 -1
- package/dist/store/actions/node-mutation-sanitize.test.js +0 -147
- package/dist/store/actions/reparent.test.d.ts +0 -2
- package/dist/store/actions/reparent.test.d.ts.map +0 -1
- package/dist/store/actions/reparent.test.js +0 -212
- package/dist/store/actions/ridge-vent-update.test.d.ts +0 -2
- package/dist/store/actions/ridge-vent-update.test.d.ts.map +0 -1
- package/dist/store/actions/ridge-vent-update.test.js +0 -264
- package/dist/store/use-scene-dirty-tracking.test.d.ts +0 -2
- package/dist/store/use-scene-dirty-tracking.test.d.ts.map +0 -1
- package/dist/store/use-scene-dirty-tracking.test.js +0 -68
- package/dist/store/use-scene-elevator-migration.test.d.ts +0 -2
- package/dist/store/use-scene-elevator-migration.test.d.ts.map +0 -1
- package/dist/store/use-scene-elevator-migration.test.js +0 -109
- package/dist/store/use-scene-measurement-roundtrip.test.d.ts +0 -2
- package/dist/store/use-scene-measurement-roundtrip.test.d.ts.map +0 -1
- package/dist/store/use-scene-measurement-roundtrip.test.js +0 -97
- package/dist/store/use-scene-plugins.test.d.ts +0 -2
- package/dist/store/use-scene-plugins.test.d.ts.map +0 -1
- package/dist/store/use-scene-plugins.test.js +0 -51
- package/dist/store/use-scene-wall-slot-migration.test.d.ts +0 -2
- package/dist/store/use-scene-wall-slot-migration.test.d.ts.map +0 -1
- package/dist/store/use-scene-wall-slot-migration.test.js +0 -210
- package/dist/store/use-scene-window-migration.test.d.ts +0 -2
- package/dist/store/use-scene-window-migration.test.d.ts.map +0 -1
- package/dist/store/use-scene-window-migration.test.js +0 -85
- package/dist/systems/elevator/elevator-opening-sync.test.d.ts +0 -2
- package/dist/systems/elevator/elevator-opening-sync.test.d.ts.map +0 -1
- package/dist/systems/elevator/elevator-opening-sync.test.js +0 -146
- package/dist/systems/elevator/elevator-runtime.test.d.ts +0 -2
- package/dist/systems/elevator/elevator-runtime.test.d.ts.map +0 -1
- package/dist/systems/elevator/elevator-runtime.test.js +0 -64
- package/dist/systems/fence/fence-spline.test.d.ts +0 -2
- package/dist/systems/fence/fence-spline.test.d.ts.map +0 -1
- package/dist/systems/fence/fence-spline.test.js +0 -136
- package/dist/systems/stair/stair-opening-preview.test.d.ts +0 -2
- package/dist/systems/stair/stair-opening-preview.test.d.ts.map +0 -1
- package/dist/systems/stair/stair-opening-preview.test.js +0 -80
- package/dist/systems/stair/stair-opening-sync.test.d.ts +0 -2
- package/dist/systems/stair/stair-opening-sync.test.d.ts.map +0 -1
- package/dist/systems/stair/stair-opening-sync.test.js +0 -485
- package/dist/systems/wall/wall-mitering.test.d.ts +0 -2
- package/dist/systems/wall/wall-mitering.test.d.ts.map +0 -1
- package/dist/systems/wall/wall-mitering.test.js +0 -63
- package/dist/utils/clone-scene-graph.test.d.ts +0 -2
- package/dist/utils/clone-scene-graph.test.d.ts.map +0 -1
- package/dist/utils/clone-scene-graph.test.js +0 -62
- package/dist/utils/heal-scene-graph.test.d.ts +0 -2
- package/dist/utils/heal-scene-graph.test.d.ts.map +0 -1
- 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
|
+
}
|