@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.
- package/.turbo/turbo-build.log +38 -31
- package/CHANGELOG.md +29 -0
- package/dist/assets/{basketViewActivator-ZpTYWE3K.js → basketViewActivator-B3CdrLsb.js} +7 -7
- package/dist/assets/{bcf-Ctcu_Sc2.js → bcf-QeHK_Aud.js} +1 -1
- package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
- package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
- package/dist/assets/{deflate-Cnx0il6E.js → deflate-B-d0SYQM.js} +1 -1
- package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
- package/dist/assets/{exporters-DSq76AVM.js → exporters-B4LbZFeT.js} +1422 -1194
- package/dist/assets/geometry.worker-BdH-E6NB.js +1 -0
- package/dist/assets/{geotiff-A5UjhI6L.js → geotiff-CrVtDRFq.js} +10 -10
- package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
- package/dist/assets/{ids-DiLcGTer.js → ids-DjsGFN10.js} +4 -4
- package/dist/assets/ifc-lite_bg-DsYUIHm3.wasm +0 -0
- package/dist/assets/{index-BAH8IJVR.js → index-COYokSKc.js} +38319 -35469
- package/dist/assets/index-ajK6D32J.css +1 -0
- package/dist/assets/index.es-CY202jA3.js +6866 -0
- package/dist/assets/{jpeg-BzSkwo5D.js → jpeg-D4wOkf5h.js} +1 -1
- package/dist/assets/jspdf.es.min-DIGb9BHN.js +19571 -0
- package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
- package/dist/assets/{lerc-Cg2Rz-D5.js → lerc-DmW0_tgf.js} +1 -1
- package/dist/assets/{lzw-BBPPLW-0.js → lzw-oWetY-d6.js} +1 -1
- package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
- package/dist/assets/{native-bridge-CPojOeGE.js → native-bridge-BX8_tHXE.js} +1 -1
- package/dist/assets/{packbits-yLSpjW-V.js → packbits-F8Nkp4NY.js} +1 -1
- package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
- package/dist/assets/{parser.worker-8md211IW.js → parser.worker-D591Zu_-.js} +3 -3
- package/dist/assets/pdf-Dsh3HPZB.js +135 -0
- package/dist/assets/raw-D9iw0tmc.js +1 -0
- package/dist/assets/{sandbox-CsRXlgCO.js → sandbox-BAC3a-eN.js} +1735 -1660
- package/dist/assets/server-client-Cjwnm7il.js +706 -0
- package/dist/assets/{webimage-YafxjjGr.js → webimage-BLV1dgmd.js} +1 -1
- package/dist/assets/xlsx-Bc2HTrjC.js +142 -0
- package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
- package/dist/assets/{zstd-CkSLOiuu.js → zstd-C_1HxVrA.js} +1 -1
- package/dist/index.html +8 -8
- package/package.json +10 -7
- package/src/components/mcp/PlaygroundChat.tsx +1 -0
- package/src/components/mcp/data.ts +6 -0
- package/src/components/mcp/playground-dispatcher.ts +277 -0
- package/src/components/mcp/types.ts +2 -1
- package/src/components/ui/combo-input.tsx +163 -0
- package/src/components/ui/tabs.tsx +1 -1
- package/src/components/viewer/PropertiesPanel.tsx +13 -6
- package/src/components/viewer/SearchInline.tsx +62 -2
- package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
- package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
- package/src/components/viewer/SearchModal.filter.tsx +64 -1
- package/src/components/viewer/SearchModal.tsx +19 -6
- package/src/components/viewer/Viewport.tsx +15 -0
- package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
- package/src/components/viewer/lists/ListBuilder.tsx +789 -280
- package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
- package/src/components/viewer/lists/ListPanel.tsx +49 -5
- package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
- package/src/components/viewer/lists/list-table-utils.ts +123 -0
- package/src/generated/mcp-catalog.json +4 -0
- package/src/hooks/source-key.ts +35 -0
- package/src/hooks/useAlignmentLines3D.ts +1 -26
- package/src/hooks/useGridLines3D.ts +140 -0
- package/src/lib/length-unit-scale.ts +41 -0
- package/src/lib/lists/adapter.ts +136 -11
- package/src/lib/lists/export/csv.ts +47 -0
- package/src/lib/lists/export/index.ts +49 -0
- package/src/lib/lists/export/model.ts +111 -0
- package/src/lib/lists/export/pdf.ts +67 -0
- package/src/lib/lists/export/xlsx.ts +83 -0
- package/src/lib/lists/index.ts +2 -0
- package/src/lib/search/filter-evaluate.test.ts +81 -0
- package/src/lib/search/filter-evaluate.ts +59 -87
- package/src/lib/search/filter-match.ts +167 -0
- package/src/lib/search/filter-rules.test.ts +25 -0
- package/src/lib/search/filter-rules.ts +75 -2
- package/src/lib/search/filter-schema.ts +0 -0
- package/src/lib/slab-edit.test.ts +72 -0
- package/src/lib/slab-edit.ts +159 -19
- package/src/sdk/adapters/export-adapter.ts +3 -3
- package/src/sdk/adapters/query-adapter.ts +3 -3
- package/src/store/slices/listSlice.ts +6 -0
- package/src/store/slices/mutationSlice.ts +14 -6
- package/src/store/slices/searchSlice.ts +29 -3
- package/src/utils/nativeSpatialDataStore.ts +6 -0
- package/src/utils/serverDataModel.test.ts +6 -0
- package/src/utils/serverDataModel.ts +7 -0
- package/dist/assets/geometry.worker-0Q9qEa6p.js +0 -1
- package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
- package/dist/assets/index-B9Ug2EqU.css +0 -1
- package/dist/assets/raw-BQrAgxwT.js +0 -1
- 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.
|
package/src/lib/slab-edit.ts
CHANGED
|
@@ -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
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
}
|