@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.
- package/compiled/examples/browser-bundled.cjs +1 -1
- package/compiled/examples/cli-bundled.cjs +1 -1
- package/compiled/examples/cli-step-bundled.cjs +1 -1
- package/compiled/examples/validator-bundled.cjs +1 -1
- package/compiled/src/compat/web-ifc/coordination_export_order.test.d.ts +2 -0
- package/compiled/src/compat/web-ifc/coordination_export_order.test.d.ts.map +1 -0
- package/compiled/src/compat/web-ifc/coordination_export_order.test.js +110 -0
- package/compiled/src/compat/web-ifc/coordination_f64.d.ts +60 -1
- package/compiled/src/compat/web-ifc/coordination_f64.d.ts.map +1 -1
- package/compiled/src/compat/web-ifc/coordination_f64.js +103 -8
- package/compiled/src/compat/web-ifc/coordination_f64.test.js +115 -30
- package/compiled/src/version/version.js +1 -1
- package/compiled/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 @@
|
|
|
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
|
|
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] =
|
|
117
|
-
translate[13] =
|
|
118
|
-
translate[14] =
|
|
211
|
+
translate[12] = recentre[0];
|
|
212
|
+
translate[13] = recentre[1];
|
|
213
|
+
translate[14] = recentre[2];
|
|
119
214
|
// scale(scaleFactor)
|
|
120
215
|
const scale = [
|
|
121
|
-
|
|
122
|
-
0,
|
|
123
|
-
0, 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("
|
|
55
|
-
// The
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
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.
|
|
8
|
+
const versionString = 'Conway v1.507.1429';
|
|
9
9
|
export { versionString };
|