@bldrs-ai/conway 1.403.1239 → 1.405.1243
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/core/composed_model_skeleton.d.ts +63 -0
- package/compiled/src/core/composed_model_skeleton.d.ts.map +1 -0
- package/compiled/src/core/composed_model_skeleton.js +66 -0
- package/compiled/src/core/composed_model_skeleton.test.d.ts +2 -0
- package/compiled/src/core/composed_model_skeleton.test.d.ts.map +1 -0
- package/compiled/src/core/composed_model_skeleton.test.js +69 -0
- package/compiled/src/core/model_registry.d.ts +87 -0
- package/compiled/src/core/model_registry.d.ts.map +1 -0
- package/compiled/src/core/model_registry.js +101 -0
- package/compiled/src/core/model_registry.test.d.ts +2 -0
- package/compiled/src/core/model_registry.test.d.ts.map +1 -0
- package/compiled/src/core/model_registry.test.js +60 -0
- package/compiled/src/core/model_uri.d.ts +57 -0
- package/compiled/src/core/model_uri.d.ts.map +1 -0
- package/compiled/src/core/model_uri.js +101 -0
- package/compiled/src/core/model_uri.test.d.ts +2 -0
- package/compiled/src/core/model_uri.test.d.ts.map +1 -0
- package/compiled/src/core/model_uri.test.js +49 -0
- package/compiled/src/core/shared_byte_budget.d.ts +60 -0
- package/compiled/src/core/shared_byte_budget.d.ts.map +1 -0
- package/compiled/src/core/shared_byte_budget.js +85 -0
- package/compiled/src/core/shared_byte_budget.test.d.ts +2 -0
- package/compiled/src/core/shared_byte_budget.test.d.ts.map +1 -0
- package/compiled/src/core/shared_byte_budget.test.js +55 -0
- package/compiled/src/step/parsing/cross_reference_registry.d.ts +95 -0
- package/compiled/src/step/parsing/cross_reference_registry.d.ts.map +1 -0
- package/compiled/src/step/parsing/cross_reference_registry.js +91 -0
- package/compiled/src/step/parsing/cross_reference_registry.test.d.ts +2 -0
- package/compiled/src/step/parsing/cross_reference_registry.test.d.ts.map +1 -0
- package/compiled/src/step/parsing/cross_reference_registry.test.js +66 -0
- package/compiled/src/step/parsing/index_sidecar.d.ts +53 -0
- package/compiled/src/step/parsing/index_sidecar.d.ts.map +1 -0
- package/compiled/src/step/parsing/index_sidecar.js +166 -0
- package/compiled/src/step/parsing/index_sidecar.test.d.ts +2 -0
- package/compiled/src/step/parsing/index_sidecar.test.d.ts.map +1 -0
- package/compiled/src/step/parsing/index_sidecar.test.js +92 -0
- package/compiled/src/step/parsing/range_byte_source.d.ts +69 -0
- package/compiled/src/step/parsing/range_byte_source.d.ts.map +1 -0
- package/compiled/src/step/parsing/range_byte_source.js +78 -0
- package/compiled/src/step/parsing/range_byte_source.test.d.ts +2 -0
- package/compiled/src/step/parsing/range_byte_source.test.d.ts.map +1 -0
- package/compiled/src/step/parsing/range_byte_source.test.js +70 -0
- package/compiled/src/version/version.js +1 -1
- package/compiled/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-model entity addressing (M5 / design S3).
|
|
3
|
+
*
|
|
4
|
+
* Federation turns "thousands of cross-referenced files" from a loader problem
|
|
5
|
+
* into an addressing problem: every entity has a **universal address** of a
|
|
6
|
+
* model URI plus an express ID — `https://…/part-B.ifc#4022`. The engine never
|
|
7
|
+
* holds all the files; it holds a registry of models keyed by URI (see
|
|
8
|
+
* {@link ModelRegistry}) and resolves an address to a (model, expressID) pair
|
|
9
|
+
* on demand, opening a sibling loader for a URI it hasn't seen.
|
|
10
|
+
*
|
|
11
|
+
* The URI is opaque to the engine except for its `#expressID` fragment: the
|
|
12
|
+
* scheme, host, path and any version identity live entirely in the model URI,
|
|
13
|
+
* so "many versions side by side" is expressible without an engine change.
|
|
14
|
+
*/
|
|
15
|
+
const FRAGMENT = '#';
|
|
16
|
+
const RADIX = 10;
|
|
17
|
+
/**
|
|
18
|
+
* Format a model URI + express ID as a single entity address URI.
|
|
19
|
+
*
|
|
20
|
+
* @param modelURI The model URI (must not itself contain a `#`).
|
|
21
|
+
* @param expressID The express ID (a non-negative integer).
|
|
22
|
+
* @return {string} The `modelURI#expressID` address.
|
|
23
|
+
*/
|
|
24
|
+
export function formatEntityAddress(modelURI, expressID) {
|
|
25
|
+
if (modelURI.includes(FRAGMENT)) {
|
|
26
|
+
throw new Error(`Model URI must not contain '#': ${modelURI}`);
|
|
27
|
+
}
|
|
28
|
+
if (!Number.isInteger(expressID) || expressID < 0) {
|
|
29
|
+
throw new Error(`Invalid express ID ${expressID}`);
|
|
30
|
+
}
|
|
31
|
+
return `${modelURI}${FRAGMENT}${expressID}`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Parse an entity address URI into its model URI and express ID.
|
|
35
|
+
*
|
|
36
|
+
* The split is on the **last** `#`, so a fragment is required; the model URI
|
|
37
|
+
* is everything before it. A missing or non-integer fragment is an error —
|
|
38
|
+
* an address without an express ID isn't an entity address.
|
|
39
|
+
*
|
|
40
|
+
* @param uri The `modelURI#expressID` address.
|
|
41
|
+
* @return {EntityAddress} The parsed address.
|
|
42
|
+
*/
|
|
43
|
+
export function parseEntityAddress(uri) {
|
|
44
|
+
const hash = uri.lastIndexOf(FRAGMENT);
|
|
45
|
+
if (hash < 0) {
|
|
46
|
+
throw new Error(`Entity address has no '#expressID' fragment: ${uri}`);
|
|
47
|
+
}
|
|
48
|
+
const modelURI = uri.slice(0, hash);
|
|
49
|
+
const fragment = uri.slice(hash + 1);
|
|
50
|
+
if (fragment.length === 0 || !/^\d+$/.test(fragment)) {
|
|
51
|
+
throw new Error(`Entity address fragment is not an express ID: ${uri}`);
|
|
52
|
+
}
|
|
53
|
+
return { modelURI, expressID: parseInt(fragment, RADIX) };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a (possibly relative) reference URI against a base model URI, so an
|
|
57
|
+
* `IfcExternalReference.Location` like `../shared/grid.ifc` becomes an absolute
|
|
58
|
+
* model URI addressable in the registry. Absolute references (with a scheme,
|
|
59
|
+
* or protocol-relative) pass through unchanged.
|
|
60
|
+
*
|
|
61
|
+
* Uses the standard URL resolver when the base is absolute; falls back to a
|
|
62
|
+
* plain path join when the base has no scheme (e.g. a bare filename in tests).
|
|
63
|
+
*
|
|
64
|
+
* @param base The base model URI the reference was found in.
|
|
65
|
+
* @param reference The reference URI (absolute or relative).
|
|
66
|
+
* @return {string} The resolved absolute reference URI.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveReference(base, reference) {
|
|
69
|
+
// Already absolute (has a scheme like `https:` or is protocol-relative).
|
|
70
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(reference) || reference.startsWith('//')) {
|
|
71
|
+
return reference;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
return new URL(reference, base).toString();
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Base isn't a valid absolute URL (bare filename): join paths manually.
|
|
78
|
+
const slash = base.lastIndexOf('/');
|
|
79
|
+
const dir = slash < 0 ? '' : base.slice(0, slash + 1);
|
|
80
|
+
return normalizePath(dir + reference);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Collapse `.` / `..` segments in a slash-joined path (used only for the
|
|
85
|
+
* scheme-less fallback in {@link resolveReference}).
|
|
86
|
+
*
|
|
87
|
+
* @param path The path to normalise.
|
|
88
|
+
* @return {string} The normalised path.
|
|
89
|
+
*/
|
|
90
|
+
function normalizePath(path) {
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const segment of path.split('/')) {
|
|
93
|
+
if (segment === '..') {
|
|
94
|
+
out.pop();
|
|
95
|
+
}
|
|
96
|
+
else if (segment !== '.') {
|
|
97
|
+
out.push(segment);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out.join('/');
|
|
101
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"model_uri.test.d.ts","sourceRoot":"","sources":["../../../src/core/model_uri.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* eslint-disable no-magic-numbers */
|
|
2
|
+
// M5: universal entity addressing — a model URI plus an express-ID fragment —
|
|
3
|
+
// must round-trip and resolve relative references the way IFC external-ref
|
|
4
|
+
// locations demand.
|
|
5
|
+
import { describe, expect, test } from "@jest/globals";
|
|
6
|
+
import { formatEntityAddress, parseEntityAddress, resolveReference, } from "./model_uri.js";
|
|
7
|
+
describe("model URI addressing", () => {
|
|
8
|
+
test("formats and parses an entity address round-trip", () => {
|
|
9
|
+
const uri = formatEntityAddress("https://ex.com/part-B.ifc", 4022);
|
|
10
|
+
expect(uri).toBe("https://ex.com/part-B.ifc#4022");
|
|
11
|
+
const parsed = parseEntityAddress(uri);
|
|
12
|
+
expect(parsed.modelURI).toBe("https://ex.com/part-B.ifc");
|
|
13
|
+
expect(parsed.expressID).toBe(4022);
|
|
14
|
+
});
|
|
15
|
+
test("splits on the last # so the model URI keeps earlier structure", () => {
|
|
16
|
+
// (Model URIs shouldn't carry a '#', but parsing must be unambiguous.)
|
|
17
|
+
const parsed = parseEntityAddress("scheme://h/a#b#42");
|
|
18
|
+
expect(parsed.modelURI).toBe("scheme://h/a#b");
|
|
19
|
+
expect(parsed.expressID).toBe(42);
|
|
20
|
+
});
|
|
21
|
+
test("rejects formatting a model URI that contains #", () => {
|
|
22
|
+
expect(() => formatEntityAddress("a#b", 1)).toThrow(/#/);
|
|
23
|
+
});
|
|
24
|
+
test("rejects formatting a non-integer or negative express ID", () => {
|
|
25
|
+
expect(() => formatEntityAddress("a", 1.5)).toThrow();
|
|
26
|
+
expect(() => formatEntityAddress("a", -1)).toThrow();
|
|
27
|
+
});
|
|
28
|
+
test("rejects an address with no fragment or a non-numeric fragment", () => {
|
|
29
|
+
expect(() => parseEntityAddress("a.ifc")).toThrow(/fragment/);
|
|
30
|
+
expect(() => parseEntityAddress("a.ifc#")).toThrow(/express ID/);
|
|
31
|
+
expect(() => parseEntityAddress("a.ifc#x")).toThrow(/express ID/);
|
|
32
|
+
});
|
|
33
|
+
test("resolves a relative reference against an absolute base URI", () => {
|
|
34
|
+
expect(resolveReference("https://ex.com/site/plan.ifc", "../shared/grid.ifc"))
|
|
35
|
+
.toBe("https://ex.com/shared/grid.ifc");
|
|
36
|
+
expect(resolveReference("https://ex.com/site/plan.ifc", "wing-A.ifc"))
|
|
37
|
+
.toBe("https://ex.com/site/wing-A.ifc");
|
|
38
|
+
});
|
|
39
|
+
test("passes an already-absolute reference through unchanged", () => {
|
|
40
|
+
expect(resolveReference("https://ex.com/a.ifc", "https://other.org/b.ifc"))
|
|
41
|
+
.toBe("https://other.org/b.ifc");
|
|
42
|
+
});
|
|
43
|
+
test("resolves relative references against a scheme-less base", () => {
|
|
44
|
+
expect(resolveReference("project/plan.ifc", "wing-A.ifc"))
|
|
45
|
+
.toBe("project/wing-A.ifc");
|
|
46
|
+
expect(resolveReference("project/site/plan.ifc", "../grid.ifc"))
|
|
47
|
+
.toBe("project/grid.ifc");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A byte budget shared across many models (M5 / design S3).
|
|
3
|
+
*
|
|
4
|
+
* The federation invariant: budgets must be **per browser, not per model**, or
|
|
5
|
+
* opening N cross-referenced files re-introduces O(N) memory. This is the one
|
|
6
|
+
* accounting primitive that enforces it — every model's demand-geometry queue
|
|
7
|
+
* and window pool draws from a single {@link SharedByteBudget}, so the total
|
|
8
|
+
* resident footprint across all open models is bounded no matter how many are
|
|
9
|
+
* federated in.
|
|
10
|
+
*
|
|
11
|
+
* It owns only the accounting (reserve / release / availability); the eviction
|
|
12
|
+
* policy that frees bytes when a reservation can't be met stays with each
|
|
13
|
+
* model's scheduler (M3's {@link DemandGeometryQueue}) — the budget just tells
|
|
14
|
+
* a caller how much it must evict first via {@link overageFor}.
|
|
15
|
+
*/
|
|
16
|
+
export declare class SharedByteBudget {
|
|
17
|
+
private readonly totalBytes_;
|
|
18
|
+
private used_;
|
|
19
|
+
/**
|
|
20
|
+
* @param totalBytes_ The global ceiling shared across all models.
|
|
21
|
+
*/
|
|
22
|
+
constructor(totalBytes_: number);
|
|
23
|
+
/**
|
|
24
|
+
* @return {number} The global ceiling.
|
|
25
|
+
*/
|
|
26
|
+
get total(): number;
|
|
27
|
+
/**
|
|
28
|
+
* @return {number} Bytes currently reserved across all models.
|
|
29
|
+
*/
|
|
30
|
+
get used(): number;
|
|
31
|
+
/**
|
|
32
|
+
* @return {number} Bytes still reservable without eviction.
|
|
33
|
+
*/
|
|
34
|
+
get available(): number;
|
|
35
|
+
/**
|
|
36
|
+
* Try to reserve `bytes` against the shared ceiling. Succeeds (and charges
|
|
37
|
+
* the budget) only if it fits without exceeding the total; otherwise makes
|
|
38
|
+
* no change and returns false, leaving the caller to evict and retry.
|
|
39
|
+
*
|
|
40
|
+
* @param bytes The bytes to reserve (≥ 0).
|
|
41
|
+
* @return {boolean} True if reserved.
|
|
42
|
+
*/
|
|
43
|
+
reserve(bytes: number): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Release previously reserved bytes back to the shared budget.
|
|
46
|
+
*
|
|
47
|
+
* @param bytes The bytes to release (≥ 0). Clamped so `used` never goes
|
|
48
|
+
* negative — a double-release is a no-op past zero, not a corruption.
|
|
49
|
+
*/
|
|
50
|
+
release(bytes: number): void;
|
|
51
|
+
/**
|
|
52
|
+
* How many bytes a caller must evict (anywhere, across any model) before a
|
|
53
|
+
* reservation of `bytes` would fit. Zero when it already fits.
|
|
54
|
+
*
|
|
55
|
+
* @param bytes The desired reservation.
|
|
56
|
+
* @return {number} The bytes of eviction required (≥ 0).
|
|
57
|
+
*/
|
|
58
|
+
overageFor(bytes: number): number;
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=shared_byte_budget.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shared_byte_budget.d.ts","sourceRoot":"","sources":["../../../src/core/shared_byte_budget.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,qBAAa,gBAAgB;IAOd,OAAO,CAAC,QAAQ,CAAC,WAAW;IALzC,OAAO,CAAC,KAAK,CAAI;IAEjB;;OAEG;gBAC2B,WAAW,EAAE,MAAM;IAOjD;;OAEG;IACH,IAAW,KAAK,IAAI,MAAM,CAEzB;IAED;;OAEG;IACH,IAAW,IAAI,IAAI,MAAM,CAExB;IAED;;OAEG;IACH,IAAW,SAAS,IAAI,MAAM,CAE7B;IAED;;;;;;;OAOG;IACI,OAAO,CAAE,KAAK,EAAE,MAAM,GAAI,OAAO;IAexC;;;;;OAKG;IACI,OAAO,CAAE,KAAK,EAAE,MAAM,GAAI,IAAI;IASrC;;;;;;OAMG;IACI,UAAU,CAAE,KAAK,EAAE,MAAM,GAAI,MAAM;CAG3C"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A byte budget shared across many models (M5 / design S3).
|
|
3
|
+
*
|
|
4
|
+
* The federation invariant: budgets must be **per browser, not per model**, or
|
|
5
|
+
* opening N cross-referenced files re-introduces O(N) memory. This is the one
|
|
6
|
+
* accounting primitive that enforces it — every model's demand-geometry queue
|
|
7
|
+
* and window pool draws from a single {@link SharedByteBudget}, so the total
|
|
8
|
+
* resident footprint across all open models is bounded no matter how many are
|
|
9
|
+
* federated in.
|
|
10
|
+
*
|
|
11
|
+
* It owns only the accounting (reserve / release / availability); the eviction
|
|
12
|
+
* policy that frees bytes when a reservation can't be met stays with each
|
|
13
|
+
* model's scheduler (M3's {@link DemandGeometryQueue}) — the budget just tells
|
|
14
|
+
* a caller how much it must evict first via {@link overageFor}.
|
|
15
|
+
*/
|
|
16
|
+
export class SharedByteBudget {
|
|
17
|
+
/**
|
|
18
|
+
* @param totalBytes_ The global ceiling shared across all models.
|
|
19
|
+
*/
|
|
20
|
+
constructor(totalBytes_) {
|
|
21
|
+
this.totalBytes_ = totalBytes_;
|
|
22
|
+
this.used_ = 0;
|
|
23
|
+
if (totalBytes_ <= 0) {
|
|
24
|
+
throw new Error(`Invalid totalBytes ${totalBytes_}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* @return {number} The global ceiling.
|
|
29
|
+
*/
|
|
30
|
+
get total() {
|
|
31
|
+
return this.totalBytes_;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* @return {number} Bytes currently reserved across all models.
|
|
35
|
+
*/
|
|
36
|
+
get used() {
|
|
37
|
+
return this.used_;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* @return {number} Bytes still reservable without eviction.
|
|
41
|
+
*/
|
|
42
|
+
get available() {
|
|
43
|
+
return this.totalBytes_ - this.used_;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Try to reserve `bytes` against the shared ceiling. Succeeds (and charges
|
|
47
|
+
* the budget) only if it fits without exceeding the total; otherwise makes
|
|
48
|
+
* no change and returns false, leaving the caller to evict and retry.
|
|
49
|
+
*
|
|
50
|
+
* @param bytes The bytes to reserve (≥ 0).
|
|
51
|
+
* @return {boolean} True if reserved.
|
|
52
|
+
*/
|
|
53
|
+
reserve(bytes) {
|
|
54
|
+
if (bytes < 0) {
|
|
55
|
+
throw new Error(`Cannot reserve negative bytes ${bytes}`);
|
|
56
|
+
}
|
|
57
|
+
if (bytes > this.available) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
this.used_ += bytes;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Release previously reserved bytes back to the shared budget.
|
|
65
|
+
*
|
|
66
|
+
* @param bytes The bytes to release (≥ 0). Clamped so `used` never goes
|
|
67
|
+
* negative — a double-release is a no-op past zero, not a corruption.
|
|
68
|
+
*/
|
|
69
|
+
release(bytes) {
|
|
70
|
+
if (bytes < 0) {
|
|
71
|
+
throw new Error(`Cannot release negative bytes ${bytes}`);
|
|
72
|
+
}
|
|
73
|
+
this.used_ = Math.max(0, this.used_ - bytes);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* How many bytes a caller must evict (anywhere, across any model) before a
|
|
77
|
+
* reservation of `bytes` would fit. Zero when it already fits.
|
|
78
|
+
*
|
|
79
|
+
* @param bytes The desired reservation.
|
|
80
|
+
* @return {number} The bytes of eviction required (≥ 0).
|
|
81
|
+
*/
|
|
82
|
+
overageFor(bytes) {
|
|
83
|
+
return Math.max(0, bytes - this.available);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shared_byte_budget.test.d.ts","sourceRoot":"","sources":["../../../src/core/shared_byte_budget.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/* eslint-disable no-magic-numbers */
|
|
2
|
+
// M5: the per-browser shared budget is what keeps federation from re-growing
|
|
3
|
+
// memory O(N) with the number of open models — reservations across all models
|
|
4
|
+
// draw from one ceiling.
|
|
5
|
+
import { describe, expect, test } from "@jest/globals";
|
|
6
|
+
import { SharedByteBudget } from "./shared_byte_budget.js";
|
|
7
|
+
describe("SharedByteBudget", () => {
|
|
8
|
+
test("reserves up to the ceiling and refuses to overcommit", () => {
|
|
9
|
+
const budget = new SharedByteBudget(1000);
|
|
10
|
+
expect(budget.reserve(600)).toBe(true);
|
|
11
|
+
expect(budget.reserve(300)).toBe(true);
|
|
12
|
+
expect(budget.used).toBe(900);
|
|
13
|
+
expect(budget.available).toBe(100);
|
|
14
|
+
// 200 doesn't fit in the remaining 100 — refused, no change.
|
|
15
|
+
expect(budget.reserve(200)).toBe(false);
|
|
16
|
+
expect(budget.used).toBe(900);
|
|
17
|
+
});
|
|
18
|
+
test("releasing frees room for later reservations", () => {
|
|
19
|
+
const budget = new SharedByteBudget(1000);
|
|
20
|
+
budget.reserve(800);
|
|
21
|
+
expect(budget.reserve(300)).toBe(false);
|
|
22
|
+
budget.release(500);
|
|
23
|
+
expect(budget.used).toBe(300);
|
|
24
|
+
expect(budget.reserve(300)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
test("release clamps at zero (a double release is not corruption)", () => {
|
|
27
|
+
const budget = new SharedByteBudget(1000);
|
|
28
|
+
budget.reserve(100);
|
|
29
|
+
budget.release(100);
|
|
30
|
+
budget.release(100);
|
|
31
|
+
expect(budget.used).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
test("overageFor reports how much must be evicted before a reservation fits", () => {
|
|
34
|
+
const budget = new SharedByteBudget(1000);
|
|
35
|
+
budget.reserve(700);
|
|
36
|
+
// 500 wanted, 300 available → must evict 200.
|
|
37
|
+
expect(budget.overageFor(500)).toBe(200);
|
|
38
|
+
// 200 wanted, 300 available → fits, no eviction.
|
|
39
|
+
expect(budget.overageFor(200)).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
test("the ceiling is shared: two models cannot each spend the whole budget", () => {
|
|
42
|
+
const budget = new SharedByteBudget(1000);
|
|
43
|
+
// "Model A" and "model B" both draw from the same budget.
|
|
44
|
+
expect(budget.reserve(700)).toBe(true); // A
|
|
45
|
+
expect(budget.reserve(700)).toBe(false); // B — only 300 left
|
|
46
|
+
expect(budget.reserve(300)).toBe(true); // B fits in the remainder
|
|
47
|
+
expect(budget.used).toBe(budget.total);
|
|
48
|
+
});
|
|
49
|
+
test("rejects an invalid ceiling and negative amounts", () => {
|
|
50
|
+
expect(() => new SharedByteBudget(0)).toThrow();
|
|
51
|
+
const budget = new SharedByteBudget(10);
|
|
52
|
+
expect(() => budget.reserve(-1)).toThrow();
|
|
53
|
+
expect(() => budget.release(-1)).toThrow();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { EntityAddress } from "../../core/model_uri.js";
|
|
2
|
+
import { RecordHandler } from "./streaming_record_dispatcher.js";
|
|
3
|
+
/**
|
|
4
|
+
* Reads the `Location` string of a cross-reference entity by express ID.
|
|
5
|
+
* Implemented over the finished (or partially materialised) model — a thin
|
|
6
|
+
* seam so the registry's link resolution is testable without a full model.
|
|
7
|
+
* `IfcExternalReference.Location` is the canonical field; STEP AP242's external
|
|
8
|
+
* references expose their location the same way.
|
|
9
|
+
*/
|
|
10
|
+
export interface ReferenceLocationReader {
|
|
11
|
+
/**
|
|
12
|
+
* @param expressID The cross-reference entity's express ID.
|
|
13
|
+
* @return {string | null | undefined} Its `Location`, or null/undefined when
|
|
14
|
+
* absent.
|
|
15
|
+
*/
|
|
16
|
+
locationOf(expressID: number): string | null | undefined;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* An outbound federation link discovered from a cross-reference: the
|
|
20
|
+
* referencing entity in this model, the raw location it declared, and — once
|
|
21
|
+
* resolved against the model's own URI — the absolute URI (and, if the
|
|
22
|
+
* location carried a `#expressID` fragment, the entity address) it points at.
|
|
23
|
+
*/
|
|
24
|
+
export interface CrossReferenceLink {
|
|
25
|
+
/** Express ID of the referencing entity in the source model. */
|
|
26
|
+
fromExpressID: number;
|
|
27
|
+
/** The raw `Location` string as authored in the file. */
|
|
28
|
+
location: string;
|
|
29
|
+
/** The location resolved to an absolute URI against the source model URI. */
|
|
30
|
+
targetURI: string;
|
|
31
|
+
/** The target entity address, when the location carried a `#expressID`. */
|
|
32
|
+
targetEntity?: EntityAddress;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Collects a model's **outbound cross-model references** during the streaming
|
|
36
|
+
* parse and, once locations are readable, resolves them into navigable
|
|
37
|
+
* federation links (M5 / design S3).
|
|
38
|
+
*
|
|
39
|
+
* The links come from IFC's own reference entities — `IfcExternalReference`
|
|
40
|
+
* and its subtypes (`IfcDocumentReference`, `IfcClassificationReference`,
|
|
41
|
+
* `IfcLibraryReference`) — and, for STEP AP242, its external references. Two
|
|
42
|
+
* phases, matching M2's "identify while parsing, resolve on demand":
|
|
43
|
+
*
|
|
44
|
+
* 1. **Identify** — subscribe {@link handler} to a
|
|
45
|
+
* {@link StreamingRecordDispatcher} for the reference types
|
|
46
|
+
* (`dispatcher.on([IfcExternalReference], reg.handler)`); it records the
|
|
47
|
+
* express ID of every such record as it streams past. Cheap and idempotent
|
|
48
|
+
* (a `Set`), so the rare grow-restart is harmless.
|
|
49
|
+
* 2. **Resolve** — after parse, {@link resolve} reads each reference's
|
|
50
|
+
* `Location` via a {@link ReferenceLocationReader} and resolves it against
|
|
51
|
+
* the source model URI, yielding {@link CrossReferenceLink}s the UI renders
|
|
52
|
+
* as links. Visiting one opens the target model in the `ModelRegistry`.
|
|
53
|
+
*
|
|
54
|
+
* The location read is deferred because the event stream carries only
|
|
55
|
+
* `(localID, expressID, typeID)` — not attribute strings; pulling `Location`
|
|
56
|
+
* needs the entity's fields, which is a model read, not a parse event.
|
|
57
|
+
*/
|
|
58
|
+
export declare class CrossReferenceRegistry<TypeIDType extends number> {
|
|
59
|
+
private readonly sourceURI_;
|
|
60
|
+
private readonly fromExpressIDs_;
|
|
61
|
+
/**
|
|
62
|
+
* @param sourceURI_ The URI of the model these references were found in —
|
|
63
|
+
* the base for resolving relative locations.
|
|
64
|
+
*/
|
|
65
|
+
constructor(sourceURI_: string);
|
|
66
|
+
/**
|
|
67
|
+
* Record one cross-reference entity. Bind to a dispatcher subscription
|
|
68
|
+
* filtered to the reference types; every record it is handed is taken to be
|
|
69
|
+
* a cross-reference (the dispatcher does the type filtering).
|
|
70
|
+
*
|
|
71
|
+
* @param localID Unused (kept for the RecordHandler shape).
|
|
72
|
+
* @param expressID The reference entity's express ID.
|
|
73
|
+
* @param typeID Unused (the dispatcher already matched the type set).
|
|
74
|
+
*/
|
|
75
|
+
readonly handler: RecordHandler<TypeIDType>;
|
|
76
|
+
/**
|
|
77
|
+
* @return {number} The number of cross-references collected.
|
|
78
|
+
*/
|
|
79
|
+
get count(): number;
|
|
80
|
+
/**
|
|
81
|
+
* @return {IterableIterator<number>} The express IDs of the collected
|
|
82
|
+
* reference entities.
|
|
83
|
+
*/
|
|
84
|
+
expressIDs(): IterableIterator<number>;
|
|
85
|
+
/**
|
|
86
|
+
* Resolve the collected references into navigable links by reading each
|
|
87
|
+
* one's `Location` and resolving it against the source model URI. References
|
|
88
|
+
* whose location is absent (null / empty) are skipped — they carry no link.
|
|
89
|
+
*
|
|
90
|
+
* @param reader Reads the `Location` of an express ID.
|
|
91
|
+
* @return {CrossReferenceLink[]} The resolved outbound links.
|
|
92
|
+
*/
|
|
93
|
+
resolve(reader: ReferenceLocationReader): CrossReferenceLink[];
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=cross_reference_registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cross_reference_registry.d.ts","sourceRoot":"","sources":["../../../../src/step/parsing/cross_reference_registry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAoB,MAAM,sBAAsB,CAAA;AACtE,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAA;AAG7D;;;;;;GAMG;AACH,MAAM,WAAW,uBAAuB;IAEtC;;;;OAIG;IACH,UAAU,CAAE,SAAS,EAAE,MAAM,GAAI,MAAM,GAAG,IAAI,GAAG,SAAS,CAAA;CAC3D;AAGD;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IAEjC,gEAAgE;IAChE,aAAa,EAAE,MAAM,CAAA;IAErB,yDAAyD;IACzD,QAAQ,EAAE,MAAM,CAAA;IAEhB,6EAA6E;IAC7E,SAAS,EAAE,MAAM,CAAA;IAEjB,2EAA2E;IAC3E,YAAY,CAAC,EAAE,aAAa,CAAA;CAC7B;AAOD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,qBAAa,sBAAsB,CAAC,UAAU,SAAS,MAAM;IAQ9C,OAAO,CAAC,QAAQ,CAAC,UAAU;IANxC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAoB;IAEpD;;;OAGG;gBAC2B,UAAU,EAAE,MAAM;IAEhD;;;;;;;;OAQG;IACH,SAAgB,OAAO,EAAE,aAAa,CAAC,UAAU,CAAC,CAG/C;IAEH;;OAEG;IACH,IAAW,KAAK,IAAI,MAAM,CAEzB;IAED;;;OAGG;IACI,UAAU,IAAI,gBAAgB,CAAC,MAAM,CAAC;IAI7C;;;;;;;OAOG;IACI,OAAO,CAAE,MAAM,EAAE,uBAAuB,GAAI,kBAAkB,EAAE;CA8BxE"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { resolveReference } from "../../core/model_uri.js";
|
|
2
|
+
const FRAGMENT = "#";
|
|
3
|
+
const RADIX = 10;
|
|
4
|
+
/**
|
|
5
|
+
* Collects a model's **outbound cross-model references** during the streaming
|
|
6
|
+
* parse and, once locations are readable, resolves them into navigable
|
|
7
|
+
* federation links (M5 / design S3).
|
|
8
|
+
*
|
|
9
|
+
* The links come from IFC's own reference entities — `IfcExternalReference`
|
|
10
|
+
* and its subtypes (`IfcDocumentReference`, `IfcClassificationReference`,
|
|
11
|
+
* `IfcLibraryReference`) — and, for STEP AP242, its external references. Two
|
|
12
|
+
* phases, matching M2's "identify while parsing, resolve on demand":
|
|
13
|
+
*
|
|
14
|
+
* 1. **Identify** — subscribe {@link handler} to a
|
|
15
|
+
* {@link StreamingRecordDispatcher} for the reference types
|
|
16
|
+
* (`dispatcher.on([IfcExternalReference], reg.handler)`); it records the
|
|
17
|
+
* express ID of every such record as it streams past. Cheap and idempotent
|
|
18
|
+
* (a `Set`), so the rare grow-restart is harmless.
|
|
19
|
+
* 2. **Resolve** — after parse, {@link resolve} reads each reference's
|
|
20
|
+
* `Location` via a {@link ReferenceLocationReader} and resolves it against
|
|
21
|
+
* the source model URI, yielding {@link CrossReferenceLink}s the UI renders
|
|
22
|
+
* as links. Visiting one opens the target model in the `ModelRegistry`.
|
|
23
|
+
*
|
|
24
|
+
* The location read is deferred because the event stream carries only
|
|
25
|
+
* `(localID, expressID, typeID)` — not attribute strings; pulling `Location`
|
|
26
|
+
* needs the entity's fields, which is a model read, not a parse event.
|
|
27
|
+
*/
|
|
28
|
+
export class CrossReferenceRegistry {
|
|
29
|
+
/**
|
|
30
|
+
* @param sourceURI_ The URI of the model these references were found in —
|
|
31
|
+
* the base for resolving relative locations.
|
|
32
|
+
*/
|
|
33
|
+
constructor(sourceURI_) {
|
|
34
|
+
this.sourceURI_ = sourceURI_;
|
|
35
|
+
this.fromExpressIDs_ = new Set();
|
|
36
|
+
/**
|
|
37
|
+
* Record one cross-reference entity. Bind to a dispatcher subscription
|
|
38
|
+
* filtered to the reference types; every record it is handed is taken to be
|
|
39
|
+
* a cross-reference (the dispatcher does the type filtering).
|
|
40
|
+
*
|
|
41
|
+
* @param localID Unused (kept for the RecordHandler shape).
|
|
42
|
+
* @param expressID The reference entity's express ID.
|
|
43
|
+
* @param typeID Unused (the dispatcher already matched the type set).
|
|
44
|
+
*/
|
|
45
|
+
this.handler = (localID, expressID, typeID) => {
|
|
46
|
+
this.fromExpressIDs_.add(expressID);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* @return {number} The number of cross-references collected.
|
|
51
|
+
*/
|
|
52
|
+
get count() {
|
|
53
|
+
return this.fromExpressIDs_.size;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* @return {IterableIterator<number>} The express IDs of the collected
|
|
57
|
+
* reference entities.
|
|
58
|
+
*/
|
|
59
|
+
expressIDs() {
|
|
60
|
+
return this.fromExpressIDs_.keys();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the collected references into navigable links by reading each
|
|
64
|
+
* one's `Location` and resolving it against the source model URI. References
|
|
65
|
+
* whose location is absent (null / empty) are skipped — they carry no link.
|
|
66
|
+
*
|
|
67
|
+
* @param reader Reads the `Location` of an express ID.
|
|
68
|
+
* @return {CrossReferenceLink[]} The resolved outbound links.
|
|
69
|
+
*/
|
|
70
|
+
resolve(reader) {
|
|
71
|
+
const links = [];
|
|
72
|
+
for (const fromExpressID of this.fromExpressIDs_) {
|
|
73
|
+
const location = reader.locationOf(fromExpressID);
|
|
74
|
+
if (location === null || location === void 0 || location.length === 0) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const resolved = resolveReference(this.sourceURI_, location);
|
|
78
|
+
const link = { fromExpressID, location, targetURI: resolved };
|
|
79
|
+
// A location may itself address a specific entity (model.ifc#42).
|
|
80
|
+
const hash = resolved.lastIndexOf(FRAGMENT);
|
|
81
|
+
if (hash >= 0 && /^\d+$/.test(resolved.slice(hash + 1))) {
|
|
82
|
+
link.targetEntity = {
|
|
83
|
+
modelURI: resolved.slice(0, hash),
|
|
84
|
+
expressID: parseInt(resolved.slice(hash + 1), RADIX),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
links.push(link);
|
|
88
|
+
}
|
|
89
|
+
return links;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cross_reference_registry.test.d.ts","sourceRoot":"","sources":["../../../../src/step/parsing/cross_reference_registry.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/* eslint-disable no-magic-numbers */
|
|
2
|
+
// M5: outbound cross-model references are collected during the parse (by type,
|
|
3
|
+
// via the dispatcher) and resolved to navigable federation links against the
|
|
4
|
+
// source model URI once their Location strings are readable.
|
|
5
|
+
import { describe, expect, test } from "@jest/globals";
|
|
6
|
+
import { CrossReferenceRegistry, } from "./cross_reference_registry.js";
|
|
7
|
+
import { StreamingRecordDispatcher } from "./streaming_record_dispatcher.js";
|
|
8
|
+
import { IfcExternalReference } from "../../ifc/ifc4_gen/index.js";
|
|
9
|
+
/**
|
|
10
|
+
* A mock reader over a fixed express-ID → Location map.
|
|
11
|
+
*
|
|
12
|
+
* @param locations The express-ID → Location map.
|
|
13
|
+
* @return {ReferenceLocationReader} The reader.
|
|
14
|
+
*/
|
|
15
|
+
function reader(locations) {
|
|
16
|
+
return { locationOf: (expressID) => locations[expressID] };
|
|
17
|
+
}
|
|
18
|
+
describe("CrossReferenceRegistry", () => {
|
|
19
|
+
test("collects references handed to its handler and resolves them", () => {
|
|
20
|
+
const registry = new CrossReferenceRegistry("https://ex.com/site/plan.ifc");
|
|
21
|
+
// Simulate the dispatcher delivering three reference records.
|
|
22
|
+
registry.handler(0, 100, 1);
|
|
23
|
+
registry.handler(1, 200, 1);
|
|
24
|
+
registry.handler(2, 300, 1);
|
|
25
|
+
expect(registry.count).toBe(3);
|
|
26
|
+
const links = registry.resolve(reader({
|
|
27
|
+
100: "../shared/grid.ifc",
|
|
28
|
+
200: "https://other.org/lib.ifc",
|
|
29
|
+
300: "wing-A.ifc#4022",
|
|
30
|
+
}));
|
|
31
|
+
const byFrom = new Map(links.map((l) => [l.fromExpressID, l]));
|
|
32
|
+
expect(byFrom.get(100)?.targetURI).toBe("https://ex.com/shared/grid.ifc");
|
|
33
|
+
expect(byFrom.get(200)?.targetURI).toBe("https://other.org/lib.ifc");
|
|
34
|
+
// A location with a #expressID resolves to a full entity address.
|
|
35
|
+
const wing = byFrom.get(300);
|
|
36
|
+
expect(wing?.targetURI).toBe("https://ex.com/site/wing-A.ifc#4022");
|
|
37
|
+
expect(wing?.targetEntity).toEqual({
|
|
38
|
+
modelURI: "https://ex.com/site/wing-A.ifc",
|
|
39
|
+
expressID: 4022,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
test("skips references with no location", () => {
|
|
43
|
+
const registry = new CrossReferenceRegistry("base.ifc");
|
|
44
|
+
registry.handler(0, 1, 1);
|
|
45
|
+
registry.handler(1, 2, 1);
|
|
46
|
+
const links = registry.resolve(reader({ 1: null, 2: "" }));
|
|
47
|
+
expect(links).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
test("is idempotent under the streaming grow-restart (dedupes express IDs)", () => {
|
|
50
|
+
const registry = new CrossReferenceRegistry("base.ifc");
|
|
51
|
+
registry.handler(0, 42, 1);
|
|
52
|
+
registry.handler(0, 42, 1); // re-fire from localID 0 after a restart
|
|
53
|
+
expect(registry.count).toBe(1);
|
|
54
|
+
});
|
|
55
|
+
test("wires to the dispatcher, collecting only the reference types", () => {
|
|
56
|
+
const registry = new CrossReferenceRegistry("base.ifc");
|
|
57
|
+
const dispatcher = new StreamingRecordDispatcher();
|
|
58
|
+
dispatcher.on([IfcExternalReference], registry.handler);
|
|
59
|
+
// One record of a matching (subtype-closure) type, one outside it.
|
|
60
|
+
const matching = IfcExternalReference.query[0];
|
|
61
|
+
const nonMatching = 10000000; // not in the reference closure
|
|
62
|
+
dispatcher.onRecordIndexed(0, 500, matching);
|
|
63
|
+
dispatcher.onRecordIndexed(1, 600, nonMatching);
|
|
64
|
+
expect([...registry.expressIDs()]).toEqual([500]);
|
|
65
|
+
});
|
|
66
|
+
});
|