@bldrs-ai/conway 1.502.1421 → 1.507.1429

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.
@@ -30,7 +30,7 @@ var import_node_process = require("node:process");
30
30
  var readline = __toESM(require("node:readline"), 1);
31
31
 
32
32
  // compiled/src/version/version.js
33
- var versionString = "Conway v1.502.1421";
33
+ var versionString = "Conway v1.507.1429";
34
34
 
35
35
  // compiled/dependencies/conway-geom/interface/conway_geometry.js
36
36
  var wasmType = "";
@@ -14965,7 +14965,7 @@ ${t5.join("\n")}` : "";
14965
14965
  var import_process = require("process");
14966
14966
 
14967
14967
  // compiled/src/version/version.js
14968
- var versionString = "Conway v1.502.1421";
14968
+ var versionString = "Conway v1.507.1429";
14969
14969
 
14970
14970
  // compiled/dependencies/conway-geom/interface/conway_geometry.js
14971
14971
  function pThreadsAllowed() {
@@ -15943,7 +15943,7 @@ var ParsingBuffer = class {
15943
15943
  };
15944
15944
 
15945
15945
  // compiled/src/version/version.js
15946
- var versionString = "Conway v1.502.1421";
15946
+ var versionString = "Conway v1.507.1429";
15947
15947
 
15948
15948
  // compiled/dependencies/conway-geom/interface/conway_geometry.js
15949
15949
  function pThreadsAllowed() {
@@ -944,7 +944,7 @@ var EntityTypesIfcCount = 909;
944
944
  var entity_types_ifc_gen_default = EntityTypesIfc;
945
945
 
946
946
  // compiled/src/version/version.js
947
- var versionString = "Conway v1.502.1421";
947
+ var versionString = "Conway v1.507.1429";
948
948
 
949
949
  // compiled/dependencies/conway-geom/interface/conway_geometry.js
950
950
  var wasmType = "";
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=coordination_export_order.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"coordination_export_order.test.d.ts","sourceRoot":"","sources":["../../../../src/compat/web-ifc/coordination_export_order.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,110 @@
1
+ /* eslint-disable no-magic-numbers */
2
+ // Export-order independence of the COORDINATE_TO_ORIGIN frame (Share#1749).
3
+ //
4
+ // The recentre anchors on the first geometry the walk reaches, which is
5
+ // whichever element the file happens to declare first. Unsnapped, that
6
+ // makes a model's rendered position a function of its element order:
7
+ // Share shipped the Bldrs logo as both `index.ifc` and `index.step`, and
8
+ // the STEP rendered 76m off the IFC because the two files disagree about
9
+ // which block comes first. Every camera permalink spanning the pair was
10
+ // wrong, and auto-framing hid it — the models look identical until a
11
+ // `#c:` camera pins them.
12
+ //
13
+ // This drives the same surface Share does (the compat IfcAPI, not the
14
+ // CLI's native writer, which has its own recentre convention) and pins
15
+ // the properties the snap has to hold together: a near-origin model
16
+ // keeps the coordinates its file authored — which is what makes two
17
+ // exports of one object coincide — a georeferenced model still
18
+ // recentres, and the classic and streamed opens agree.
19
+ //
20
+ // Not covered here: the AP214 arm. Its placements come back at the
21
+ // origin with the geometry carrying the world transform, so neither
22
+ // placement bounds nor a fixture pair in two element orders reproduces
23
+ // the defect through this surface; the cross-format claim is pinned
24
+ // Share-side instead (`src/Containers/indexStepLogo.spec.ts`, which
25
+ // compares the rendered bounds of `index.ifc` and `index.step`).
26
+ import * as fs from "fs";
27
+ import { beforeAll, describe, expect, test } from "@jest/globals";
28
+ import { LARGE_COORDINATE_BUDGET_M, COORDINATION_SNAP_M } from "./coordination_f64.js";
29
+ import { IfcAPI } from "./ifc_api.js";
30
+ const SETTINGS = { COORDINATE_TO_ORIGIN: true, USE_FAST_BOOLS: true };
31
+ let api;
32
+ let local;
33
+ let georeferenced;
34
+ /**
35
+ * World-space bounds of every placed geometry in a model, as the
36
+ * consumer sees them: the placement translation is what Share stamps
37
+ * onto its instances.
38
+ *
39
+ * @param modelID An open model.
40
+ * @return {{min: number[], max: number[]}} Translation bounds.
41
+ */
42
+ function placementBounds(modelID) {
43
+ const min = [Infinity, Infinity, Infinity];
44
+ const max = [-Infinity, -Infinity, -Infinity];
45
+ api.StreamAllMeshes(modelID, (mesh) => {
46
+ for (let where = 0; where < mesh.geometries.size(); ++where) {
47
+ const t = mesh.geometries.get(where).flatTransformation;
48
+ for (let axis = 0; axis < 3; ++axis) {
49
+ min[axis] = Math.min(min[axis], t[12 + axis]);
50
+ max[axis] = Math.max(max[axis], t[12 + axis]);
51
+ }
52
+ }
53
+ });
54
+ return { min, max };
55
+ }
56
+ beforeAll(async () => {
57
+ api = new IfcAPI();
58
+ await api.Init();
59
+ local = new Uint8Array(fs.readFileSync("data/index.ifc"));
60
+ georeferenced = new Uint8Array(fs.readFileSync("data/index_georeferenced.ifc"));
61
+ }, 120000);
62
+ describe("COORDINATE_TO_ORIGIN export-order independence (Share#1749)", () => {
63
+ test("a near-origin model keeps its authored coordinates", async () => {
64
+ const modelID = await api.OpenModel(local, { ...SETTINGS });
65
+ const { min, max } = placementBounds(modelID);
66
+ expect(Number.isFinite(min[0])).toBe(true);
67
+ // The logo's first-declared block sits at x=76, so the unsnapped
68
+ // frame used to drag the whole model 76m negative. Snapped, the
69
+ // anchor lands in the origin cell and the model stays where the file
70
+ // put it — which is what makes a second export of the same object
71
+ // coincide with it regardless of element order.
72
+ expect(Math.max(...min.map(Math.abs), ...max.map(Math.abs)))
73
+ .toBeLessThan(COORDINATION_SNAP_M);
74
+ expect(min[0]).toBeGreaterThanOrEqual(0);
75
+ api.CloseModel(modelID);
76
+ }, 120000);
77
+ test("a georeferenced model still recentres, inside the budget", async () => {
78
+ const modelID = await api.OpenModel(georeferenced, { ...SETTINGS });
79
+ const { min, max } = placementBounds(modelID);
80
+ // Snapping costs at most half a cell on top of the model's own
81
+ // extent; the budget is the threshold above which float32 jitter
82
+ // becomes visible (Share#1631).
83
+ expect(Math.max(...min.map(Math.abs), ...max.map(Math.abs)))
84
+ .toBeLessThan(LARGE_COORDINATE_BUDGET_M);
85
+ // The applied-frame report (Share#1634) is not asserted here: only
86
+ // the deferred pump records it, and
87
+ // `ifc_api_preview_coordination.test.ts` already covers that path.
88
+ api.CloseModel(modelID);
89
+ }, 120000);
90
+ test("the classic and streamed opens agree on the frame", async () => {
91
+ // Both paths derive through deriveCoordinationF64, so the snap has
92
+ // to leave them identical — otherwise one model renders in two
93
+ // places depending on which open Share happened to take. Compared
94
+ // through the emitted placements rather than
95
+ // GetAppliedCoordinationMatrix, which only the deferred pump fills
96
+ // in: on these two opens it reports the identity contract for both,
97
+ // so comparing it would pass no matter what the frames did.
98
+ const classicID = await api.OpenModel(georeferenced, { ...SETTINGS });
99
+ const classic = placementBounds(classicID);
100
+ api.CloseModel(classicID);
101
+ const streamedID = await api.OpenModelStreamed(georeferenced, { ...SETTINGS });
102
+ const streamed = placementBounds(streamedID);
103
+ api.CloseModel(streamedID);
104
+ expect(Number.isFinite(classic.min[0])).toBe(true);
105
+ for (let axis = 0; axis < 3; ++axis) {
106
+ expect(streamed.min[axis]).toBeCloseTo(classic.min[axis], 6);
107
+ expect(streamed.max[axis]).toBeCloseTo(classic.max[axis], 6);
108
+ }
109
+ }, 120000);
110
+ });
@@ -45,10 +45,69 @@ export interface Point3Like {
45
45
  * @return {number[]} The product as a fresh 16-element `number[]`.
46
46
  */
47
47
  export declare function mat4MultiplyF64(a: ArrayLike<number>, b: ArrayLike<number>): number[];
48
+ /**
49
+ * Grid (metres) a recentre snaps to once it is needed at all.
50
+ *
51
+ * The anchor a recentre is derived from is the *first geometry* the walk
52
+ * reaches, which is an arbitrary interior element: whichever one the file
53
+ * happens to declare first. Left as-is, that makes a model's world
54
+ * position a function of its element order, with two consequences users
55
+ * see (conway#87, "not a sound basis for a camera in the permalink"):
56
+ *
57
+ * - Two exports of one object — the same logo as IFC and as STEP, a part
58
+ * re-exported by a different CAD kernel — land in different places,
59
+ * because they disagree about which element comes first. Share#1749
60
+ * hit exactly this: `index.step` sat 76m off `index.ifc` because the
61
+ * IFC declares the x=76 block first and the STEP the x=0 one.
62
+ * - Re-exporting a model with the element order shuffled moves it, so
63
+ * every camera permalink saved against it is silently wrong.
64
+ *
65
+ * `quantizeRecentre` answers that in two stages, because the two ranges
66
+ * want different things:
67
+ *
68
+ * - **Inside LARGE_COORDINATE_BUDGET_M, do not recentre at all.** There
69
+ * is no float32 benefit below the budget — that constant *is* the
70
+ * threshold where quantization becomes visible — so a model within
71
+ * 10km of the origin simply keeps the coordinates its file authored.
72
+ * That is model-zero, conway#87's proposal, and it makes the frame
73
+ * exactly order-independent for the overwhelming majority of models
74
+ * rather than order-independent-per-cell.
75
+ * - **Above it, snap to this grid.** A georeferenced model has to come
76
+ * back near the origin, and snapping keeps that repeatable across
77
+ * exports. Half a cell (500m) out of a 1e4 m budget costs ~0.05mm of
78
+ * float32 resolution, and a whole-kilometre translation is exactly
79
+ * representable where the raw anchor was not, so the recentre itself
80
+ * stops contributing rounding.
81
+ *
82
+ * **Quantizing cannot remove the discontinuity, only move it**, and it
83
+ * is worth being precise about where this one sits, because it is not
84
+ * where you would guess. Snapping *everything* to the grid would put a
85
+ * 1km step at every cell edge, including around the near-origin models
86
+ * that make up almost the whole corpus. Staging moves it to the budget:
87
+ * inside, every anchor derives model-zero and there is no edge at all;
88
+ * the single remaining edge is the budget itself, where the step is
89
+ * `LARGE_COORDINATE_BUDGET_M` (1e4), not the grid (1e3) — an anchor at
90
+ * 9900 derives zero and one at 10100 derives -10000.
91
+ *
92
+ * That edge is also invisible to the adopted-preview-frame gate, which
93
+ * re-derives only when a durable placement lands beyond the budget: a
94
+ * preview anchored at 10100 and a durable first placement at 9900 probe
95
+ * at ~100m, so the preview frame is kept and the model renders 10km off
96
+ * a classic open. The trade is deliberate — one edge that a georeferenced
97
+ * model may sit near, rather than an edge every kilometre through the
98
+ * range where nearly every model lives — but it is a real residual, not
99
+ * an eliminated one. design/new/coordination-frame.md tracks it, and
100
+ * conway#87's relative-to-centre direction is what removes it properly.
101
+ */
102
+ export declare const COORDINATION_SNAP_M = 1000;
48
103
  /**
49
104
  * Derive the coordination (recentre) matrix in float64, matching the
50
105
  * gl-matrix op sequence exactly:
51
- * `scale * NormalizeMat * translate(-(placement * point))`.
106
+ * `scale * NormalizeMat * translate(-quantize(placement * point))`.
107
+ *
108
+ * The quantization is what keeps the derived frame independent of which
109
+ * element a file declares first — see COORDINATION_SNAP_M for why that
110
+ * matters, and why it is staged around LARGE_COORDINATE_BUDGET_M.
52
111
  *
53
112
  * @param placement The native placement (16 elements, column-major
54
113
  * float64 from `getValues()`); `undefined` is treated as identity.
@@ -1 +1 @@
1
- {"version":3,"file":"coordination_f64.d.ts","sourceRoot":"","sources":["../../../../src/compat/web-ifc/coordination_f64.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAiDH;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;CACV;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAC3B,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,GAAG,MAAM,EAAE,CAkBxD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CACjC,SAAS,EAAE,SAAS,CAAC,MAAM,CAAC,GAAG,SAAS,EACxC,KAAK,EAAE,UAAU,EACjB,YAAY,EAAE,SAAS,CAAC,MAAM,CAAC,EAC/B,WAAW,EAAE,MAAM,GAAG,MAAM,EAAE,CA0BjC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAC/B,YAAY,EAAE,SAAS,CAAC,MAAM,CAAC,EAC/B,SAAS,EAAE,SAAS,CAAC,MAAM,CAAC,GAAG,SAAS,EACxC,UAAU,CAAC,EAAE,UAAU,GAAG,MAAM,EAAE,CAerC;AAGD,eAAO,MAAM,aAAa,KAAK,CAAA;AAC/B,eAAO,MAAM,aAAa,KAAK,CAAA;AAC/B,eAAO,MAAM,aAAa,KAAK,CAAA;AAE/B;;;;;;;GAOG;AACH,eAAO,MAAM,yBAAyB,QAAM,CAAA"}
1
+ {"version":3,"file":"coordination_f64.d.ts","sourceRoot":"","sources":["../../../../src/compat/web-ifc/coordination_f64.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAiDH;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;CACV;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAC3B,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,GAAG,MAAM,EAAE,CAkBxD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,eAAO,MAAM,mBAAmB,OAAM,CAAA;AAoCtC;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CACjC,SAAS,EAAE,SAAS,CAAC,MAAM,CAAC,GAAG,SAAS,EACxC,KAAK,EAAE,UAAU,EACjB,YAAY,EAAE,SAAS,CAAC,MAAM,CAAC,EAC/B,WAAW,EAAE,MAAM,GAAG,MAAM,EAAE,CAgCjC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAC/B,YAAY,EAAE,SAAS,CAAC,MAAM,CAAC,EAC/B,SAAS,EAAE,SAAS,CAAC,MAAM,CAAC,GAAG,SAAS,EACxC,UAAU,CAAC,EAAE,UAAU,GAAG,MAAM,EAAE,CAerC;AAGD,eAAO,MAAM,aAAa,KAAK,CAAA;AAC/B,eAAO,MAAM,aAAa,KAAK,CAAA;AAC/B,eAAO,MAAM,aAAa,KAAK,CAAA;AAE/B;;;;;;;GAOG;AACH,eAAO,MAAM,yBAAyB,QAAM,CAAA"}
@@ -92,10 +92,99 @@ export function mat4MultiplyF64(a, b) {
92
92
  }
93
93
  return out;
94
94
  }
95
+ /**
96
+ * Grid (metres) a recentre snaps to once it is needed at all.
97
+ *
98
+ * The anchor a recentre is derived from is the *first geometry* the walk
99
+ * reaches, which is an arbitrary interior element: whichever one the file
100
+ * happens to declare first. Left as-is, that makes a model's world
101
+ * position a function of its element order, with two consequences users
102
+ * see (conway#87, "not a sound basis for a camera in the permalink"):
103
+ *
104
+ * - Two exports of one object — the same logo as IFC and as STEP, a part
105
+ * re-exported by a different CAD kernel — land in different places,
106
+ * because they disagree about which element comes first. Share#1749
107
+ * hit exactly this: `index.step` sat 76m off `index.ifc` because the
108
+ * IFC declares the x=76 block first and the STEP the x=0 one.
109
+ * - Re-exporting a model with the element order shuffled moves it, so
110
+ * every camera permalink saved against it is silently wrong.
111
+ *
112
+ * `quantizeRecentre` answers that in two stages, because the two ranges
113
+ * want different things:
114
+ *
115
+ * - **Inside LARGE_COORDINATE_BUDGET_M, do not recentre at all.** There
116
+ * is no float32 benefit below the budget — that constant *is* the
117
+ * threshold where quantization becomes visible — so a model within
118
+ * 10km of the origin simply keeps the coordinates its file authored.
119
+ * That is model-zero, conway#87's proposal, and it makes the frame
120
+ * exactly order-independent for the overwhelming majority of models
121
+ * rather than order-independent-per-cell.
122
+ * - **Above it, snap to this grid.** A georeferenced model has to come
123
+ * back near the origin, and snapping keeps that repeatable across
124
+ * exports. Half a cell (500m) out of a 1e4 m budget costs ~0.05mm of
125
+ * float32 resolution, and a whole-kilometre translation is exactly
126
+ * representable where the raw anchor was not, so the recentre itself
127
+ * stops contributing rounding.
128
+ *
129
+ * **Quantizing cannot remove the discontinuity, only move it**, and it
130
+ * is worth being precise about where this one sits, because it is not
131
+ * where you would guess. Snapping *everything* to the grid would put a
132
+ * 1km step at every cell edge, including around the near-origin models
133
+ * that make up almost the whole corpus. Staging moves it to the budget:
134
+ * inside, every anchor derives model-zero and there is no edge at all;
135
+ * the single remaining edge is the budget itself, where the step is
136
+ * `LARGE_COORDINATE_BUDGET_M` (1e4), not the grid (1e3) — an anchor at
137
+ * 9900 derives zero and one at 10100 derives -10000.
138
+ *
139
+ * That edge is also invisible to the adopted-preview-frame gate, which
140
+ * re-derives only when a durable placement lands beyond the budget: a
141
+ * preview anchored at 10100 and a durable first placement at 9900 probe
142
+ * at ~100m, so the preview frame is kept and the model renders 10km off
143
+ * a classic open. The trade is deliberate — one edge that a georeferenced
144
+ * model may sit near, rather than an edge every kilometre through the
145
+ * range where nearly every model lives — but it is a real residual, not
146
+ * an eliminated one. design/new/coordination-frame.md tracks it, and
147
+ * conway#87's relative-to-centre direction is what removes it properly.
148
+ */
149
+ export const COORDINATION_SNAP_M = 1e3;
150
+ /**
151
+ * The recentre for an anchor: all zeros while the anchor is close enough
152
+ * to the origin not to need one, else each component snapped to
153
+ * COORDINATION_SNAP_M.
154
+ *
155
+ * The stage decision is made **once, on the anchor's distance from the
156
+ * origin**, and applied to all three components together. Deciding per
157
+ * component would leave a model offset diagonally — say (9km, 9km, 9km),
158
+ * 15.6km out and well past the budget — with no recentre at all, since
159
+ * no single component crosses the threshold. That silently regresses the
160
+ * float32 jitter fix (Share#1631) the budget exists to enforce.
161
+ *
162
+ * Works in source units: the values are pre-scale, so both the budget
163
+ * and the grid convert by the same `scaleFactor` the caller is about to
164
+ * apply (a millimetre model snaps every 1e6 source units, a metre model
165
+ * every 1e3).
166
+ *
167
+ * @param values The three translation components, in source units.
168
+ * @param scaleFactor The linear scaling factor to metres; already
169
+ * sanitized by the caller.
170
+ * @return {number[]} The recentre per component, in source units.
171
+ */
172
+ function quantizeRecentre(values, scaleFactor) {
173
+ const finite = values.map((v) => (Number.isFinite(v) ? v : 0));
174
+ if (Math.hypot(...finite) * scaleFactor <= LARGE_COORDINATE_BUDGET_M) {
175
+ return [0, 0, 0];
176
+ }
177
+ const gridSourceUnits = COORDINATION_SNAP_M / scaleFactor;
178
+ return finite.map((v) => Math.round(v / gridSourceUnits) * gridSourceUnits);
179
+ }
95
180
  /**
96
181
  * Derive the coordination (recentre) matrix in float64, matching the
97
182
  * gl-matrix op sequence exactly:
98
- * `scale * NormalizeMat * translate(-(placement * point))`.
183
+ * `scale * NormalizeMat * translate(-quantize(placement * point))`.
184
+ *
185
+ * The quantization is what keeps the derived frame independent of which
186
+ * element a file declares first — see COORDINATION_SNAP_M for why that
187
+ * matters, and why it is staged around LARGE_COORDINATE_BUDGET_M.
99
188
  *
100
189
  * @param placement The native placement (16 elements, column-major
101
190
  * float64 from `getValues()`); `undefined` is treated as identity.
@@ -107,20 +196,26 @@ export function mat4MultiplyF64(a, b) {
107
196
  export function deriveCoordinationF64(placement, point, normalizeMat, scaleFactor) {
108
197
  const p = placement !== undefined ? sanitize16(placement) : IDENTITY;
109
198
  const { x, y, z } = point;
199
+ // Sanitized once, here, so the promise the rest of this function makes
200
+ // about degenerate input actually holds: a NaN/Infinite factor from a
201
+ // malformed unit-assignment chain would otherwise pass the recentre
202
+ // guard and then poison every component through the scale matrix below.
203
+ const scale1 = Number.isFinite(scaleFactor) && scaleFactor > 0 ? scaleFactor : 1;
110
204
  // transformedPt = placement * (x, y, z, 1)
111
205
  const tx = p[0] * x + p[4] * y + p[8] * z + p[12];
112
206
  const ty = p[1] * x + p[5] * y + p[9] * z + p[13];
113
207
  const tz = p[2] * x + p[6] * y + p[10] * z + p[14];
114
- // translate(-transformedPt)
208
+ // translate(-quantize(transformedPt))
209
+ const recentre = quantizeRecentre([-tx, -ty, -tz], scale1);
115
210
  const translate = IDENTITY.slice();
116
- translate[12] = -tx;
117
- translate[13] = -ty;
118
- translate[14] = -tz;
211
+ translate[12] = recentre[0];
212
+ translate[13] = recentre[1];
213
+ translate[14] = recentre[2];
119
214
  // scale(scaleFactor)
120
215
  const scale = [
121
- scaleFactor, 0, 0, 0,
122
- 0, scaleFactor, 0, 0,
123
- 0, 0, scaleFactor, 0,
216
+ scale1, 0, 0, 0,
217
+ 0, scale1, 0, 0,
218
+ 0, 0, scale1, 0,
124
219
  0, 0, 0, 1,
125
220
  ];
126
221
  // scale * (NormalizeMat * translate)
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable no-magic-numbers */
2
2
  import { describe, expect, test } from "@jest/globals";
3
3
  import * as glmatrix from "gl-matrix";
4
- import { composeTransformF64, deriveCoordinationF64, mat4MultiplyF64, } from "./coordination_f64.js";
4
+ import { COORDINATION_SNAP_M, LARGE_COORDINATE_BUDGET_M, TRANSLATION_X, TRANSLATION_Y, TRANSLATION_Z, composeTransformF64, deriveCoordinationF64, mat4MultiplyF64, } from "./coordination_f64.js";
5
5
  // The Z-up -> Y-up normalize matrix the proxies pass in (column-major).
6
6
  const NORMALIZE_MAT = [
7
7
  1,
@@ -51,36 +51,121 @@ describe("coordination_f64", () => {
51
51
  }
52
52
  }
53
53
  });
