@ifc-lite/viewer 1.26.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/.turbo/turbo-build.log +38 -31
  2. package/CHANGELOG.md +29 -0
  3. package/dist/assets/{basketViewActivator-ZpTYWE3K.js → basketViewActivator-B3CdrLsb.js} +7 -7
  4. package/dist/assets/{bcf-Ctcu_Sc2.js → bcf-QeHK_Aud.js} +1 -1
  5. package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
  6. package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
  7. package/dist/assets/{deflate-Cnx0il6E.js → deflate-B-d0SYQM.js} +1 -1
  8. package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
  9. package/dist/assets/{exporters-DSq76AVM.js → exporters-B4LbZFeT.js} +1422 -1194
  10. package/dist/assets/geometry.worker-BdH-E6NB.js +1 -0
  11. package/dist/assets/{geotiff-A5UjhI6L.js → geotiff-CrVtDRFq.js} +10 -10
  12. package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
  13. package/dist/assets/{ids-DiLcGTer.js → ids-DjsGFN10.js} +4 -4
  14. package/dist/assets/ifc-lite_bg-DsYUIHm3.wasm +0 -0
  15. package/dist/assets/{index-BAH8IJVR.js → index-COYokSKc.js} +38319 -35469
  16. package/dist/assets/index-ajK6D32J.css +1 -0
  17. package/dist/assets/index.es-CY202jA3.js +6866 -0
  18. package/dist/assets/{jpeg-BzSkwo5D.js → jpeg-D4wOkf5h.js} +1 -1
  19. package/dist/assets/jspdf.es.min-DIGb9BHN.js +19571 -0
  20. package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
  21. package/dist/assets/{lerc-Cg2Rz-D5.js → lerc-DmW0_tgf.js} +1 -1
  22. package/dist/assets/{lzw-BBPPLW-0.js → lzw-oWetY-d6.js} +1 -1
  23. package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
  24. package/dist/assets/{native-bridge-CPojOeGE.js → native-bridge-BX8_tHXE.js} +1 -1
  25. package/dist/assets/{packbits-yLSpjW-V.js → packbits-F8Nkp4NY.js} +1 -1
  26. package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
  27. package/dist/assets/{parser.worker-8md211IW.js → parser.worker-D591Zu_-.js} +3 -3
  28. package/dist/assets/pdf-Dsh3HPZB.js +135 -0
  29. package/dist/assets/raw-D9iw0tmc.js +1 -0
  30. package/dist/assets/{sandbox-CsRXlgCO.js → sandbox-BAC3a-eN.js} +1735 -1660
  31. package/dist/assets/server-client-Cjwnm7il.js +706 -0
  32. package/dist/assets/{webimage-YafxjjGr.js → webimage-BLV1dgmd.js} +1 -1
  33. package/dist/assets/xlsx-Bc2HTrjC.js +142 -0
  34. package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
  35. package/dist/assets/{zstd-CkSLOiuu.js → zstd-C_1HxVrA.js} +1 -1
  36. package/dist/index.html +8 -8
  37. package/package.json +10 -7
  38. package/src/components/mcp/PlaygroundChat.tsx +1 -0
  39. package/src/components/mcp/data.ts +6 -0
  40. package/src/components/mcp/playground-dispatcher.ts +277 -0
  41. package/src/components/mcp/types.ts +2 -1
  42. package/src/components/ui/combo-input.tsx +163 -0
  43. package/src/components/ui/tabs.tsx +1 -1
  44. package/src/components/viewer/PropertiesPanel.tsx +13 -6
  45. package/src/components/viewer/SearchInline.tsx +62 -2
  46. package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
  47. package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
  48. package/src/components/viewer/SearchModal.filter.tsx +64 -1
  49. package/src/components/viewer/SearchModal.tsx +19 -6
  50. package/src/components/viewer/Viewport.tsx +15 -0
  51. package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
  52. package/src/components/viewer/lists/ListBuilder.tsx +789 -280
  53. package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
  54. package/src/components/viewer/lists/ListPanel.tsx +49 -5
  55. package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
  56. package/src/components/viewer/lists/list-table-utils.ts +123 -0
  57. package/src/generated/mcp-catalog.json +4 -0
  58. package/src/hooks/source-key.ts +35 -0
  59. package/src/hooks/useAlignmentLines3D.ts +1 -26
  60. package/src/hooks/useGridLines3D.ts +140 -0
  61. package/src/lib/length-unit-scale.ts +41 -0
  62. package/src/lib/lists/adapter.ts +136 -11
  63. package/src/lib/lists/export/csv.ts +47 -0
  64. package/src/lib/lists/export/index.ts +49 -0
  65. package/src/lib/lists/export/model.ts +111 -0
  66. package/src/lib/lists/export/pdf.ts +67 -0
  67. package/src/lib/lists/export/xlsx.ts +83 -0
  68. package/src/lib/lists/index.ts +2 -0
  69. package/src/lib/search/filter-evaluate.test.ts +81 -0
  70. package/src/lib/search/filter-evaluate.ts +59 -87
  71. package/src/lib/search/filter-match.ts +167 -0
  72. package/src/lib/search/filter-rules.test.ts +25 -0
  73. package/src/lib/search/filter-rules.ts +75 -2
  74. package/src/lib/search/filter-schema.ts +0 -0
  75. package/src/lib/slab-edit.test.ts +72 -0
  76. package/src/lib/slab-edit.ts +159 -19
  77. package/src/sdk/adapters/export-adapter.ts +3 -3
  78. package/src/sdk/adapters/query-adapter.ts +3 -3
  79. package/src/store/slices/listSlice.ts +6 -0
  80. package/src/store/slices/mutationSlice.ts +14 -6
  81. package/src/store/slices/searchSlice.ts +29 -3
  82. package/src/utils/nativeSpatialDataStore.ts +6 -0
  83. package/src/utils/serverDataModel.test.ts +6 -0
  84. package/src/utils/serverDataModel.ts +7 -0
  85. package/dist/assets/geometry.worker-0Q9qEa6p.js +0 -1
  86. package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
  87. package/dist/assets/index-B9Ug2EqU.css +0 -1
  88. package/dist/assets/raw-BQrAgxwT.js +0 -1
  89. package/dist/assets/server-client-Bk4c1CPO.js +0 -626
