@ifc-lite/viewer 1.17.4 → 1.18.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 +20 -17
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +630 -0
- package/DESKTOP_CONTRACT_VERSION +1 -1
- package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-Cm1QEk_R.js} +1 -1
- package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
- package/dist/assets/{exporters-ChAtBmlj.js → exporters-B_OBqIyD.js} +3479 -2845
- package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-xHHy-9DV.js} +1 -1
- package/dist/assets/ids-DQ5jY0E8.js +1 -0
- package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
- package/dist/assets/{index-Co8E2-FE.js → index-BKq-M3Mk.js} +55873 -40593
- package/dist/assets/index-COnQRuqY.css +1 -0
- package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-SHXiQwFW.js} +104 -104
- package/dist/assets/sandbox-jez21HtV.js +9627 -0
- package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-ncOQVNso.js} +1 -1
- package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-DyfBSB8z.js} +1 -1
- package/dist/index.html +8 -7
- package/index.html +1 -0
- package/package.json +13 -13
- package/src/App.tsx +16 -2
- package/src/apache-arrow.d.ts +30 -0
- package/src/components/viewer/AddElementPanel.tsx +758 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
- package/src/components/viewer/CesiumOverlay.tsx +62 -19
- package/src/components/viewer/ChatPanel.tsx +259 -93
- package/src/components/viewer/CommandPalette.tsx +56 -7
- package/src/components/viewer/EntityContextMenu.tsx +168 -4
- package/src/components/viewer/ExportChangesButton.tsx +25 -5
- package/src/components/viewer/ExportDialog.tsx +19 -1
- package/src/components/viewer/MainToolbar.tsx +73 -13
- package/src/components/viewer/PropertiesPanel.tsx +237 -23
- package/src/components/viewer/SearchInline.tsx +669 -0
- package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
- package/src/components/viewer/SearchModal.filter.tsx +514 -0
- package/src/components/viewer/SearchModal.text.tsx +388 -0
- package/src/components/viewer/SearchModal.tsx +235 -0
- package/src/components/viewer/SettingsPage.tsx +252 -101
- package/src/components/viewer/ThemeSwitch.tsx +63 -7
- package/src/components/viewer/ToolOverlays.tsx +5 -0
- package/src/components/viewer/ViewerLayout.tsx +25 -4
- package/src/components/viewer/Viewport.tsx +25 -3
- package/src/components/viewer/ViewportContainer.tsx +51 -64
- package/src/components/viewer/ViewportOverlays.tsx +5 -2
- package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
- package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
- package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
- package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
- package/src/components/viewer/chat/ModelSelector.tsx +90 -54
- package/src/components/viewer/lists/ListPanel.tsx +14 -21
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
- package/src/components/viewer/properties/LocationMap.tsx +9 -7
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
- package/src/components/viewer/properties/RawStepCard.tsx +332 -0
- package/src/components/viewer/properties/RawStepRow.tsx +261 -0
- package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
- package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
- package/src/components/viewer/properties/raw-step-format.ts +193 -0
- package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
- package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
- package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
- package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
- package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
- package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
- package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
- package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
- package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
- package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
- package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
- package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
- package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
- package/src/components/viewer/schedule/generate-schedule.ts +648 -0
- package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
- package/src/components/viewer/schedule/schedule-animator.ts +488 -0
- package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
- package/src/components/viewer/schedule/schedule-selection.ts +163 -0
- package/src/components/viewer/schedule/schedule-utils.ts +223 -0
- package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
- package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
- package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
- package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
- package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
- package/src/components/viewer/selectionHandlers.ts +446 -0
- package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
- package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
- package/src/components/viewer/tools/SectionPanel.tsx +39 -18
- package/src/components/viewer/useAnimationLoop.ts +9 -1
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/components/viewer/useRenderUpdates.ts +1 -1
- package/src/hooks/ids/idsDataAccessor.ts +60 -24
- package/src/hooks/ingest/viewerModelIngest.ts +7 -2
- package/src/hooks/useIfcFederation.ts +326 -71
- package/src/hooks/useIfcLoader.ts +23 -10
- package/src/hooks/useKeyboardShortcuts.ts +25 -0
- package/src/hooks/useSandbox.ts +1 -1
- package/src/hooks/useSearchIndex.ts +125 -0
- package/src/hooks/useViewControls.ts +13 -5
- package/src/index.css +550 -10
- package/src/lib/desktop-entitlement.ts +2 -4
- package/src/lib/geo/cesium-bridge.ts +15 -7
- package/src/lib/geo/effective-georef.test.ts +73 -0
- package/src/lib/geo/effective-georef.ts +111 -0
- package/src/lib/geo/reproject.ts +105 -19
- package/src/lib/llm/byok-guard.test.ts +77 -0
- package/src/lib/llm/byok-guard.ts +39 -0
- package/src/lib/llm/free-models.test.ts +0 -6
- package/src/lib/llm/models.ts +104 -42
- package/src/lib/llm/stream-client.ts +74 -110
- package/src/lib/llm/stream-direct.test.ts +130 -0
- package/src/lib/llm/stream-direct.ts +316 -0
- package/src/lib/llm/system-prompt.test.ts +14 -0
- package/src/lib/llm/system-prompt.ts +102 -1
- package/src/lib/llm/types.ts +20 -2
- package/src/lib/recent-files.ts +38 -4
- package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
- package/src/lib/scripts/templates/construction-schedule.ts +223 -0
- package/src/lib/scripts/templates.ts +7 -0
- package/src/lib/search/common-ifc-types.ts +36 -0
- package/src/lib/search/filter-evaluate.test.ts +537 -0
- package/src/lib/search/filter-evaluate.ts +610 -0
- package/src/lib/search/filter-rules.test.ts +119 -0
- package/src/lib/search/filter-rules.ts +198 -0
- package/src/lib/search/filter-schema.test.ts +233 -0
- package/src/lib/search/filter-schema.ts +146 -0
- package/src/lib/search/recent-searches.test.ts +116 -0
- package/src/lib/search/recent-searches.ts +93 -0
- package/src/lib/search/result-export.test.ts +101 -0
- package/src/lib/search/result-export.ts +104 -0
- package/src/lib/search/saved-filters.test.ts +118 -0
- package/src/lib/search/saved-filters.ts +154 -0
- package/src/lib/search/tier0-scan.test.ts +196 -0
- package/src/lib/search/tier0-scan.ts +237 -0
- package/src/lib/search/tier1-index.test.ts +242 -0
- package/src/lib/search/tier1-index.ts +448 -0
- package/src/main.tsx +1 -10
- package/src/sdk/adapters/export-adapter.test.ts +434 -1
- package/src/sdk/adapters/export-adapter.ts +404 -1
- package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
- package/src/sdk/adapters/export-schedule-splice.ts +87 -0
- package/src/sdk/adapters/model-compat.ts +8 -2
- package/src/sdk/adapters/schedule-adapter.ts +73 -0
- package/src/sdk/adapters/store-adapter.ts +201 -0
- package/src/sdk/adapters/visibility-adapter.ts +3 -0
- package/src/sdk/local-backend.ts +16 -8
- package/src/services/api-keys.ts +73 -0
- package/src/services/desktop-export.ts +3 -1
- package/src/services/desktop-native-metadata.ts +41 -18
- package/src/services/file-dialog.ts +4 -1
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/basketVisibleSet.ts +3 -0
- package/src/store/constants.ts +20 -2
- package/src/store/globalId.ts +4 -1
- package/src/store/index.ts +82 -6
- package/src/store/slices/addElementMeshes.ts +365 -0
- package/src/store/slices/addElementSlice.ts +275 -0
- package/src/store/slices/annotationsSlice.test.ts +133 -0
- package/src/store/slices/annotationsSlice.ts +251 -0
- package/src/store/slices/cesiumSlice.ts +5 -0
- package/src/store/slices/chatSlice.test.ts +6 -76
- package/src/store/slices/chatSlice.ts +17 -58
- package/src/store/slices/dataSlice.test.ts +23 -4
- package/src/store/slices/dataSlice.ts +1 -1
- package/src/store/slices/modelSlice.test.ts +67 -9
- package/src/store/slices/modelSlice.ts +39 -7
- package/src/store/slices/mutationSlice.ts +964 -3
- package/src/store/slices/overlayCompositor.test.ts +164 -0
- package/src/store/slices/overlaySlice.test.ts +93 -0
- package/src/store/slices/overlaySlice.ts +151 -0
- package/src/store/slices/pinboardSlice.test.ts +6 -1
- package/src/store/slices/playbackSlice.ts +128 -0
- package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
- package/src/store/slices/schedule-edit-helpers.ts +179 -0
- package/src/store/slices/scheduleSlice.test.ts +694 -0
- package/src/store/slices/scheduleSlice.ts +1330 -0
- package/src/store/slices/searchSlice.test.ts +342 -0
- package/src/store/slices/searchSlice.ts +341 -0
- package/src/store/slices/sectionSlice.test.ts +87 -7
- package/src/store/slices/sectionSlice.ts +151 -5
- package/src/store/slices/selectionSlice.test.ts +46 -0
- package/src/store/slices/selectionSlice.ts +20 -0
- package/src/store/slices/uiSlice.ts +28 -5
- package/src/store/types.ts +26 -0
- package/src/store.ts +14 -0
- package/src/utils/nativeSpatialDataStore.ts +4 -1
- package/src/utils/viewportUtils.ts +7 -2
- package/src/vite-env.d.ts +0 -4
- package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
- package/dist/assets/ids-B4jTqB1O.js +0 -1
- package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
- package/dist/assets/index-DckuDqlv.css +0 -1
- package/dist/assets/sandbox-DZiNLNMk.js +0 -5933
- package/src/components/viewer/UpgradePage.tsx +0 -71
- package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
- package/src/lib/llm/ClerkChatSync.tsx +0 -74
- package/src/lib/llm/clerk-auth.ts +0 -62
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
import { describe, it } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import {
|
|
8
|
+
setOpMatches,
|
|
9
|
+
stringOpMatches,
|
|
10
|
+
numericOpMatches,
|
|
11
|
+
valueOpMatches,
|
|
12
|
+
combineRuleResults,
|
|
13
|
+
isFilterRule,
|
|
14
|
+
parseFilterRules,
|
|
15
|
+
Rule,
|
|
16
|
+
} from './filter-rules.js';
|
|
17
|
+
|
|
18
|
+
describe('setOpMatches', () => {
|
|
19
|
+
it('matches case-insensitively for "in"', () => {
|
|
20
|
+
assert.strictEqual(setOpMatches('in', 'IfcWall', ['ifcwall', 'IfcDoor']), true);
|
|
21
|
+
assert.strictEqual(setOpMatches('in', 'IfcSlab', ['IfcWall', 'IfcDoor']), false);
|
|
22
|
+
});
|
|
23
|
+
it('inverts for "notIn"', () => {
|
|
24
|
+
assert.strictEqual(setOpMatches('notIn', 'IfcSlab', ['IfcWall']), true);
|
|
25
|
+
assert.strictEqual(setOpMatches('notIn', 'IfcWall', ['IfcWall']), false);
|
|
26
|
+
});
|
|
27
|
+
it('treats an empty values list as no match for "in"', () => {
|
|
28
|
+
assert.strictEqual(setOpMatches('in', 'IfcWall', []), false);
|
|
29
|
+
assert.strictEqual(setOpMatches('notIn', 'IfcWall', []), true);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('stringOpMatches', () => {
|
|
34
|
+
it('eq / ne are case-insensitive', () => {
|
|
35
|
+
assert.strictEqual(stringOpMatches('eq', 'Foo', 'FOO'), true);
|
|
36
|
+
assert.strictEqual(stringOpMatches('ne', 'Foo', 'bar'), true);
|
|
37
|
+
assert.strictEqual(stringOpMatches('ne', 'Foo', 'foo'), false);
|
|
38
|
+
});
|
|
39
|
+
it('contains / notContains ignore case', () => {
|
|
40
|
+
assert.strictEqual(stringOpMatches('contains', 'Wall-EXT', 'ext'), true);
|
|
41
|
+
assert.strictEqual(stringOpMatches('notContains', 'Wall-EXT', 'int'), true);
|
|
42
|
+
});
|
|
43
|
+
it('startsWith ignores case', () => {
|
|
44
|
+
assert.strictEqual(stringOpMatches('startsWith', 'IfcWallStandardCase', 'ifcwall'), true);
|
|
45
|
+
assert.strictEqual(stringOpMatches('startsWith', 'IfcWall', 'wall'), false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('numericOpMatches', () => {
|
|
50
|
+
it('eq uses 1e-9 epsilon (matches Rust impl)', () => {
|
|
51
|
+
assert.strictEqual(numericOpMatches('eq', 1.0 + 1e-12, 1.0), true);
|
|
52
|
+
assert.strictEqual(numericOpMatches('eq', 1.0 + 1e-7, 1.0), false);
|
|
53
|
+
});
|
|
54
|
+
it('gt/gte/lt/lte are exact', () => {
|
|
55
|
+
assert.strictEqual(numericOpMatches('gt', 5, 5), false);
|
|
56
|
+
assert.strictEqual(numericOpMatches('gte', 5, 5), true);
|
|
57
|
+
assert.strictEqual(numericOpMatches('lt', 5, 5), false);
|
|
58
|
+
assert.strictEqual(numericOpMatches('lte', 5, 5), true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('valueOpMatches', () => {
|
|
63
|
+
it('isSet / isNotSet check string presence', () => {
|
|
64
|
+
assert.strictEqual(valueOpMatches('isSet', 'foo', ''), true);
|
|
65
|
+
assert.strictEqual(valueOpMatches('isSet', '', ''), false);
|
|
66
|
+
assert.strictEqual(valueOpMatches('isNotSet', '', ''), true);
|
|
67
|
+
});
|
|
68
|
+
it('eq / ne / contains pass through case-insensitive', () => {
|
|
69
|
+
assert.strictEqual(valueOpMatches('eq', 'Concrete', 'concrete'), true);
|
|
70
|
+
assert.strictEqual(valueOpMatches('contains', 'C30/37', '30'), true);
|
|
71
|
+
assert.strictEqual(valueOpMatches('notContains', 'C30/37', '50'), true);
|
|
72
|
+
});
|
|
73
|
+
it('numeric ops parse both sides as floats; NaN parses fail closed', () => {
|
|
74
|
+
assert.strictEqual(valueOpMatches('gt', '12.5', '10'), true);
|
|
75
|
+
assert.strictEqual(valueOpMatches('lt', '12.5', '10'), false);
|
|
76
|
+
assert.strictEqual(valueOpMatches('gt', 'abc', '10'), false);
|
|
77
|
+
assert.strictEqual(valueOpMatches('gt', '12', 'abc'), false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('combineRuleResults', () => {
|
|
82
|
+
it('AND requires all true', () => {
|
|
83
|
+
assert.strictEqual(combineRuleResults('AND', [true, true]), true);
|
|
84
|
+
assert.strictEqual(combineRuleResults('AND', [true, false]), false);
|
|
85
|
+
});
|
|
86
|
+
it('OR requires any true', () => {
|
|
87
|
+
assert.strictEqual(combineRuleResults('OR', [false, true]), true);
|
|
88
|
+
assert.strictEqual(combineRuleResults('OR', [false, false]), false);
|
|
89
|
+
});
|
|
90
|
+
it('returns false on an empty list (no rule = no match)', () => {
|
|
91
|
+
assert.strictEqual(combineRuleResults('AND', []), false);
|
|
92
|
+
assert.strictEqual(combineRuleResults('OR', []), false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('isFilterRule / parseFilterRules', () => {
|
|
97
|
+
it('accepts every known kind', () => {
|
|
98
|
+
assert.strictEqual(isFilterRule(Rule.storey(['L1'])), true);
|
|
99
|
+
assert.strictEqual(isFilterRule(Rule.ifcType(['IfcWall'])), true);
|
|
100
|
+
assert.strictEqual(isFilterRule(Rule.predefinedType(['SOLID'])), true);
|
|
101
|
+
assert.strictEqual(isFilterRule(Rule.name('contains', 'wall')), true);
|
|
102
|
+
assert.strictEqual(isFilterRule(Rule.property('Pset_X', 'P', 'eq', 'v')), true);
|
|
103
|
+
assert.strictEqual(isFilterRule(Rule.quantity('Qto_X', 'Q', 'gt', 1)), true);
|
|
104
|
+
});
|
|
105
|
+
it('rejects unknown kinds and non-objects', () => {
|
|
106
|
+
assert.strictEqual(isFilterRule({ kind: 'bogus' }), false);
|
|
107
|
+
assert.strictEqual(isFilterRule(null), false);
|
|
108
|
+
assert.strictEqual(isFilterRule('storey'), false);
|
|
109
|
+
});
|
|
110
|
+
it('parseFilterRules drops invalid entries', () => {
|
|
111
|
+
const parsed = parseFilterRules([
|
|
112
|
+
{ kind: 'ifcType', values: ['IfcWall'], op: 'in' },
|
|
113
|
+
{ kind: 'unknown' },
|
|
114
|
+
'nope',
|
|
115
|
+
]);
|
|
116
|
+
assert.strictEqual(parsed.length, 1);
|
|
117
|
+
assert.strictEqual(parsed[0].kind, 'ifcType');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
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
|
+
* Unified filter rule taxonomy.
|
|
7
|
+
*
|
|
8
|
+
* Ported from the Tauri-side `filter.rs` engine and consumed by the
|
|
9
|
+
* in-memory path-B runtime evaluator (`filter-evaluate.ts`). The
|
|
10
|
+
* discriminated-union shape lets the chip UI serialise any rule as a
|
|
11
|
+
* JSON object with a `"kind"` discriminator, mirroring serde's tagged
|
|
12
|
+
* enum encoding. We use `kind` rather than `type` because `type`
|
|
13
|
+
* collides with the IFC `type` attribute name on element rows.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ── Operator enums ────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** Set-membership: storey, ifcType, predefinedType. */
|
|
19
|
+
export type SetOp = 'in' | 'notIn';
|
|
20
|
+
|
|
21
|
+
/** String comparisons (Name rule). */
|
|
22
|
+
export type StringOp = 'eq' | 'ne' | 'contains' | 'notContains' | 'startsWith';
|
|
23
|
+
|
|
24
|
+
/** Numeric comparisons (Quantity rule). */
|
|
25
|
+
export type NumericOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte';
|
|
26
|
+
|
|
27
|
+
/** Mixed string+numeric+presence ops for Property values. */
|
|
28
|
+
export type ValueOp =
|
|
29
|
+
| 'eq'
|
|
30
|
+
| 'ne'
|
|
31
|
+
| 'gt'
|
|
32
|
+
| 'gte'
|
|
33
|
+
| 'lt'
|
|
34
|
+
| 'lte'
|
|
35
|
+
| 'contains'
|
|
36
|
+
| 'notContains'
|
|
37
|
+
| 'isSet'
|
|
38
|
+
| 'isNotSet';
|
|
39
|
+
|
|
40
|
+
/** Top-level rule combinator. */
|
|
41
|
+
export type Combinator = 'AND' | 'OR';
|
|
42
|
+
|
|
43
|
+
// ── Rule discriminated union ──────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export interface StoreyRule {
|
|
46
|
+
kind: 'storey';
|
|
47
|
+
values: string[];
|
|
48
|
+
op: SetOp;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface IfcTypeRule {
|
|
52
|
+
kind: 'ifcType';
|
|
53
|
+
values: string[];
|
|
54
|
+
op: SetOp;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PredefinedTypeRule {
|
|
58
|
+
kind: 'predefinedType';
|
|
59
|
+
values: string[];
|
|
60
|
+
op: SetOp;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface NameRule {
|
|
64
|
+
kind: 'name';
|
|
65
|
+
op: StringOp;
|
|
66
|
+
value: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface PropertyRule {
|
|
70
|
+
kind: 'property';
|
|
71
|
+
setName: string;
|
|
72
|
+
propertyName: string;
|
|
73
|
+
op: ValueOp;
|
|
74
|
+
/** Raw user input. Numeric ops parse as f64; isSet/isNotSet ignore. */
|
|
75
|
+
value: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface QuantityRule {
|
|
79
|
+
kind: 'quantity';
|
|
80
|
+
setName: string;
|
|
81
|
+
quantityName: string;
|
|
82
|
+
op: NumericOp;
|
|
83
|
+
value: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type FilterRule =
|
|
87
|
+
| StoreyRule
|
|
88
|
+
| IfcTypeRule
|
|
89
|
+
| PredefinedTypeRule
|
|
90
|
+
| NameRule
|
|
91
|
+
| PropertyRule
|
|
92
|
+
| QuantityRule;
|
|
93
|
+
|
|
94
|
+
// ── Pure op helpers (ported verbatim from filter.rs) ──────────────────────────
|
|
95
|
+
|
|
96
|
+
export function setOpMatches(op: SetOp, candidate: string, values: readonly string[]): boolean {
|
|
97
|
+
const hit = values.some((v) => v.toLowerCase() === candidate.toLowerCase());
|
|
98
|
+
return op === 'in' ? hit : !hit;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function stringOpMatches(op: StringOp, candidate: string, value: string): boolean {
|
|
102
|
+
const a = candidate.toLowerCase();
|
|
103
|
+
const b = value.toLowerCase();
|
|
104
|
+
switch (op) {
|
|
105
|
+
case 'eq': return a === b;
|
|
106
|
+
case 'ne': return a !== b;
|
|
107
|
+
case 'contains': return a.includes(b);
|
|
108
|
+
case 'notContains': return !a.includes(b);
|
|
109
|
+
case 'startsWith': return a.startsWith(b);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function numericOpMatches(op: NumericOp, candidate: number, value: number): boolean {
|
|
114
|
+
// The Rust side uses 1e-9 as the epsilon for eq/ne. Match it here for
|
|
115
|
+
// IDS-style parity — IFC quantities are stored as IFC4 IfcReal so the
|
|
116
|
+
// tolerance is large enough to absorb f32→f64 rounding from the parser.
|
|
117
|
+
const EPS = 1e-9;
|
|
118
|
+
switch (op) {
|
|
119
|
+
case 'eq': return Math.abs(candidate - value) < EPS;
|
|
120
|
+
case 'ne': return Math.abs(candidate - value) >= EPS;
|
|
121
|
+
case 'gt': return candidate > value;
|
|
122
|
+
case 'gte': return candidate >= value;
|
|
123
|
+
case 'lt': return candidate < value;
|
|
124
|
+
case 'lte': return candidate <= value;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Evaluate a Property ValueOp against the candidate's raw stringified
|
|
130
|
+
* value. `isSet`/`isNotSet` are presence checks and the property layer
|
|
131
|
+
* (filter-evaluate.ts) decides them before calling here — but we still
|
|
132
|
+
* accept them so the function is total.
|
|
133
|
+
*/
|
|
134
|
+
export function valueOpMatches(op: ValueOp, psetVal: string, ruleVal: string): boolean {
|
|
135
|
+
switch (op) {
|
|
136
|
+
case 'isSet': return psetVal.length > 0;
|
|
137
|
+
case 'isNotSet': return psetVal.length === 0;
|
|
138
|
+
case 'eq': return psetVal.toLowerCase() === ruleVal.toLowerCase();
|
|
139
|
+
case 'ne': return psetVal.toLowerCase() !== ruleVal.toLowerCase();
|
|
140
|
+
case 'contains': return psetVal.toLowerCase().includes(ruleVal.toLowerCase());
|
|
141
|
+
case 'notContains': return !psetVal.toLowerCase().includes(ruleVal.toLowerCase());
|
|
142
|
+
case 'gt':
|
|
143
|
+
case 'gte':
|
|
144
|
+
case 'lt':
|
|
145
|
+
case 'lte': {
|
|
146
|
+
const cv = Number.parseFloat(psetVal);
|
|
147
|
+
const rv = Number.parseFloat(ruleVal);
|
|
148
|
+
if (!Number.isFinite(cv) || !Number.isFinite(rv)) return false;
|
|
149
|
+
return numericOpMatches(op, cv, rv);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Combinator helpers ────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/** Combine an array of per-rule booleans according to AND/OR semantics. */
|
|
157
|
+
export function combineRuleResults(combinator: Combinator, results: readonly boolean[]): boolean {
|
|
158
|
+
if (results.length === 0) return false;
|
|
159
|
+
return combinator === 'AND' ? results.every((r) => r) : results.some((r) => r);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Convenience constructors ──────────────────────────────────────────────────
|
|
163
|
+
//
|
|
164
|
+
// The chip UI builds rules via `set*` slice actions (see searchSlice.ts);
|
|
165
|
+
// these helpers exist primarily for tests and for code paths that synthesize
|
|
166
|
+
// rules from a different representation (URL state, presets).
|
|
167
|
+
|
|
168
|
+
export const Rule = {
|
|
169
|
+
storey: (values: string[], op: SetOp = 'in'): StoreyRule => ({ kind: 'storey', values, op }),
|
|
170
|
+
ifcType: (values: string[], op: SetOp = 'in'): IfcTypeRule => ({ kind: 'ifcType', values, op }),
|
|
171
|
+
predefinedType: (values: string[], op: SetOp = 'in'): PredefinedTypeRule =>
|
|
172
|
+
({ kind: 'predefinedType', values, op }),
|
|
173
|
+
name: (op: StringOp, value: string): NameRule => ({ kind: 'name', op, value }),
|
|
174
|
+
property: (setName: string, propertyName: string, op: ValueOp, value: string): PropertyRule =>
|
|
175
|
+
({ kind: 'property', setName, propertyName, op, value }),
|
|
176
|
+
quantity: (setName: string, quantityName: string, op: NumericOp, value: number): QuantityRule =>
|
|
177
|
+
({ kind: 'quantity', setName, quantityName, op, value }),
|
|
178
|
+
} as const;
|
|
179
|
+
|
|
180
|
+
// ── JSON guards ──────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
export function isFilterRule(value: unknown): value is FilterRule {
|
|
183
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
184
|
+
const kind = (value as { kind?: unknown }).kind;
|
|
185
|
+
return (
|
|
186
|
+
kind === 'storey' ||
|
|
187
|
+
kind === 'ifcType' ||
|
|
188
|
+
kind === 'predefinedType' ||
|
|
189
|
+
kind === 'name' ||
|
|
190
|
+
kind === 'property' ||
|
|
191
|
+
kind === 'quantity'
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function parseFilterRules(raw: unknown): FilterRule[] {
|
|
196
|
+
if (!Array.isArray(raw)) return [];
|
|
197
|
+
return raw.filter(isFilterRule);
|
|
198
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
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
|
+
import { describe, it } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import {
|
|
8
|
+
StringTable,
|
|
9
|
+
EntityTableBuilder,
|
|
10
|
+
PropertyTableBuilder,
|
|
11
|
+
QuantityTableBuilder,
|
|
12
|
+
PropertyValueType,
|
|
13
|
+
QuantityType,
|
|
14
|
+
IfcTypeEnum,
|
|
15
|
+
} from '@ifc-lite/data';
|
|
16
|
+
import type { SpatialHierarchy } from '@ifc-lite/data';
|
|
17
|
+
import type { IfcDataStore } from '@ifc-lite/parser';
|
|
18
|
+
import { discoverFilterSchema, discoverPropertyAndQuantitySchema } from './filter-schema.js';
|
|
19
|
+
|
|
20
|
+
interface EntityRow {
|
|
21
|
+
expressId: number;
|
|
22
|
+
type: string;
|
|
23
|
+
globalId: string;
|
|
24
|
+
name: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build an in-memory IfcDataStore that exercises the schema discovery
|
|
29
|
+
* fallbacks: empty `source` so the on-demand extractors short-circuit
|
|
30
|
+
* to the pre-computed PropertyTable / QuantityTable lookup.
|
|
31
|
+
*/
|
|
32
|
+
function buildStore(args: {
|
|
33
|
+
entities: EntityRow[];
|
|
34
|
+
storeys?: Array<{ id: number; name: string; elevation?: number }>;
|
|
35
|
+
elementToStorey?: Array<[number, number]>;
|
|
36
|
+
psetRows?: Array<{ entityId: number; psetName: string; propName: string }>;
|
|
37
|
+
qtoRows?: Array<{ entityId: number; qsetName: string; quantityName: string }>;
|
|
38
|
+
}): IfcDataStore {
|
|
39
|
+
const strings = new StringTable();
|
|
40
|
+
const builder = new EntityTableBuilder(args.entities.length, strings);
|
|
41
|
+
for (const r of args.entities) {
|
|
42
|
+
builder.add(r.expressId, r.type, r.globalId, r.name, '', '', false, false);
|
|
43
|
+
}
|
|
44
|
+
const entities = builder.build();
|
|
45
|
+
|
|
46
|
+
const propertyBuilder = new PropertyTableBuilder(strings);
|
|
47
|
+
for (const r of args.psetRows ?? []) {
|
|
48
|
+
propertyBuilder.add({
|
|
49
|
+
entityId: r.entityId,
|
|
50
|
+
psetName: r.psetName,
|
|
51
|
+
psetGlobalId: '',
|
|
52
|
+
propName: r.propName,
|
|
53
|
+
propType: PropertyValueType.String,
|
|
54
|
+
value: '',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const properties = propertyBuilder.build();
|
|
58
|
+
|
|
59
|
+
const quantityBuilder = new QuantityTableBuilder(strings);
|
|
60
|
+
for (const r of args.qtoRows ?? []) {
|
|
61
|
+
quantityBuilder.add({
|
|
62
|
+
entityId: r.entityId,
|
|
63
|
+
qsetName: r.qsetName,
|
|
64
|
+
quantityName: r.quantityName,
|
|
65
|
+
quantityType: QuantityType.Length,
|
|
66
|
+
value: 0,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const quantities = quantityBuilder.build();
|
|
70
|
+
|
|
71
|
+
const byType = new Map<string, number[]>();
|
|
72
|
+
for (const r of args.entities) {
|
|
73
|
+
const upper = r.type.toUpperCase();
|
|
74
|
+
let bucket = byType.get(upper);
|
|
75
|
+
if (!bucket) { bucket = []; byType.set(upper, bucket); }
|
|
76
|
+
bucket.push(r.expressId);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let spatialHierarchy: SpatialHierarchy | undefined;
|
|
80
|
+
if (args.storeys && args.storeys.length > 0) {
|
|
81
|
+
const byStorey = new Map<number, number[]>();
|
|
82
|
+
const storeyElevations = new Map<number, number>();
|
|
83
|
+
const elementToStorey = new Map<number, number>(args.elementToStorey ?? []);
|
|
84
|
+
for (const s of args.storeys) {
|
|
85
|
+
byStorey.set(s.id, []);
|
|
86
|
+
if (s.elevation !== undefined) storeyElevations.set(s.id, s.elevation);
|
|
87
|
+
}
|
|
88
|
+
spatialHierarchy = {
|
|
89
|
+
project: { expressId: 1, type: IfcTypeEnum.IfcProject, name: '', children: [], elements: [] },
|
|
90
|
+
byStorey,
|
|
91
|
+
byBuilding: new Map(),
|
|
92
|
+
bySite: new Map(),
|
|
93
|
+
bySpace: new Map(),
|
|
94
|
+
storeyElevations,
|
|
95
|
+
storeyHeights: new Map(),
|
|
96
|
+
elementToStorey,
|
|
97
|
+
getStoreyElements: () => [],
|
|
98
|
+
getStoreyByElevation: () => null,
|
|
99
|
+
getContainingSpace: () => null,
|
|
100
|
+
getPath: () => [],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Build expressId → entityId-list map for properties / quantities so
|
|
105
|
+
// the on-demand maps have the right shape.
|
|
106
|
+
const onDemandPropertyMap = new Map<number, number[]>();
|
|
107
|
+
for (const r of args.psetRows ?? []) {
|
|
108
|
+
const list = onDemandPropertyMap.get(r.entityId) ?? [];
|
|
109
|
+
list.push(r.entityId); // dummy pset id; the value is unused on the
|
|
110
|
+
// PropertyTable fallback path because source=''
|
|
111
|
+
onDemandPropertyMap.set(r.entityId, list);
|
|
112
|
+
}
|
|
113
|
+
const onDemandQuantityMap = new Map<number, number[]>();
|
|
114
|
+
for (const r of args.qtoRows ?? []) {
|
|
115
|
+
const list = onDemandQuantityMap.get(r.entityId) ?? [];
|
|
116
|
+
list.push(r.entityId);
|
|
117
|
+
onDemandQuantityMap.set(r.entityId, list);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
fileSize: 0,
|
|
122
|
+
schemaVersion: 'IFC4',
|
|
123
|
+
entityCount: args.entities.length,
|
|
124
|
+
parseTime: 0,
|
|
125
|
+
source: new Uint8Array(0),
|
|
126
|
+
entityIndex: { byId: { ranges: new Uint32Array(0), index: new Map() }, byType },
|
|
127
|
+
strings,
|
|
128
|
+
entities,
|
|
129
|
+
properties,
|
|
130
|
+
quantities,
|
|
131
|
+
relationships: { count: 0 } as unknown as IfcDataStore['relationships'],
|
|
132
|
+
spatialHierarchy,
|
|
133
|
+
onDemandPropertyMap,
|
|
134
|
+
onDemandQuantityMap,
|
|
135
|
+
} as unknown as IfcDataStore;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
describe('discoverFilterSchema — basic pass', () => {
|
|
139
|
+
it('returns an empty schema when no models / no spatial hierarchy', () => {
|
|
140
|
+
const store = buildStore({ entities: [] });
|
|
141
|
+
const schema = discoverFilterSchema(store);
|
|
142
|
+
assert.deepStrictEqual(schema.storeys, []);
|
|
143
|
+
assert.deepStrictEqual(schema.ifcTypes, []);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('collects unique IFC types in canonical PascalCase, sorted', () => {
|
|
147
|
+
const store = buildStore({
|
|
148
|
+
entities: [
|
|
149
|
+
{ expressId: 1, type: 'IFCWALL', globalId: '', name: '' },
|
|
150
|
+
{ expressId: 2, type: 'IFCWALL', globalId: '', name: '' },
|
|
151
|
+
{ expressId: 3, type: 'IFCDOOR', globalId: '', name: '' },
|
|
152
|
+
{ expressId: 4, type: 'IFCWINDOW', globalId: '', name: '' },
|
|
153
|
+
],
|
|
154
|
+
});
|
|
155
|
+
const schema = discoverFilterSchema(store);
|
|
156
|
+
assert.deepStrictEqual(schema.ifcTypes, ['IfcDoor', 'IfcWall', 'IfcWindow']);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('collects storeys with elevations sorted by name, deduped', () => {
|
|
160
|
+
const store = buildStore({
|
|
161
|
+
entities: [
|
|
162
|
+
{ expressId: 100, type: 'IFCBUILDINGSTOREY', globalId: '', name: 'Level 1' },
|
|
163
|
+
{ expressId: 101, type: 'IFCBUILDINGSTOREY', globalId: '', name: 'Level 2' },
|
|
164
|
+
{ expressId: 102, type: 'IFCBUILDINGSTOREY', globalId: '', name: 'Level 1' /* duplicate name */ },
|
|
165
|
+
],
|
|
166
|
+
storeys: [
|
|
167
|
+
{ id: 100, name: 'Level 1', elevation: 0.0 },
|
|
168
|
+
{ id: 101, name: 'Level 2', elevation: 3.0 },
|
|
169
|
+
{ id: 102, name: 'Level 1', elevation: 0.0 },
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
const schema = discoverFilterSchema(store);
|
|
173
|
+
// Two unique names; "Level 1" is kept once.
|
|
174
|
+
assert.strictEqual(schema.storeys.length, 2);
|
|
175
|
+
assert.deepStrictEqual(schema.storeys.map(([n]) => n), ['Level 1', 'Level 2']);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('treats missing elevation as null', () => {
|
|
179
|
+
const store = buildStore({
|
|
180
|
+
entities: [{ expressId: 10, type: 'IFCBUILDINGSTOREY', globalId: '', name: 'Roof' }],
|
|
181
|
+
storeys: [{ id: 10, name: 'Roof' /* no elevation */ }],
|
|
182
|
+
});
|
|
183
|
+
const schema = discoverFilterSchema(store);
|
|
184
|
+
assert.deepStrictEqual(schema.storeys, [['Roof', null]]);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('discoverPropertyAndQuantitySchema — fallback pass via PropertyTable', () => {
|
|
189
|
+
it('returns empty schema when no on-demand maps', () => {
|
|
190
|
+
const store = buildStore({ entities: [{ expressId: 1, type: 'IFCWALL', globalId: '', name: '' }] });
|
|
191
|
+
// Strip the on-demand maps for the test.
|
|
192
|
+
delete (store as { onDemandPropertyMap?: unknown }).onDemandPropertyMap;
|
|
193
|
+
delete (store as { onDemandQuantityMap?: unknown }).onDemandQuantityMap;
|
|
194
|
+
const schema = discoverPropertyAndQuantitySchema(store);
|
|
195
|
+
assert.deepStrictEqual(schema.psets, []);
|
|
196
|
+
assert.deepStrictEqual(schema.qtos, []);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('harvests pset → property names from the on-demand iteration', () => {
|
|
200
|
+
const store = buildStore({
|
|
201
|
+
entities: [
|
|
202
|
+
{ expressId: 1, type: 'IFCWALL', globalId: '', name: '' },
|
|
203
|
+
{ expressId: 2, type: 'IFCDOOR', globalId: '', name: '' },
|
|
204
|
+
],
|
|
205
|
+
psetRows: [
|
|
206
|
+
{ entityId: 1, psetName: 'Pset_WallCommon', propName: 'IsExternal' },
|
|
207
|
+
{ entityId: 1, psetName: 'Pset_WallCommon', propName: 'LoadBearing' },
|
|
208
|
+
{ entityId: 2, psetName: 'Pset_DoorCommon', propName: 'FireRating' },
|
|
209
|
+
],
|
|
210
|
+
});
|
|
211
|
+
const schema = discoverPropertyAndQuantitySchema(store);
|
|
212
|
+
assert.deepStrictEqual(schema.psets.map(([n]) => n).sort(), ['Pset_DoorCommon', 'Pset_WallCommon']);
|
|
213
|
+
const wall = schema.psets.find(([n]) => n === 'Pset_WallCommon');
|
|
214
|
+
assert.deepStrictEqual(wall?.[1].sort(), ['IsExternal', 'LoadBearing']);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('harvests qto → quantity names with empty unit', () => {
|
|
218
|
+
const store = buildStore({
|
|
219
|
+
entities: [{ expressId: 1, type: 'IFCWALL', globalId: '', name: '' }],
|
|
220
|
+
qtoRows: [
|
|
221
|
+
{ entityId: 1, qsetName: 'Qto_WallBaseQuantities', quantityName: 'NetSideArea' },
|
|
222
|
+
{ entityId: 1, qsetName: 'Qto_WallBaseQuantities', quantityName: 'GrossVolume' },
|
|
223
|
+
],
|
|
224
|
+
});
|
|
225
|
+
const schema = discoverPropertyAndQuantitySchema(store);
|
|
226
|
+
assert.strictEqual(schema.qtos.length, 1);
|
|
227
|
+
const [qsetName, quantities] = schema.qtos[0];
|
|
228
|
+
assert.strictEqual(qsetName, 'Qto_WallBaseQuantities');
|
|
229
|
+
assert.deepStrictEqual(quantities.map(([n]) => n).sort(), ['GrossVolume', 'NetSideArea']);
|
|
230
|
+
// Unit is "" until on-demand extractors materialise it.
|
|
231
|
+
assert.ok(quantities.every(([, unit]) => unit === ''));
|
|
232
|
+
});
|
|
233
|
+
});
|