54
- test("recentres a LV95-magnitude reference exactly where float32 mis-lands it", () => {
55
- // The georeferencing jitter mechanism: the recentre translation
56
- // negates a reference point at Swiss LV95 magnitude (~2.6M easting).
57
- // Stored in a Float32Array it quantizes to the ~0.31 m grid (float32
58
- // ULP at 2.6M), so the reference lands cm-to-dm off the origin — a
59
- // per-model positional error baked into every emitted transform. The
60
- // float64 recentre lands it exactly.
61
- const identity = glmatrix.mat4.create(); // placement; world via the point
54
+ test("below the budget every anchor derives model-zero (export-order independence)", () => {
55
+ // The Share#1749 shape: two exports of one object disagree about
56
+ // which element comes first, so the walk anchors on points 76m
57
+ // apart. Both must derive the same frame, or the two files render
58
+ // 76m apart and no camera permalink spans them.
59
+ const identity = glmatrix.mat4.create();
60
+ const first = deriveCoordinationF64(identity, { x: 76, y: -11.4504049888, z: 0 }, NORMALIZE_MAT, 1);
61
+ const second = deriveCoordinationF64(identity, { x: 0, y: -11.4504049888, z: 0 }, NORMALIZE_MAT, 1);
62
+ expect(second).toEqual(first);
63
+ // Model-zero (conway#87): the frame is the bare Y-up normalize and
64
+ // the model keeps the coordinates its file authored.
65
+ expect(first[TRANSLATION_X]).toBe(0);
66
+ expect(first[TRANSLATION_Y]).toBe(0);
67
+ expect(first[TRANSLATION_Z]).toBe(0);
68
+ });
69
+ test("anchors either side of a grid line below the budget still agree", () => {
70
+ // Why the recentre is staged rather than snapped everywhere: a
71
+ // straight snap turns a small anchor disagreement into a full-cell
72
+ // one. A site-grid model at x ~ 500m whose preview channel anchors
73
+ // at 480 and whose durable walk anchors at 520 would derive frames
74
+ // 1km apart — and the adopted-frame gate only re-derives past
75
+ // LARGE_COORDINATE_BUDGET_M, so it would keep the wrong one and
76
+ // render 1km off a classic open. Below the budget both are zero.
77
+ const identity = glmatrix.mat4.create();
78
+ const below = deriveCoordinationF64(identity, { x: 480, y: 0, z: 0 }, NORMALIZE_MAT, 1);
79
+ const above = deriveCoordinationF64(identity, { x: 520, y: 0, z: 0 }, NORMALIZE_MAT, 1);
80
+ expect(above).toEqual(below);
81
+ expect(below[TRANSLATION_X]).toBe(0);
82
+ });
83
+ test("recentres only once past the budget, on the same metre grid whatever the source unit", () => {
84
+ // Same object, one file in metres and one in millimetres: the
85
+ // millimetre file's anchor is 1000x larger and its scaleFactor
86
+ // 1000x smaller, so both must land on the same metre frame.
87
+ const identity = glmatrix.mat4.create();
88
+ const metres = deriveCoordinationF64(identity, { x: 2600076, y: 412, z: 0 }, NORMALIZE_MAT, 1);
89
+ const millimetres = deriveCoordinationF64(identity, { x: 2600076000, y: 412000, z: 0 }, NORMALIZE_MAT, 0.001);
90
+ for (const i of [TRANSLATION_X, TRANSLATION_Y, TRANSLATION_Z]) {
91
+ expect(millimetres[i]).toBeCloseTo(metres[i], 6);
92
+ }
93
+ expect(metres[TRANSLATION_X]).toBe(-2600000);
94
+ });
95
+ test("keeps a georeferenced model inside the recentre budget", () => {
96
+ // Above the budget the recentre engages and snapping trades
97
+ // exactness for order-independence. Once engaged every component is
98
+ // snapped, so each keeps at most half a cell and the anchor lands
99
+ // within half a cell in each axis — measured as a distance so the
100
+ // bound is a property of the anchor, like the stage decision itself.
101
+ // The outer bound that matters is LARGE_COORDINATE_BUDGET_M, the
102
+ // threshold above which a frame counts as not having recentred.
103
+ const identity = glmatrix.mat4.create();
104
+ const ref = { x: 2600000.31, y: 1200000.17, z: 412.5 };
105
+ const coord = deriveCoordinationF64(identity, ref, NORMALIZE_MAT, 1);
106
+ // NORMALIZE_MAT is Z-up -> Y-up, so the source y/z components land in
107
+ // the frame's z/y translation slots.
108
+ const residual = Math.hypot(ref.x + coord[TRANSLATION_X], ref.z + coord[TRANSLATION_Y], ref.y - coord[TRANSLATION_Z]);
109
+ // sqrt(3) half-cells is the true worst case; assert both it and the
110
+ // budget so a future edit that widens either is caught.
111
+ expect(residual).toBeLessThanOrEqual(COORDINATION_SNAP_M);
112
+ expect(residual).toBeLessThan(LARGE_COORDINATE_BUDGET_M);
113
+ });
114
+ test("recentres a model whose offset is spread across axes", () => {
115
+ // The stage decision is the anchor's distance from the origin, not
116
+ // any one component: (9km, 9km, 9km) is 15.6km out — past the budget
117
+ // — while no single component crosses it. Deciding per component
118
+ // would leave this model in file coordinates and quietly undo the
119
+ // float32 jitter fix the budget exists to enforce.
120
+ const identity = glmatrix.mat4.create();
121
+ const coord = deriveCoordinationF64(identity, { x: 9000, y: 9000, z: 9000 }, NORMALIZE_MAT, 1);
122
+ const residual = Math.hypot(9000 + coord[TRANSLATION_X], 9000 + coord[TRANSLATION_Y], 9000 - coord[TRANSLATION_Z]);
123
+ expect(coord[TRANSLATION_X]).not.toBe(0);
124
+ expect(residual).toBeLessThan(LARGE_COORDINATE_BUDGET_M);
125
+ });
126
+ test("places a LV95-magnitude element exactly where float32 mis-lands it", () => {
127
+ // The georeferencing jitter mechanism (Share#1631): an element placed
128
+ // at Swiss LV95 magnitude (~2.6M easting) is composed against the
129
+ // recentre frame, and float32 ULP at 2.6M is ~0.31m. Run through
130
+ // gl-matrix's Float32Array that composition quantizes, and the
131
+ // element lands cm-to-dm from where the frame says — a positional
132
+ // error baked into every emitted transform. The float64 path lands it
133
+ // exactly.
134
+ //
135
+ // Note the frame's own translation is now snapped to whole kilometres
136
+ // (COORDINATION_SNAP_M), which happens to be exactly representable in
137
+ // float32 — so the loss this pins is in the composition against the
138
+ // element's full-precision placement, which is where it always
139
+ // mattered: that product is what becomes the FlatMesh transform.
62
140
  const ref = { x: 2600000.31, y: 1200000.17, z: 412.5 };
63
- /**
64
- * Apply a column-major 4x4 to a point.
65
- *
66
- * @param m 16-element matrix.
67
- * @param p The point.
68
- * @return {number} The transformed point's distance from origin.
69
- */
70
- const originDist = (m) => Math.hypot(m[0] * ref.x + m[4] * ref.y + m[8] * ref.z + m[12], m[1] * ref.x + m[5] * ref.y + m[9] * ref.z + m[13], m[2] * ref.x + m[6] * ref.y + m[10] * ref.z + m[14]);
71
- // float64 recentre (the fix).
72
- const distF64 = originDist(deriveCoordinationF64(identity, ref, NORMALIZE_MAT, 1));
73
- // Old float32 gl-matrix recentre: the translation is stored in a
74
- // Float32Array, quantizing the -2.6M component.
75
- const tp = glmatrix.vec4.create();
76
- glmatrix.vec4.transformMat4(tp, [ref.x, ref.y, ref.z, 1], identity);
77
- const coordF32 = glmatrix.mat4.create();
78
- glmatrix.mat4.fromTranslation(coordF32, [-tp[0], -tp[1], -tp[2]]);
79
- glmatrix.mat4.multiply(coordF32, NORMALIZE_MAT, coordF32);
80
- const distF32 = originDist(coordF32);
81
- // float64 lands sub-micron; float32 is off by cm+.
82
- expect(distF64).toBeLessThan(0.000001);
83
- expect(distF32).toBeGreaterThan(0.01);
141
+ const frame = deriveCoordinationF64(glmatrix.mat4.create(), ref, NORMALIZE_MAT, 1);
142
+ // An element sitting on that reference point.
143
+ const placement = glmatrix.mat4.create();
144
+ glmatrix.mat4.identity(placement);
145
+ const placementF64 = Array.from(placement);
146
+ placementF64[TRANSLATION_X] = ref.x;
147
+ placementF64[TRANSLATION_Y] = ref.y;
148
+ placementF64[TRANSLATION_Z] = ref.z;
149
+ // Where it must land: the within-cell remainder of every component.
150
+ // The stage decision is made once on the whole anchor, and this one
151
+ // is far past the budget, so all three components snap — including
152
+ // z=412.5, whose nearest grid multiple is 0 and which therefore
153
+ // keeps its full value. (A per-component budget test would leave z
154
+ // untouched for a different reason and agree here by coincidence;
155
+ // this mirrors what quantizeRecentre actually does.)
156
+ const rem = (v) => v - Math.round(v / COORDINATION_SNAP_M) * COORDINATION_SNAP_M;
157
+ const expected = [rem(ref.x), rem(ref.z), -rem(ref.y)];
158
+ const f64 = composeTransformF64(frame, placementF64);
159
+ // The old path's loss: the placement crosses the wasm boundary as a
160
+ // glm::dmat4 but was rebuilt with gl-matrix `mat4.fromValues`, i.e.
161
+ // stored in a Float32Array — quantizing its 2.6M component to the
162
+ // ~0.31m float32 grid before anything is composed. Snapping the frame
163
+ // does not rescue that; only keeping the placement in float64 does.
164
+ const f32 = composeTransformF64(frame, new Float32Array(placementF64));
165
+ const errF64 = Math.max(Math.abs(f64[TRANSLATION_X] - expected[0]), Math.abs(f64[TRANSLATION_Y] - expected[1]), Math.abs(f64[TRANSLATION_Z] - expected[2]));
166
+ const errF32 = Math.max(Math.abs(f32[TRANSLATION_X] - expected[0]), Math.abs(f32[TRANSLATION_Y] - expected[1]), Math.abs(f32[TRANSLATION_Z] - expected[2]));
167
+ expect(errF64).toBeLessThan(0.000001);
168
+ expect(errF32).toBeGreaterThan(0.01);
84
169
  });
85
170
  test("near origin float64 and float32 agree (fixtures unaffected)", () => {
86
171
  const placement = pseudoMat(99);
@@ -5,5 +5,5 @@
5
5
  // only the first segment (major) is meaningful and is the one CI carries forward.
6
6
  // Must stay in `vN.N.N` shape: the CI stamp regex, scripts/updateVersion.mjs, and
7
7
  // statistics.ts all match `v\d+\.\d+\.\d+`.
8
- const versionString = 'Conway v1.502.1421';
8
+ const versionString = 'Conway v1.507.1429';
9
9
  export { versionString };