@@ -0,0 +1,167 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Per-rule value resolution + matching for the path-B evaluator.
7
+ *
8
+ * Split out of `filter-evaluate.ts` (which keeps the iteration /
9
+ * orchestration logic) to stay under the module size cap. These helpers
10
+ * are pure given their inputs, which is what makes them unit-testable in
11
+ * `filter-evaluate.test.ts` via the evaluator's `__internal` re-export.
12
+ */
13
+
14
+ import {
15
+ extractPropertiesOnDemand,
16
+ extractQuantitiesOnDemand,
17
+ type IfcDataStore,
18
+ type MaterialInfo,
19
+ type ClassificationInfo,
20
+ } from '@ifc-lite/parser';
21
+
22
+ import {
23
+ valueOpMatches,
24
+ numericOpMatches,
25
+ matchStringAnyNone,
26
+ type PropertyRule,
27
+ type QuantityRule,
28
+ type ClassificationRule,
29
+ } from './filter-rules.js';
30
+
31
+ // ── Pset / Qto matching ──────────────────────────────────────────────────────
32
+
33
+ export interface PsetRow { setName: string; propertyName: string; value: string }
34
+ export type PsetRows = ReadonlyArray<PsetRow>;
35
+
36
+ export interface QtyRow { setName: string; quantityName: string; value: number }
37
+ export type QtyRows = ReadonlyArray<QtyRow>;
38
+
39
+ export function flattenPsets(
40
+ psets: ReturnType<typeof extractPropertiesOnDemand>,
41
+ ): PsetRows {
42
+ const out: PsetRow[] = [];
43
+ for (const set of psets) {
44
+ for (const p of set.properties) {
45
+ out.push({
46
+ setName: set.name,
47
+ propertyName: p.name,
48
+ // Stringify everything — `valueOpMatches` re-parses numeric ops
49
+ // from this representation. Booleans render as "true"/"false"
50
+ // which matches the chip UI's lowercased input convention.
51
+ value: stringifyValue(p.value),
52
+ });
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ export function flattenQtys(
59
+ qtos: ReturnType<typeof extractQuantitiesOnDemand>,
60
+ ): QtyRows {
61
+ const out: QtyRow[] = [];
62
+ for (const set of qtos) {
63
+ for (const q of set.quantities) {
64
+ out.push({ setName: set.name, quantityName: q.name, value: q.value });
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ export function stringifyValue(value: unknown): string {
71
+ if (value === null || value === undefined) return '';
72
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
73
+ if (typeof value === 'number') return String(value);
74
+ return String(value);
75
+ }
76
+
77
+ export function matchPropertyRule(rule: PropertyRule, rows: PsetRows): boolean {
78
+ // isSet / isNotSet are presence checks against (setName, propertyName).
79
+ if (rule.op === 'isSet' || rule.op === 'isNotSet') {
80
+ const present = rows.some(
81
+ (r) =>
82
+ r.setName.toLowerCase() === rule.setName.toLowerCase() &&
83
+ r.propertyName.toLowerCase() === rule.propertyName.toLowerCase(),
84
+ );
85
+ return rule.op === 'isSet' ? present : !present;
86
+ }
87
+
88
+ return rows.some(
89
+ (r) =>
90
+ r.setName.toLowerCase() === rule.setName.toLowerCase() &&
91
+ r.propertyName.toLowerCase() === rule.propertyName.toLowerCase() &&
92
+ valueOpMatches(rule.op, r.value, rule.value),
93
+ );
94
+ }
95
+
96
+ export function matchQuantityRule(rule: QuantityRule, rows: QtyRows): boolean {
97
+ return rows.some(
98
+ (r) =>
99
+ r.setName.toLowerCase() === rule.setName.toLowerCase() &&
100
+ r.quantityName.toLowerCase() === rule.quantityName.toLowerCase() &&
101
+ numericOpMatches(rule.op, r.value, rule.value),
102
+ );
103
+ }
104
+
105
+ // ── Storey lookup fallback ────────────────────────────────────────────────────
106
+
107
+ export function defaultStoreyName(store: IfcDataStore, expressId: number): string {
108
+ const hierarchy = store.spatialHierarchy;
109
+ if (!hierarchy) return '';
110
+ const storeyId = hierarchy.elementToStorey.get(expressId);
111
+ if (!storeyId) return '';
112
+ return store.entities.getName(storeyId);
113
+ }
114
+
115
+ // ── Material / classification / elevation resolution ─────────────────────────
116
+
117
+ /** Collect every material-name string an element exposes — top-level
118
+ * material, plus layer / constituent / profile names and list members.
119
+ * Used by the multi-valued `material` rule matcher. */
120
+ export function materialNamesOf(info: MaterialInfo | null): string[] {
121
+ if (!info) return [];
122
+ const names: string[] = [];
123
+ const push = (s: string | undefined) => { if (s) names.push(s); };
124
+ push(info.name);
125
+ for (const l of info.layers ?? []) { push(l.materialName); push(l.name); }
126
+ for (const c of info.constituents ?? []) { push(c.materialName); push(c.name); }
127
+ for (const p of info.profiles ?? []) { push(p.materialName); push(p.name); }
128
+ for (const m of info.materials ?? []) push(m.name);
129
+ return names;
130
+ }
131
+
132
+ /** Match a classification rule against an element's classification refs.
133
+ * `system` (when set) scopes to one classification system; value ops
134
+ * match a ref's code (identification) OR name. */
135
+ export function matchClassificationRule(
136
+ rule: ClassificationRule,
137
+ refs: readonly ClassificationInfo[],
138
+ ): boolean {
139
+ const sys = rule.system?.trim().toLowerCase();
140
+ const scoped = sys
141
+ ? refs.filter((r) => (r.system ?? '').toLowerCase() === sys)
142
+ : refs;
143
+
144
+ if (rule.op === 'isSet') return scoped.length > 0;
145
+ if (rule.op === 'isNotSet') return scoped.length === 0;
146
+
147
+ // Value ops — match against identification (code) and name of each ref.
148
+ const candidates: string[] = [];
149
+ for (const r of scoped) {
150
+ if (r.identification) candidates.push(r.identification);
151
+ if (r.name) candidates.push(r.name);
152
+ }
153
+ // rule.op is now eq | ne | contains | notContains — a StringOp subset.
154
+ return matchStringAnyNone(rule.op, candidates, rule.value);
155
+ }
156
+
157
+ /** Element elevation in metres, derived from its building storey's
158
+ * elevation. Returns null when the element isn't placed in the spatial
159
+ * hierarchy (so an elevation rule simply doesn't match it). */
160
+ export function elevationOf(store: IfcDataStore, expressId: number): number | null {
161
+ const hierarchy = store.spatialHierarchy;
162
+ if (!hierarchy) return null;
163
+ const storeyId = hierarchy.elementToStorey.get(expressId);
164
+ if (!storeyId) return null;
165
+ const elev = hierarchy.storeyElevations.get(storeyId);
166
+ return typeof elev === 'number' ? elev : null;
167
+ }
@@ -7,6 +7,7 @@ import assert from 'node:assert';
7
7
  import {
8
8
  setOpMatches,
9
9
  stringOpMatches,
10
+ matchStringAnyNone,
10
11
  numericOpMatches,
11
12
  valueOpMatches,
12
13
  combineRuleResults,
@@ -46,6 +47,27 @@ describe('stringOpMatches', () => {
46
47
  });
47
48
  });
48
49
 
50
+ describe('matchStringAnyNone', () => {
51
+ const layers = ['Concrete C30/37', 'Rigid Insulation', 'Gypsum Board'];
52
+ it('positive ops match if ANY candidate satisfies them', () => {
53
+ assert.strictEqual(matchStringAnyNone('contains', layers, 'insulation'), true);
54
+ assert.strictEqual(matchStringAnyNone('eq', layers, 'gypsum board'), true);
55
+ assert.strictEqual(matchStringAnyNone('startsWith', layers, 'concrete'), true);
56
+ assert.strictEqual(matchStringAnyNone('contains', layers, 'timber'), false);
57
+ });
58
+ it('negative ops match only if NO candidate violates them', () => {
59
+ assert.strictEqual(matchStringAnyNone('notContains', layers, 'timber'), true);
60
+ assert.strictEqual(matchStringAnyNone('notContains', layers, 'concrete'), false);
61
+ assert.strictEqual(matchStringAnyNone('ne', layers, 'steel'), true);
62
+ assert.strictEqual(matchStringAnyNone('ne', layers, 'gypsum board'), false);
63
+ });
64
+ it('an empty candidate set never matches — including negative ops', () => {
65
+ assert.strictEqual(matchStringAnyNone('contains', [], 'concrete'), false);
66
+ assert.strictEqual(matchStringAnyNone('notContains', [], 'concrete'), false);
67
+ assert.strictEqual(matchStringAnyNone('ne', [], 'concrete'), false);
68
+ });
69
+ });
70
+
49
71
  describe('numericOpMatches', () => {
50
72
  it('eq uses 1e-9 epsilon (matches Rust impl)', () => {
51
73
  assert.strictEqual(numericOpMatches('eq', 1.0 + 1e-12, 1.0), true);
@@ -101,6 +123,9 @@ describe('isFilterRule / parseFilterRules', () => {
101
123
  assert.strictEqual(isFilterRule(Rule.name('contains', 'wall')), true);
102
124
  assert.strictEqual(isFilterRule(Rule.property('Pset_X', 'P', 'eq', 'v')), true);
103
125
  assert.strictEqual(isFilterRule(Rule.quantity('Qto_X', 'Q', 'gt', 1)), true);
126
+ assert.strictEqual(isFilterRule(Rule.material('contains', 'Concrete')), true);
127
+ assert.strictEqual(isFilterRule(Rule.classification('Uniclass', 'contains', 'Pr_')), true);
128
+ assert.strictEqual(isFilterRule(Rule.elevation('gt', 3)), true);
104
129
  });
105
130
  it('rejects unknown kinds and non-objects', () => {
106
131
  assert.strictEqual(isFilterRule({ kind: 'bogus' }), false);
@@ -37,6 +37,11 @@ export type ValueOp =
37
37
  | 'isSet'
38
38
  | 'isNotSet';
39
39
 
40
+ /** Classification value+presence ops. A classification is matched against
41
+ * its code / name string, so this is the StringOp comparison subset plus
42
+ * presence — numeric ops don't apply. */
43
+ export type ClassificationOp = 'eq' | 'ne' | 'contains' | 'notContains' | 'isSet' | 'isNotSet';
44
+
40
45
  /** Top-level rule combinator. */
41
46
  export type Combinator = 'AND' | 'OR';
42
47
 
@@ -83,13 +88,47 @@ export interface QuantityRule {
83
88
  value: number;
84
89
  }
85
90
 
91
+ /** Match against an element's material name(s) — top-level material,
92
+ * layer / constituent / profile names, and list members. Multi-valued:
93
+ * the evaluator matches if ANY name satisfies a positive op, or NONE
94
+ * violates a negative op (ne / notContains). */
95
+ export interface MaterialRule {
96
+ kind: 'material';
97
+ op: StringOp;
98
+ value: string;
99
+ }
100
+
101
+ /** Match against an element's classification references (e.g. Uniclass,
102
+ * OmniClass). `system` optionally scopes to one classification system;
103
+ * the value matches a reference's code (identification) OR name. */
104
+ export interface ClassificationRule {
105
+ kind: 'classification';
106
+ /** Optional system scope (e.g. "Uniclass 2015"). Empty = any system. */
107
+ system?: string;
108
+ op: ClassificationOp;
109
+ /** Matched against identification (code) OR name. Ignored for isSet/isNotSet. */
110
+ value: string;
111
+ }
112
+
113
+ /** Match against an element's elevation in metres — derived from the
114
+ * elevation of the building storey the element belongs to. */
115
+ export interface ElevationRule {
116
+ kind: 'elevation';
117
+ op: NumericOp;
118
+ /** Threshold in metres. */
119
+ value: number;
120
+ }
121
+
86
122
  export type FilterRule =
87
123
  | StoreyRule
88
124
  | IfcTypeRule
89
125
  | PredefinedTypeRule
90
126
  | NameRule
91
127
  | PropertyRule
92
- | QuantityRule;
128
+ | QuantityRule
129
+ | MaterialRule
130
+ | ClassificationRule
131
+ | ElevationRule;
93
132
 
94
133
  // ── Pure op helpers (ported verbatim from filter.rs) ──────────────────────────
95
134
 
@@ -110,6 +149,33 @@ export function stringOpMatches(op: StringOp, candidate: string, value: string):
110
149
  }
111
150
  }
112
151
 
152
+ /**
153
+ * Match a StringOp against a *set* of candidate strings — used for
154
+ * multi-valued dimensions (an element's material names, a classification's
155
+ * code+name pair). Positive ops (eq / contains / startsWith) match if ANY
156
+ * candidate satisfies them; negative ops (ne / notContains) match only if
157
+ * NO candidate violates them. An empty candidate set never matches: an
158
+ * element with no materials/classifications shouldn't satisfy a filter on
159
+ * that dimension (including the negative ops).
160
+ */
161
+ export function matchStringAnyNone(
162
+ op: StringOp,
163
+ candidates: readonly string[],
164
+ value: string,
165
+ ): boolean {
166
+ if (candidates.length === 0) return false;
167
+ switch (op) {
168
+ case 'eq':
169
+ case 'contains':
170
+ case 'startsWith':
171
+ return candidates.some((c) => stringOpMatches(op, c, value));
172
+ case 'ne':
173
+ return candidates.every((c) => stringOpMatches('eq', c, value) === false);
174
+ case 'notContains':
175
+ return candidates.every((c) => stringOpMatches('contains', c, value) === false);
176
+ }
177
+ }
178
+
113
179
  export function numericOpMatches(op: NumericOp, candidate: number, value: number): boolean {
114
180
  // The Rust side uses 1e-9 as the epsilon for eq/ne. Match it here for
115
181
  // IDS-style parity — IFC quantities are stored as IFC4 IfcReal so the
@@ -175,6 +241,10 @@ export const Rule = {
175
241
  ({ kind: 'property', setName, propertyName, op, value }),
176
242
  quantity: (setName: string, quantityName: string, op: NumericOp, value: number): QuantityRule =>
177
243
  ({ kind: 'quantity', setName, quantityName, op, value }),
244
+ material: (op: StringOp, value: string): MaterialRule => ({ kind: 'material', op, value }),
245
+ classification: (system: string, op: ClassificationOp, value: string): ClassificationRule =>
246
+ ({ kind: 'classification', system: system || undefined, op, value }),
247
+ elevation: (op: NumericOp, value: number): ElevationRule => ({ kind: 'elevation', op, value }),
178
248
  } as const;
179
249
 
180
250
  // ── JSON guards ──────────────────────────────────────────────────────────────
@@ -188,7 +258,10 @@ export function isFilterRule(value: unknown): value is FilterRule {
188
258
  kind === 'predefinedType' ||
189
259
  kind === 'name' ||
190
260
  kind === 'property' ||
191
- kind === 'quantity'
261
+ kind === 'quantity' ||
262
+ kind === 'material' ||
263
+ kind === 'classification' ||
264
+ kind === 'elevation'
192
265
  );
193
266
  }
194
267
 
Binary file
@@ -99,6 +99,78 @@ describe('slab-edit', () => {
99
99
  assert.deepStrictEqual(chain.footprint[2], [11, 22]);
100
100
  });
101
101
 
102
+ it('applies the IfcExtrudedAreaSolid.Position transform (offset + axis flip)', () => {
103
+ // Real authoring tools bake the slab's plan offset/rotation into the
104
+ // solid Position rather than the IfcLocalPlacement. Here the solid is
105
+ // placed at (100, 50) with RefDirection (-1,0,0) + Axis (0,0,-1) — the
106
+ // 180°-about-the-vertical flip seen in the BIMcollab fixture (#90).
107
+ const entities = makePolygonSlabFixture();
108
+ entities.push(
109
+ { expressId: 70, type: 'IFCCARTESIANPOINT', attributes: [[100, 50, 0]] },
110
+ { expressId: 71, type: 'IFCDIRECTION', attributes: [[0, 0, -1]] }, // Axis (Z)
111
+ { expressId: 72, type: 'IFCDIRECTION', attributes: [[-1, 0, 0]] }, // RefDirection (X)
112
+ { expressId: 73, type: 'IFCAXIS2PLACEMENT3D', attributes: [70, 71, 72] },
113
+ );
114
+ // Point the solid's Position slot (attr 1) at the new placement.
115
+ entities.find((e) => e.expressId === 93)!.attributes = [92, 73, null, 0.25];
116
+
117
+ const editor = new StubStoreEditor(entities) as unknown as Parameters<typeof resolveSlabEditChain>[2];
118
+ const view = new StubView() as unknown as Parameters<typeof resolveSlabEditChain>[1];
119
+ const chain = resolveSlabEditChain(dataStoreStub, view, editor, 100);
120
+ assert.ok(chain);
121
+ // X = (-1,0,0), Y = Z×X = (0,1,0) → solidXform(p) = (100 - p.x, 50 + p.y).
122
+ // Then + placement origin (10, 20).
123
+ // (0,0) → (100,50) → (110,70)
124
+ // (2,0) → (98,50) → (108,70)
125
+ // (1,2) → (99,52) → (109,72)
126
+ assert.deepStrictEqual(chain.footprint[0], [110, 70]);
127
+ assert.deepStrictEqual(chain.footprint[1], [108, 70]);
128
+ assert.deepStrictEqual(chain.footprint[2], [109, 72]);
129
+ });
130
+
131
+ it('normalizes a non-unit-length Axis/RefDirection in the solid Position', () => {
132
+ // IfcDirection.DirectionRatios are ratios, not guaranteed unit
133
+ // vectors. A valid Axis=(0,0,2) must not leak its length into the
134
+ // Y basis (Y = Z×X) or skew the Gram-Schmidt projection — otherwise
135
+ // the footprint disagrees with the (normalizing) renderer. With
136
+ // proper normalization the result matches an identity-rotation
137
+ // placement: solidXform(p) = (100 + p.x, 50 + p.y).
138
+ const entities = makePolygonSlabFixture();
139
+ entities.push(
140
+ { expressId: 70, type: 'IFCCARTESIANPOINT', attributes: [[100, 50, 0]] },
141
+ { expressId: 71, type: 'IFCDIRECTION', attributes: [[0, 0, 2]] }, // non-unit Axis (Z)
142
+ { expressId: 72, type: 'IFCDIRECTION', attributes: [[3, 0, 0]] }, // non-unit RefDirection (X)
143
+ { expressId: 73, type: 'IFCAXIS2PLACEMENT3D', attributes: [70, 71, 72] },
144
+ );
145
+ entities.find((e) => e.expressId === 93)!.attributes = [92, 73, null, 0.25];
146
+
147
+ const editor = new StubStoreEditor(entities) as unknown as Parameters<typeof resolveSlabEditChain>[2];
148
+ const view = new StubView() as unknown as Parameters<typeof resolveSlabEditChain>[1];
149
+ const chain = resolveSlabEditChain(dataStoreStub, view, editor, 100);
150
+ assert.ok(chain);
151
+ // (0,0) → (100,50) → +placement(10,20) → (110,70)
152
+ // (2,0) → (102,50) → (112,70)
153
+ // (1,2) → (101,52) → (111,72) [buggy raw-Axis would give y=74]
154
+ assert.deepStrictEqual(chain.footprint[0], [110, 70]);
155
+ assert.deepStrictEqual(chain.footprint[1], [112, 70]);
156
+ assert.deepStrictEqual(chain.footprint[2], [111, 72]);
157
+ });
158
+
159
+ it('ignores lengthUnitScale for authored (overlay) entities', () => {
160
+ // The in-store builders already emit metres, so a freshly-authored
161
+ // slab must NOT be re-scaled even on a millimetre model — otherwise
162
+ // re-splitting a just-cut half would shrink it 1000×. The stub serves
163
+ // overlay entities, so the footprint stays in its given units despite
164
+ // the 0.001 scale.
165
+ const entities = makePolygonSlabFixture();
166
+ const editor = new StubStoreEditor(entities) as unknown as Parameters<typeof resolveSlabEditChain>[2];
167
+ const view = new StubView() as unknown as Parameters<typeof resolveSlabEditChain>[1];
168
+ const chain = resolveSlabEditChain(dataStoreStub, view, editor, 100, 0.001);
169
+ assert.ok(chain);
170
+ assert.deepStrictEqual(chain.footprint[0], [10, 20]);
171
+ assert.strictEqual(chain.thickness, 0.25);
172
+ });
173
+
102
174
  it('strips the redundant closing vertex from an IfcPolyline', () => {
103
175
  const entities = makePolygonSlabFixture();
104
176
  // Append a duplicate of the first vertex to the polyline.
@@ -35,6 +35,7 @@ import type { MutablePropertyView, StoreEditor } from '@ifc-lite/mutations';
35
35
  import {
36
36
  asExpressIdRef,
37
37
  asCoordinateTriple,
38
+ asDirectionRatios,
38
39
  readAttributes,
39
40
  resolvePlacementChain,
40
41
  } from './placement-core.js';
@@ -61,6 +62,93 @@ function stepTypeToSlabLike(stepType: string): SlabLikeType | null {
61
62
  return SLAB_LIKE_STEP_TYPES[stepType.toUpperCase()] ?? null;
62
63
  }
63
64
 
65
+ /**
66
+ * A 2D rigid transform mapping a profile-coordinate point into the
67
+ * solid's local plan (XY). Built from the `IfcExtrudedAreaSolid`'s
68
+ * `Position` (an `IfcAxis2Placement3D`), it folds in the in-place
69
+ * translation + rotation that real-world authoring tools bake there.
70
+ * In-store-built slabs carry an identity Position, so the resolver
71
+ * defaults to the identity transform for them.
72
+ */
73
+ type Xform2D = (p: [number, number]) => [number, number];
74
+
75
+ const IDENTITY_XFORM2D: Xform2D = (p) => [p[0], p[1]];
76
+
77
+ function readDirection(
78
+ dataStore: IfcDataStore,
79
+ view: MutablePropertyView,
80
+ editor: StoreEditor,
81
+ id: number | null,
82
+ ): [number, number, number] | null {
83
+ if (id === null) return null;
84
+ const attrs = readAttributes(dataStore, view, editor, id);
85
+ return attrs ? asDirectionRatios(attrs[0]) : null;
86
+ }
87
+
88
+ /**
89
+ * Build the plan-space transform for an `IfcExtrudedAreaSolid.Position`.
90
+ * The profile lives in the placement's local XY plane; we map a profile
91
+ * point `(px, py)` to `origin + px·X + py·Y` and keep the XY components
92
+ * (the footprint is the plan). X comes from RefDirection (orthonormalised
93
+ * against the Axis/Z), Y = Z × X — matching the IFC placement convention,
94
+ * including axis flips (e.g. Axis `(0,0,-1)`, RefDirection `(-1,0,0)`).
95
+ * Returns identity when the placement is absent or degenerate.
96
+ */
97
+ function resolveSolidPositionXform(
98
+ dataStore: IfcDataStore,
99
+ view: MutablePropertyView,
100
+ editor: StoreEditor,
101
+ placementId: number | null,
102
+ ): Xform2D {
103
+ if (placementId === null) return IDENTITY_XFORM2D;
104
+ const attrs = readAttributes(dataStore, view, editor, placementId);
105
+ if (!attrs) return IDENTITY_XFORM2D;
106
+
107
+ // IfcAxis2Placement3D: [0] Location · [1] Axis (Z) · [2] RefDirection (X).
108
+ const locId = asExpressIdRef(attrs[0]);
109
+ let ox = 0;
110
+ let oy = 0;
111
+ if (locId !== null) {
112
+ const locAttrs = readAttributes(dataStore, view, editor, locId);
113
+ const c = locAttrs ? asCoordinateTriple(locAttrs[0]) : null;
114
+ if (c) {
115
+ ox = c[0];
116
+ oy = c[1];
117
+ }
118
+ }
119
+
120
+ // IfcDirection ratios are NOT guaranteed unit length, so normalise Z
121
+ // before using it as a basis vector — otherwise the Gram-Schmidt
122
+ // projection (which assumes |Z|=1) and Y = Z × X both pick up |Z| as a
123
+ // stray scale factor, skewing the footprint away from the rendered mesh
124
+ // for files with e.g. Axis=(0,0,2). The Rust profile extractor
125
+ // normalises the same placement.
126
+ const rawZ = readDirection(dataStore, view, editor, asExpressIdRef(attrs[1])) ?? [0, 0, 1];
127
+ const zlen = Math.hypot(rawZ[0], rawZ[1], rawZ[2]);
128
+ if (zlen < 1e-9) return IDENTITY_XFORM2D;
129
+ const z: [number, number, number] = [rawZ[0] / zlen, rawZ[1] / zlen, rawZ[2] / zlen];
130
+ const refX = readDirection(dataStore, view, editor, asExpressIdRef(attrs[2])) ?? [1, 0, 0];
131
+
132
+ // Orthonormalise X against the unit Z (Gram-Schmidt), then Y = Z × X.
133
+ const dot = refX[0] * z[0] + refX[1] * z[1] + refX[2] * z[2];
134
+ let xv: [number, number, number] = [
135
+ refX[0] - dot * z[0],
136
+ refX[1] - dot * z[1],
137
+ refX[2] - dot * z[2],
138
+ ];
139
+ const xlen = Math.hypot(xv[0], xv[1], xv[2]);
140
+ if (xlen < 1e-9) return IDENTITY_XFORM2D;
141
+ xv = [xv[0] / xlen, xv[1] / xlen, xv[2] / xlen];
142
+ // Z and X are now orthonormal, so Y = Z × X is already unit length.
143
+ const yv: [number, number, number] = [
144
+ z[1] * xv[2] - z[2] * xv[1],
145
+ z[2] * xv[0] - z[0] * xv[2],
146
+ z[0] * xv[1] - z[1] * xv[0],
147
+ ];
148
+
149
+ return (p) => [ox + p[0] * xv[0] + p[1] * yv[0], oy + p[0] * xv[1] + p[1] * yv[1]];
150
+ }
151
+
64
152
  export interface SlabEditChain {
65
153
  /** STEP type name, for the slice's dispatch. */
66
154
  elementType: SlabLikeType;
@@ -106,22 +194,23 @@ function rectangleFootprint(
106
194
  profileOrigin2D: [number, number],
107
195
  xdim: number,
108
196
  ydim: number,
197
+ solidXform: Xform2D,
109
198
  ): Point2D[] {
110
- // The profile's local frame maps directly to storey-local XY for
111
- // a slab (no in-plane rotation; the builder writes Position +
112
- // null Axis + null RefDirection so the placement is axis-aligned).
199
+ // Rectangle corners in the profile coordinate system (centred on the
200
+ // profile origin), mapped through the solid Position into plan space,
201
+ // then offset by the slab's placement origin.
113
202
  const [px, py] = placementOrigin;
114
203
  const [cx, cy] = profileOrigin2D;
115
- const xMin = px + cx - xdim / 2;
116
- const xMax = px + cx + xdim / 2;
117
- const yMin = py + cy - ydim / 2;
118
- const yMax = py + cy + ydim / 2;
119
- return [
120
- [xMin, yMin],
121
- [xMax, yMin],
122
- [xMax, yMax],
123
- [xMin, yMax],
204
+ const corners: Point2D[] = [
205
+ [cx - xdim / 2, cy - ydim / 2],
206
+ [cx + xdim / 2, cy - ydim / 2],
207
+ [cx + xdim / 2, cy + ydim / 2],
208
+ [cx - xdim / 2, cy + ydim / 2],
124
209
  ];
210
+ return corners.map((c) => {
211
+ const [wx, wy] = solidXform(c);
212
+ return [px + wx, py + wy] as Point2D;
213
+ });
125
214
  }
126
215
 
127
216
  /**
@@ -136,6 +225,7 @@ function polylineFootprint(
136
225
  polylineId: number,
137
226
  placementOrigin: [number, number, number],
138
227
  profileOrigin2D: [number, number],
228
+ solidXform: Xform2D,
139
229
  ): Point2D[] | null {
140
230
  const attrs = readAttributes(dataStore, view, editor, polylineId);
141
231
  if (!attrs) return null;
@@ -155,7 +245,10 @@ function polylineFootprint(
155
245
  // tolerantly — IFC files in the wild sometimes pad with Z=0.
156
246
  const coords = asCoordinateTriple(ptAttrs[0]);
157
247
  if (!coords) return null;
158
- out.push([px + cx + coords[0], py + cy + coords[1]]);
248
+ // Point in profile CS solid plan (Position translation + rotation)
249
+ // → slab placement origin.
250
+ const [wx, wy] = solidXform([cx + coords[0], cy + coords[1]]);
251
+ out.push([px + wx, py + wy]);
159
252
  }
160
253
  // IfcPolyline for a closed profile may or may not repeat the
161
254
  // first vertex at the end — strip if present, our clip API
@@ -170,22 +263,57 @@ function polylineFootprint(
170
263
  return out.length >= 3 ? out : null;
171
264
  }
172
265
 
266
+ /**
267
+ * Scale a chain's coordinate-bearing fields (footprint, placement
268
+ * origin, thickness) by `scale`. Identity when `scale === 1`. Used to
269
+ * lift a native-unit (e.g. millimetre) STEP read into the viewer's
270
+ * metre working space — see `resolveSlabEditChain`'s `lengthUnitScale`.
271
+ */
272
+ function scaleSlabChain(chain: SlabEditChain, scale: number): SlabEditChain {
273
+ if (scale === 1) return chain;
274
+ return {
275
+ ...chain,
276
+ placementOrigin: [
277
+ chain.placementOrigin[0] * scale,
278
+ chain.placementOrigin[1] * scale,
279
+ chain.placementOrigin[2] * scale,
280
+ ],
281
+ footprint: chain.footprint.map(([x, y]) => [x * scale, y * scale] as Point2D),
282
+ thickness: chain.thickness * scale,
283
+ };
284
+ }
285
+
173
286
  /**
174
287
  * Resolve the slab chain (placement + footprint + extrusion). Works
175
288
  * for IfcSlab / IfcRoof / IfcPlate / IfcSpace whose representation
176
289
  * matches the in-store builder shape; null otherwise.
290
+ *
291
+ * `lengthUnitScale` is the model's native-unit → metre factor (e.g.
292
+ * `0.001` for a millimetre file). Raw STEP coordinate reads are in
293
+ * native units, but the rest of the split flow — raycast cut points,
294
+ * preview meshes, selection hit-tests — lives in metres, so the
295
+ * resolved footprint/thickness are scaled to match. Authored overlay
296
+ * entities are skipped: the in-store builders already emit metres, so
297
+ * scaling them would double-apply (re-splitting a freshly-cut half).
177
298
  */
178
299
  export function resolveSlabEditChain(
179
300
  dataStore: IfcDataStore,
180
301
  view: MutablePropertyView,
181
302
  editor: StoreEditor,
182
303
  expressId: number,
304
+ lengthUnitScale = 1,
183
305
  ): SlabEditChain | null {
184
306
  const rawType = readEntityType(dataStore, view, editor, expressId);
185
307
  if (!rawType) return null;
186
308
  const elementType = stepTypeToSlabLike(rawType);
187
309
  if (!elementType) return null;
188
310
 
311
+ // Overlay (authored) entities are stored in metres by the in-store
312
+ // builders; only native STEP reads need the unit scale applied.
313
+ // `getNewEntity` returns null (not undefined) for source entities.
314
+ const isAuthored = editor.getNewEntity(expressId) != null;
315
+ const scale = isAuthored ? 1 : lengthUnitScale;
316
+
189
317
  const chain = resolvePlacementChain(dataStore, view, editor, expressId);
190
318
  if (!chain) return null;
191
319
  const placementOrigin = chain.coordinates;
@@ -212,6 +340,18 @@ export function resolveSlabEditChain(
212
340
  const thicknessRaw = solidAttrs[3];
213
341
  if (profileId === null || typeof thicknessRaw !== 'number') return null;
214
342
 
343
+ // IfcExtrudedAreaSolid.Position (attr 1) is an IfcAxis2Placement3D that
344
+ // places the profile in the solid's frame — real authoring tools bake
345
+ // the slab's plan offset + rotation here (in-store-built slabs leave it
346
+ // identity). Fold it into the footprint so the preview, cut line, and
347
+ // resulting halves land where the rendered mesh actually is.
348
+ const solidXform = resolveSolidPositionXform(
349
+ dataStore,
350
+ view,
351
+ editor,
352
+ asExpressIdRef(solidAttrs[1]),
353
+ );
354
+
215
355
  // Profile dispatch — rectangle vs polygon, both produced by
216
356
  // addSlabToStore. Source-buffer slabs with mapped representations,
217
357
  // I-shape profiles, etc. land in `null` here and the slice
@@ -244,29 +384,29 @@ export function resolveSlabEditChain(
244
384
  const xdim = profileAttrs[3];
245
385
  const ydim = profileAttrs[4];
246
386
  if (typeof xdim !== 'number' || typeof ydim !== 'number') return null;
247
- return {
387
+ return scaleSlabChain({
248
388
  elementType,
249
389
  placementOrigin,
250
- footprint: rectangleFootprint(placementOrigin, profileOrigin2D, xdim, ydim),
390
+ footprint: rectangleFootprint(placementOrigin, profileOrigin2D, xdim, ydim, solidXform),
251
391
  extrudedSolidId: solidId,
252
392
  thickness: thicknessRaw,
253
393
  profileKind: 'rectangle',
254
- };
394
+ }, scale);
255
395
  }
256
396
  if (profileType && profileType.toUpperCase() === 'IFCARBITRARYCLOSEDPROFILEDEF') {
257
397
  // OuterCurve at attr 2.
258
398
  const polylineId = asExpressIdRef(profileAttrs[2]);
259
399
  if (polylineId === null) return null;
260
- const fp = polylineFootprint(dataStore, view, editor, polylineId, placementOrigin, profileOrigin2D);
400
+ const fp = polylineFootprint(dataStore, view, editor, polylineId, placementOrigin, profileOrigin2D, solidXform);
261
401
  if (!fp) return null;
262
- return {
402
+ return scaleSlabChain({
263
403
  elementType,
264
404
  placementOrigin,
265
405
  footprint: fp,
266
406
  extrudedSolidId: solidId,
267
407
  thickness: thicknessRaw,
268
408
  profileKind: 'polygon',
269
- };
409
+ }, scale);
270
410
  }
271
411
  return null;
272
412
  }