@ifc-lite/viewer 1.6.0 → 1.7.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 (95) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
  3. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  4. package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
  5. package/dist/assets/index-yTqs8kgX.css +1 -0
  6. package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
  7. package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.js} +1 -1
  8. package/dist/index.html +2 -2
  9. package/package.json +18 -15
  10. package/src/components/viewer/BCFPanel.tsx +7 -789
  11. package/src/components/viewer/Drawing2DCanvas.tsx +1048 -0
  12. package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
  13. package/src/components/viewer/HierarchyPanel.tsx +110 -842
  14. package/src/components/viewer/IDSExportDialog.tsx +281 -0
  15. package/src/components/viewer/IDSPanel.tsx +126 -17
  16. package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
  17. package/src/components/viewer/LensPanel.tsx +603 -0
  18. package/src/components/viewer/MainToolbar.tsx +188 -21
  19. package/src/components/viewer/PropertiesPanel.tsx +171 -663
  20. package/src/components/viewer/PropertyEditor.tsx +866 -77
  21. package/src/components/viewer/Section2DPanel.tsx +76 -2648
  22. package/src/components/viewer/ToolOverlays.tsx +3 -1097
  23. package/src/components/viewer/ViewerLayout.tsx +132 -45
  24. package/src/components/viewer/Viewport.tsx +237 -1659
  25. package/src/components/viewer/ViewportContainer.tsx +11 -3
  26. package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
  27. package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
  28. package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
  29. package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
  30. package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
  31. package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
  32. package/src/components/viewer/hierarchy/types.ts +54 -0
  33. package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
  34. package/src/components/viewer/lists/ListBuilder.tsx +486 -0
  35. package/src/components/viewer/lists/ListPanel.tsx +540 -0
  36. package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
  37. package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
  38. package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
  39. package/src/components/viewer/properties/DocumentCard.tsx +89 -0
  40. package/src/components/viewer/properties/MaterialCard.tsx +201 -0
  41. package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
  42. package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
  43. package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
  44. package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
  45. package/src/components/viewer/properties/encodingUtils.ts +29 -0
  46. package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
  47. package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
  48. package/src/components/viewer/tools/SectionPanel.tsx +183 -0
  49. package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
  50. package/src/components/viewer/tools/formatDistance.ts +18 -0
  51. package/src/components/viewer/tools/sectionConstants.ts +14 -0
  52. package/src/components/viewer/useAnimationLoop.ts +166 -0
  53. package/src/components/viewer/useGeometryStreaming.ts +398 -0
  54. package/src/components/viewer/useKeyboardControls.ts +221 -0
  55. package/src/components/viewer/useMouseControls.ts +1009 -0
  56. package/src/components/viewer/useRenderUpdates.ts +165 -0
  57. package/src/components/viewer/useTouchControls.ts +245 -0
  58. package/src/hooks/ids/idsColorSystem.ts +125 -0
  59. package/src/hooks/ids/idsDataAccessor.ts +237 -0
  60. package/src/hooks/ids/idsExportService.ts +444 -0
  61. package/src/hooks/useBCF.ts +7 -0
  62. package/src/hooks/useDrawingExport.ts +627 -0
  63. package/src/hooks/useDrawingGeneration.ts +627 -0
  64. package/src/hooks/useFloorplanView.ts +108 -0
  65. package/src/hooks/useIDS.ts +270 -463
  66. package/src/hooks/useIfc.ts +26 -1628
  67. package/src/hooks/useIfcFederation.ts +803 -0
  68. package/src/hooks/useIfcLoader.ts +508 -0
  69. package/src/hooks/useIfcServer.ts +465 -0
  70. package/src/hooks/useKeyboardShortcuts.ts +1 -1
  71. package/src/hooks/useLens.ts +129 -0
  72. package/src/hooks/useMeasure2D.ts +365 -0
  73. package/src/hooks/useViewControls.ts +218 -0
  74. package/src/lib/ifc4-pset-definitions.test.ts +161 -0
  75. package/src/lib/ifc4-pset-definitions.ts +621 -0
  76. package/src/lib/ifc4-qto-definitions.ts +315 -0
  77. package/src/lib/lens/adapter.ts +138 -0
  78. package/src/lib/lens/index.ts +5 -0
  79. package/src/lib/lists/adapter.ts +69 -0
  80. package/src/lib/lists/index.ts +28 -0
  81. package/src/lib/lists/persistence.ts +64 -0
  82. package/src/services/fs-cache.ts +1 -1
  83. package/src/services/tauri-modules.d.ts +25 -0
  84. package/src/store/index.ts +38 -2
  85. package/src/store/slices/cameraSlice.ts +14 -1
  86. package/src/store/slices/dataSlice.ts +14 -1
  87. package/src/store/slices/lensSlice.ts +184 -0
  88. package/src/store/slices/listSlice.ts +74 -0
  89. package/src/store/slices/pinboardSlice.ts +114 -0
  90. package/src/store/types.ts +5 -0
  91. package/src/utils/ifcConfig.ts +16 -3
  92. package/src/utils/serverDataModel.ts +64 -101
  93. package/src/vite-env.d.ts +3 -0
  94. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  95. package/dist/assets/index-v3mcCUPN.css +0 -1
@@ -0,0 +1,237 @@
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
+ * IDS Data Accessor Factory
7
+ *
8
+ * Creates an IFCDataAccessor bridge from an IfcDataStore to the IDS
9
+ * validator's expected interface. This is a pure function with no
10
+ * React dependencies.
11
+ */
12
+
13
+ import type {
14
+ IFCDataAccessor,
15
+ PropertyValueResult,
16
+ PropertySetInfo,
17
+ ClassificationInfo,
18
+ MaterialInfo,
19
+ ParentInfo,
20
+ PartOfRelation,
21
+ } from '@ifc-lite/ids';
22
+ import type { IfcDataStore } from '@ifc-lite/parser';
23
+
24
+ /**
25
+ * Create an IFCDataAccessor from an IfcDataStore
26
+ * This bridges the viewer's data store to the IDS validator's interface
27
+ */
28
+ export function createDataAccessor(
29
+ dataStore: IfcDataStore,
30
+ _modelId: string
31
+ ): IFCDataAccessor {
32
+ return {
33
+ getEntityType(expressId: number): string | undefined {
34
+ // Try entities table first
35
+ const entityType = dataStore.entities?.getTypeName?.(expressId);
36
+ if (entityType) return entityType;
37
+
38
+ // Fallback to entityIndex
39
+ const byId = dataStore.entityIndex?.byId;
40
+ if (byId) {
41
+ const entry = byId.get(expressId);
42
+ if (entry) {
43
+ return typeof entry === 'object' && 'type' in entry ? String(entry.type) : undefined;
44
+ }
45
+ }
46
+ return undefined;
47
+ },
48
+
49
+ getEntityName(expressId: number): string | undefined {
50
+ return dataStore.entities?.getName?.(expressId);
51
+ },
52
+
53
+ getGlobalId(expressId: number): string | undefined {
54
+ return dataStore.entities?.getGlobalId?.(expressId);
55
+ },
56
+
57
+ getDescription(expressId: number): string | undefined {
58
+ return dataStore.entities?.getDescription?.(expressId);
59
+ },
60
+
61
+ getObjectType(expressId: number): string | undefined {
62
+ return dataStore.entities?.getObjectType?.(expressId);
63
+ },
64
+
65
+ getEntitiesByType(typeName: string): number[] {
66
+ const byType = dataStore.entityIndex?.byType;
67
+ if (byType) {
68
+ const ids = byType.get(typeName.toUpperCase());
69
+ if (ids) return Array.from(ids);
70
+ }
71
+ return [];
72
+ },
73
+
74
+ getAllEntityIds(): number[] {
75
+ const byId = dataStore.entityIndex?.byId;
76
+ if (byId) {
77
+ return Array.from(byId.keys());
78
+ }
79
+ return [];
80
+ },
81
+
82
+ getPropertyValue(
83
+ expressId: number,
84
+ propertySetName: string,
85
+ propertyName: string
86
+ ): PropertyValueResult | undefined {
87
+ const propertiesStore = dataStore.properties;
88
+ if (!propertiesStore) return undefined;
89
+
90
+ // Get property sets for this entity using getForEntity (returns PropertySet[])
91
+ const psets = propertiesStore.getForEntity?.(expressId);
92
+ if (!psets) return undefined;
93
+
94
+ for (const pset of psets) {
95
+ if (pset.name.toLowerCase() === propertySetName.toLowerCase()) {
96
+ const props = pset.properties || [];
97
+ for (const prop of props) {
98
+ if (prop.name.toLowerCase() === propertyName.toLowerCase()) {
99
+ // Convert value: ensure it's a primitive type (not array)
100
+ let value: string | number | boolean | null = null;
101
+ if (Array.isArray(prop.value)) {
102
+ // For arrays, convert to string representation
103
+ value = JSON.stringify(prop.value);
104
+ } else {
105
+ value = prop.value as string | number | boolean | null;
106
+ }
107
+ return {
108
+ value,
109
+ dataType: String(prop.type || 'IFCLABEL'),
110
+ propertySetName: pset.name,
111
+ propertyName: prop.name,
112
+ };
113
+ }
114
+ }
115
+ }
116
+ }
117
+ return undefined;
118
+ },
119
+
120
+ getPropertySets(expressId: number): PropertySetInfo[] {
121
+ const propertiesStore = dataStore.properties;
122
+ if (!propertiesStore) return [];
123
+
124
+ // Use getForEntity (returns PropertySet[])
125
+ const psets = propertiesStore.getForEntity?.(expressId);
126
+ if (!psets) return [];
127
+
128
+ return psets.map((pset) => ({
129
+ name: pset.name,
130
+ properties: (pset.properties || []).map((prop) => {
131
+ // Convert value: ensure it's a primitive type (not array)
132
+ let value: string | number | boolean | null = null;
133
+ if (Array.isArray(prop.value)) {
134
+ value = JSON.stringify(prop.value);
135
+ } else {
136
+ value = prop.value as string | number | boolean | null;
137
+ }
138
+ return {
139
+ name: prop.name,
140
+ value,
141
+ dataType: String(prop.type || 'IFCLABEL'),
142
+ };
143
+ }),
144
+ }));
145
+ },
146
+
147
+ getClassifications(expressId: number): ClassificationInfo[] {
148
+ // Classifications might be stored separately or in properties
149
+ // This is a placeholder - implement based on actual data structure
150
+ const classifications: ClassificationInfo[] = [];
151
+
152
+ // Check if there's a classifications accessor
153
+ const classStore = (dataStore as { classifications?: { getForEntity?: (id: number) => ClassificationInfo[] } }).classifications;
154
+ if (classStore?.getForEntity) {
155
+ return classStore.getForEntity(expressId);
156
+ }
157
+
158
+ return classifications;
159
+ },
160
+
161
+ getMaterials(expressId: number): MaterialInfo[] {
162
+ // Materials might be stored separately or in relationships
163
+ const materials: MaterialInfo[] = [];
164
+
165
+ // Check if there's a materials accessor
166
+ const matStore = (dataStore as { materials?: { getForEntity?: (id: number) => MaterialInfo[] } }).materials;
167
+ if (matStore?.getForEntity) {
168
+ return matStore.getForEntity(expressId);
169
+ }
170
+
171
+ return materials;
172
+ },
173
+
174
+ getParent(
175
+ expressId: number,
176
+ relationType: PartOfRelation
177
+ ): ParentInfo | undefined {
178
+ const relationships = dataStore.relationships;
179
+ if (!relationships) return undefined;
180
+
181
+ // Map IDS relation type to internal relation type
182
+ const relationMap: Record<PartOfRelation, string> = {
183
+ 'IfcRelAggregates': 'Aggregates',
184
+ 'IfcRelContainedInSpatialStructure': 'ContainedInSpatialStructure',
185
+ 'IfcRelNests': 'Nests',
186
+ 'IfcRelVoidsElement': 'VoidsElement',
187
+ 'IfcRelFillsElement': 'FillsElement',
188
+ };
189
+
190
+ const relType = relationMap[relationType];
191
+ if (!relType) return undefined;
192
+
193
+ // Get related entities (parent direction)
194
+ const getRelated = relationships.getRelated;
195
+ if (getRelated) {
196
+ const parents = getRelated(expressId, relType as never, 'inverse');
197
+ if (parents && parents.length > 0) {
198
+ const parentId = parents[0];
199
+ return {
200
+ expressId: parentId,
201
+ entityType: this.getEntityType(parentId) || 'Unknown',
202
+ predefinedType: this.getObjectType(parentId),
203
+ };
204
+ }
205
+ }
206
+
207
+ return undefined;
208
+ },
209
+
210
+ getAttribute(expressId: number, attributeName: string): string | undefined {
211
+ const lowerName = attributeName.toLowerCase();
212
+
213
+ // Map common attribute names to accessor methods
214
+ switch (lowerName) {
215
+ case 'name':
216
+ return this.getEntityName(expressId);
217
+ case 'description':
218
+ return this.getDescription(expressId);
219
+ case 'globalid':
220
+ return this.getGlobalId(expressId);
221
+ case 'objecttype':
222
+ case 'predefinedtype':
223
+ return this.getObjectType(expressId);
224
+ default: {
225
+ // Try to get from entities table if available
226
+ const entities = dataStore.entities as {
227
+ getAttribute?: (id: number, attr: string) => string | undefined;
228
+ };
229
+ if (entities?.getAttribute) {
230
+ return entities.getAttribute(expressId, attributeName);
231
+ }
232
+ return undefined;
233
+ }
234
+ }
235
+ },
236
+ };
237
+ }
@@ -0,0 +1,444 @@
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
+ * IDS Export Service
7
+ *
8
+ * Pure functions that generate downloadable JSON and HTML reports
9
+ * from IDS validation results. No React dependencies.
10
+ */
11
+
12
+ import type { IDSValidationReport, SupportedLocale } from '@ifc-lite/ids';
13
+
14
+ // ============================================================================
15
+ // JSON Export
16
+ // ============================================================================
17
+
18
+ /**
19
+ * Generate a JSON export object from a validation report.
20
+ * Returns a plain object suitable for JSON.stringify.
21
+ */
22
+ export function buildReportJSON(report: IDSValidationReport): Record<string, unknown> {
23
+ return {
24
+ document: report.document,
25
+ modelInfo: report.modelInfo,
26
+ timestamp: report.timestamp.toISOString(),
27
+ summary: report.summary,
28
+ specificationResults: report.specificationResults.map(spec => ({
29
+ specification: spec.specification,
30
+ status: spec.status,
31
+ applicableCount: spec.applicableCount,
32
+ passedCount: spec.passedCount,
33
+ failedCount: spec.failedCount,
34
+ passRate: spec.passRate,
35
+ entityResults: spec.entityResults.map(entity => ({
36
+ expressId: entity.expressId,
37
+ modelId: entity.modelId,
38
+ entityType: entity.entityType,
39
+ entityName: entity.entityName,
40
+ globalId: entity.globalId,
41
+ passed: entity.passed,
42
+ requirementResults: entity.requirementResults.map(req => ({
43
+ requirement: req.requirement,
44
+ status: req.status,
45
+ facetType: req.facetType,
46
+ checkedDescription: req.checkedDescription,
47
+ failureReason: req.failureReason,
48
+ actualValue: req.actualValue,
49
+ expectedValue: req.expectedValue,
50
+ })),
51
+ })),
52
+ })),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Trigger a JSON report download in the browser.
58
+ */
59
+ export function downloadReportJSON(report: IDSValidationReport): void {
60
+ const exportData = buildReportJSON(report);
61
+ const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
62
+ const url = URL.createObjectURL(blob);
63
+ const a = globalThis.document.createElement('a');
64
+ a.href = url;
65
+ a.download = `ids-report-${new Date().toISOString().split('T')[0]}.json`;
66
+ a.click();
67
+ URL.revokeObjectURL(url);
68
+ }
69
+
70
+ // ============================================================================
71
+ // HTML Export
72
+ // ============================================================================
73
+
74
+ /** HTML escape helper to prevent XSS */
75
+ function escapeHtml(str: string | undefined | null): string {
76
+ if (str == null) return '';
77
+ return String(str)
78
+ .replace(/&/g, '&amp;')
79
+ .replace(/</g, '&lt;')
80
+ .replace(/>/g, '&gt;')
81
+ .replace(/"/g, '&quot;')
82
+ .replace(/'/g, '&#39;');
83
+ }
84
+
85
+ /** Build entity rows HTML for a specification table */
86
+ function buildEntityRows(
87
+ spec: IDSValidationReport['specificationResults'][0],
88
+ esc: typeof escapeHtml,
89
+ ): string {
90
+ return spec.entityResults.map(entity => {
91
+ const failedReqs = entity.requirementResults.filter(r => r.status === 'fail');
92
+ const passedReqs = entity.requirementResults.filter(r => r.status === 'pass');
93
+ const allReqs = entity.requirementResults.filter(r => r.status !== 'not_applicable');
94
+
95
+ const reqDetails = failedReqs.length > 0
96
+ ? failedReqs.map(req => `<div class="req-detail">
97
+ <span class="req-facet">${esc(req.facetType)}</span>
98
+ <span class="req-desc">${esc(req.checkedDescription)}</span>
99
+ ${req.failureReason ? `<div class="req-failure">${esc(req.failureReason)}</div>` : ''}
100
+ ${req.expectedValue || req.actualValue ? `<div class="req-values">${req.expectedValue ? `<span>Expected: <code>${esc(req.expectedValue)}</code></span>` : ''}${req.actualValue ? `<span>Actual: <code>${esc(req.actualValue)}</code></span>` : ''}</div>` : ''}
101
+ </div>`).join('')
102
+ : '<span class="all-pass">All requirements passed</span>';
103
+
104
+ return `<tr class="entity-row" data-status="${entity.passed ? 'pass' : 'fail'}" data-type="${esc(entity.entityType)}" data-name="${esc(entity.entityName ?? '')}">
105
+ <td class="col-status"><span class="badge ${entity.passed ? 'badge-pass' : 'badge-fail'}">${entity.passed ? 'PASS' : 'FAIL'}</span></td>
106
+ <td class="col-type">${esc(entity.entityType)}</td>
107
+ <td class="col-name">${esc(entity.entityName) || '<em>unnamed</em>'}</td>
108
+ <td class="col-globalid"><code class="globalid" title="Click to copy">${esc(entity.globalId) || '\u2014'}</code></td>
109
+ <td class="col-expressid">${entity.expressId}</td>
110
+ <td class="col-reqs"><span class="pass-count">${passedReqs.length}</span>/<span class="total-count">${allReqs.length}</span></td>
111
+ <td class="col-details"><details><summary>${failedReqs.length > 0 ? `${failedReqs.length} failure${failedReqs.length > 1 ? 's' : ''}` : 'Details'}</summary><div class="req-list">${reqDetails}</div></details></td>
112
+ </tr>`;
113
+ }).join('');
114
+ }
115
+
116
+ /**
117
+ * Generate an interactive HTML report with search, filtering, sorting,
118
+ * and click-to-copy GlobalId support.
119
+ */
120
+ export function buildReportHTML(report: IDSValidationReport, locale: SupportedLocale): string {
121
+ const esc = escapeHtml;
122
+ const totalChecks = report.summary.totalEntitiesChecked;
123
+ const totalPassed = report.specificationResults.reduce((s, sp) => s + sp.passedCount, 0);
124
+ const totalFailed = report.specificationResults.reduce((s, sp) => s + sp.failedCount, 0);
125
+ const overallPassRate = totalChecks > 0 ? Math.round((totalPassed / totalChecks) * 100) : 0;
126
+
127
+ return `<!DOCTYPE html>
128
+ <html lang="${esc(locale)}">
129
+ <head>
130
+ <meta charset="UTF-8">
131
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
132
+ <title>IDS Validation Report - ${esc(report.document.info.title)}</title>
133
+ <style>
134
+ :root {
135
+ --pass: #22c55e; --pass-bg: #dcfce7; --pass-border: #86efac;
136
+ --fail: #ef4444; --fail-bg: #fef2f2; --fail-border: #fca5a5;
137
+ --warn: #eab308; --muted: #6b7280; --border: #e5e7eb;
138
+ --bg: #f8fafc; --card: #fff; --hover: #f1f5f9;
139
+ }
140
+ * { box-sizing: border-box; margin: 0; }
141
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 1400px; margin: 0 auto; padding: 20px; background: var(--bg); color: #1e293b; line-height: 1.5; }
142
+ h1 { font-size: 1.5rem; margin-bottom: 4px; }
143
+ h2 { font-size: 1.25rem; margin-bottom: 8px; }
144
+ h3 { font-size: 1rem; }
145
+ .card { background: var(--card); border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04); }
146
+ .meta { color: var(--muted); font-size: 0.875rem; margin-top: 4px; }
147
+ .meta span { margin-right: 16px; }
148
+
149
+ /* Summary grid */
150
+ .summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-top: 12px; }
151
+ .stat { text-align: center; padding: 12px; background: var(--bg); border-radius: 8px; border: 1px solid var(--border); }
152
+ .stat .value { font-size: 1.75rem; font-weight: 700; }
153
+ .stat .label { color: var(--muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; }
154
+ .stat.pass .value { color: var(--pass); }
155
+ .stat.fail .value { color: var(--fail); }
156
+
157
+ /* Progress bar */
158
+ .progress { height: 8px; background: var(--fail-bg); border-radius: 4px; overflow: hidden; margin: 8px 0; }
159
+ .progress-fill { height: 100%; background: var(--pass); border-radius: 4px; transition: width 0.3s; }
160
+
161
+ /* Filter toolbar */
162
+ .toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
163
+ .toolbar input[type="text"] { padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 0.875rem; min-width: 200px; }
164
+ .toolbar input[type="text"]:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
165
+ .filter-btn { padding: 5px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--card); cursor: pointer; font-size: 0.8rem; font-weight: 500; }
166
+ .filter-btn:hover { background: var(--hover); }
167
+ .filter-btn.active { background: #1e293b; color: white; border-color: #1e293b; }
168
+ .result-count { color: var(--muted); font-size: 0.8rem; margin-left: auto; }
169
+
170
+ /* Specification sections */
171
+ .spec { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 12px; overflow: hidden; }
172
+ .spec-header { padding: 16px; cursor: pointer; display: flex; align-items: flex-start; gap: 12px; }
173
+ .spec-header:hover { background: var(--hover); }
174
+ .spec-indicator { font-size: 1.25rem; margin-top: 2px; transition: transform 0.2s; }
175
+ .spec.open .spec-indicator { transform: rotate(90deg); }
176
+ .spec-info { flex: 1; }
177
+ .spec-info h3 { display: flex; align-items: center; gap: 8px; }
178
+ .spec-desc { color: var(--muted); font-size: 0.875rem; margin-top: 4px; }
179
+ .spec-stats { display: flex; gap: 16px; font-size: 0.8rem; color: var(--muted); margin-top: 8px; }
180
+ .spec-body { display: none; border-top: 1px solid var(--border); }
181
+ .spec.open .spec-body { display: block; }
182
+
183
+ /* Entity table */
184
+ table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
185
+ th { padding: 8px 12px; text-align: left; background: var(--bg); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); cursor: pointer; user-select: none; white-space: nowrap; border-bottom: 2px solid var(--border); }
186
+ th:hover { background: #e2e8f0; }
187
+ th .sort-icon { margin-left: 4px; opacity: 0.3; }
188
+ th.sorted .sort-icon { opacity: 1; }
189
+ td { padding: 8px 12px; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
190
+ tr.entity-row:hover { background: var(--hover); }
191
+ tr.entity-row[data-status="fail"] { background: #fefce8; }
192
+ tr.entity-row[data-status="fail"]:hover { background: #fef9c3; }
193
+
194
+ /* Badges */
195
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.05em; }
196
+ .badge-pass { background: var(--pass-bg); color: #166534; border: 1px solid var(--pass-border); }
197
+ .badge-fail { background: var(--fail-bg); color: #991b1b; border: 1px solid var(--fail-border); }
198
+ .badge-spec { font-size: 0.7rem; padding: 2px 6px; }
199
+
200
+ /* Columns */
201
+ .col-status { width: 60px; }
202
+ .col-type { width: 140px; font-family: monospace; font-size: 0.8rem; }
203
+ .col-name { min-width: 120px; }
204
+ .col-globalid { width: 200px; }
205
+ .col-expressid { width: 70px; text-align: right; font-family: monospace; }
206
+ .col-reqs { width: 60px; text-align: center; }
207
+ .col-details { min-width: 200px; }
208
+
209
+ /* GlobalId */
210
+ code.globalid { font-size: 0.75rem; background: #f1f5f9; padding: 2px 6px; border-radius: 3px; cursor: pointer; word-break: break-all; }
211
+ code.globalid:hover { background: #e2e8f0; }
212
+ code.globalid.copied { background: var(--pass-bg); }
213
+
214
+ /* Requirement details */
215
+ details summary { cursor: pointer; color: var(--fail); font-size: 0.8rem; }
216
+ details summary:hover { text-decoration: underline; }
217
+ .req-list { padding: 8px 0; }
218
+ .req-detail { padding: 6px 0; border-bottom: 1px solid #f1f5f9; }
219
+ .req-detail:last-child { border-bottom: none; }
220
+ .req-facet { display: inline-block; background: #f1f5f9; padding: 1px 6px; border-radius: 3px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; color: var(--muted); margin-right: 6px; }
221
+ .req-desc { font-size: 0.8rem; }
222
+ .req-failure { color: var(--fail); font-size: 0.8rem; margin-top: 2px; }
223
+ .req-values { display: flex; gap: 16px; font-size: 0.75rem; color: var(--muted); margin-top: 2px; }
224
+ .req-values code { background: #fef3c7; padding: 1px 4px; border-radius: 2px; color: #92400e; }
225
+ .all-pass { color: var(--pass); font-size: 0.8rem; }
226
+ .pass-count { color: var(--pass); font-weight: 600; }
227
+ .total-count { color: var(--muted); }
228
+
229
+ /* Responsive */
230
+ @media (max-width: 768px) {
231
+ .col-globalid, .col-expressid { display: none; }
232
+ .toolbar { flex-direction: column; }
233
+ .toolbar input[type="text"] { width: 100%; min-width: unset; }
234
+ }
235
+
236
+ /* Print */
237
+ @media print {
238
+ body { background: white; max-width: none; }
239
+ .card { box-shadow: none; border: 1px solid #ddd; }
240
+ .toolbar { display: none; }
241
+ .spec.open .spec-body { display: block; }
242
+ details { open; }
243
+ details[open] summary { display: none; }
244
+ }
245
+
246
+ .hidden { display: none !important; }
247
+ </style>
248
+ </head>
249
+ <body>
250
+ <!-- Header -->
251
+ <div class="card">
252
+ <h1>${esc(report.document.info.title)}</h1>
253
+ ${report.document.info.description ? `<p style="color: var(--muted); margin-top: 4px;">${esc(report.document.info.description)}</p>` : ''}
254
+ <div class="meta">
255
+ ${report.document.info.author ? `<span>Author: ${esc(report.document.info.author)}</span>` : ''}
256
+ <span>Generated: ${esc(report.timestamp.toLocaleString())}</span>
257
+ <span>Schema: ${esc(report.modelInfo.schemaVersion)}</span>
258
+ </div>
259
+ </div>
260
+
261
+ <!-- Summary -->
262
+ <div class="card">
263
+ <h2>Summary</h2>
264
+ <div class="progress">
265
+ <div class="progress-fill" style="width: ${overallPassRate}%;"></div>
266
+ </div>
267
+ <div style="text-align: center; font-size: 0.875rem; color: var(--muted);">${overallPassRate}% of entity checks passed</div>
268
+ <div class="summary">
269
+ <div class="stat">
270
+ <div class="value">${report.summary.totalSpecifications}</div>
271
+ <div class="label">Specifications</div>
272
+ </div>
273
+ <div class="stat pass">
274
+ <div class="value">${report.summary.passedSpecifications}</div>
275
+ <div class="label">Specs Passed</div>
276
+ </div>
277
+ <div class="stat fail">
278
+ <div class="value">${report.summary.failedSpecifications}</div>
279
+ <div class="label">Specs Failed</div>
280
+ </div>
281
+ <div class="stat">
282
+ <div class="value">${totalChecks}</div>
283
+ <div class="label">Entities Checked</div>
284
+ </div>
285
+ <div class="stat pass">
286
+ <div class="value">${totalPassed}</div>
287
+ <div class="label">Passed</div>
288
+ </div>
289
+ <div class="stat fail">
290
+ <div class="value">${totalFailed}</div>
291
+ <div class="label">Failed</div>
292
+ </div>
293
+ </div>
294
+ </div>
295
+
296
+ <!-- Filter toolbar -->
297
+ <div class="card">
298
+ <div class="toolbar">
299
+ <input type="text" id="search" placeholder="Search by name, type, or GlobalId..." oninput="filterAll()">
300
+ <button class="filter-btn active" data-filter="all" onclick="setFilter('all')">All</button>
301
+ <button class="filter-btn" data-filter="fail" onclick="setFilter('fail')">Failed Only</button>
302
+ <button class="filter-btn" data-filter="pass" onclick="setFilter('pass')">Passed Only</button>
303
+ <span class="result-count" id="result-count"></span>
304
+ </div>
305
+
306
+ <h2>Specifications</h2>
307
+
308
+ ${report.specificationResults.map((spec, i) => `
309
+ <div class="spec ${spec.status === 'fail' ? 'open' : ''}" id="spec-${i}">
310
+ <div class="spec-header" onclick="toggleSpec(${i})">
311
+ <span class="spec-indicator">&#9654;</span>
312
+ <div class="spec-info">
313
+ <h3>
314
+ <span class="badge badge-spec ${spec.status === 'pass' ? 'badge-pass' : spec.status === 'fail' ? 'badge-fail' : ''}">${spec.status.toUpperCase()}</span>
315
+ ${esc(spec.specification.name)}
316
+ </h3>
317
+ ${spec.specification.description ? `<div class="spec-desc">${esc(spec.specification.description)}</div>` : ''}
318
+ <div class="spec-stats">
319
+ <span>${spec.applicableCount} applicable</span>
320
+ <span style="color: var(--pass);">${spec.passedCount} passed</span>
321
+ <span style="color: var(--fail);">${spec.failedCount} failed</span>
322
+ <span>${spec.passRate}% pass rate</span>
323
+ </div>
324
+ <div class="progress" style="margin-top: 6px;">
325
+ <div class="progress-fill" style="width: ${spec.passRate}%;"></div>
326
+ </div>
327
+ </div>
328
+ </div>
329
+ <div class="spec-body">
330
+ <table>
331
+ <thead>
332
+ <tr>
333
+ <th class="col-status" onclick="sortTable(${i}, 0)">Status <span class="sort-icon">&#x25B4;&#x25BE;</span></th>
334
+ <th class="col-type" onclick="sortTable(${i}, 1)">IFC Type <span class="sort-icon">&#x25B4;&#x25BE;</span></th>
335
+ <th class="col-name" onclick="sortTable(${i}, 2)">Name <span class="sort-icon">&#x25B4;&#x25BE;</span></th>
336
+ <th class="col-globalid" onclick="sortTable(${i}, 3)">GlobalId <span class="sort-icon">&#x25B4;&#x25BE;</span></th>
337
+ <th class="col-expressid" onclick="sortTable(${i}, 4)">ID <span class="sort-icon">&#x25B4;&#x25BE;</span></th>
338
+ <th class="col-reqs">Reqs</th>
339
+ <th class="col-details">Details</th>
340
+ </tr>
341
+ </thead>
342
+ <tbody id="tbody-${i}">
343
+ ${buildEntityRows(spec, esc)}
344
+ </tbody>
345
+ </table>
346
+ </div>
347
+ </div>
348
+ `).join('')}
349
+ </div>
350
+
351
+ <footer style="text-align: center; color: var(--muted); padding: 20px; font-size: 0.8rem;">
352
+ Generated by <strong>IFC-Lite</strong> IDS Validator &middot; ${esc(new Date().toISOString().split('T')[0])}
353
+ </footer>
354
+
355
+ <script>
356
+ let currentFilter = 'all';
357
+
358
+ function toggleSpec(i) {
359
+ document.getElementById('spec-' + i).classList.toggle('open');
360
+ }
361
+
362
+ function setFilter(filter) {
363
+ currentFilter = filter;
364
+ document.querySelectorAll('.filter-btn').forEach(btn => {
365
+ btn.classList.toggle('active', btn.dataset.filter === filter);
366
+ });
367
+ filterAll();
368
+ }
369
+
370
+ function filterAll() {
371
+ const search = document.getElementById('search').value.toLowerCase();
372
+ let visible = 0, total = 0;
373
+
374
+ document.querySelectorAll('.entity-row').forEach(row => {
375
+ total++;
376
+ const status = row.dataset.status;
377
+ const text = row.textContent.toLowerCase();
378
+ const matchesFilter = currentFilter === 'all' || status === currentFilter;
379
+ const matchesSearch = !search || text.includes(search);
380
+ const show = matchesFilter && matchesSearch;
381
+ row.classList.toggle('hidden', !show);
382
+ if (show) visible++;
383
+ });
384
+
385
+ document.getElementById('result-count').textContent =
386
+ search || currentFilter !== 'all'
387
+ ? visible + ' of ' + total + ' entities shown'
388
+ : total + ' entities';
389
+ }
390
+
391
+ function sortTable(specIndex, colIndex) {
392
+ const tbody = document.getElementById('tbody-' + specIndex);
393
+ const rows = Array.from(tbody.querySelectorAll('tr.entity-row'));
394
+
395
+ const th = tbody.parentElement.querySelectorAll('th')[colIndex];
396
+ const asc = !th.classList.contains('sorted-asc');
397
+
398
+ tbody.parentElement.querySelectorAll('th').forEach(h => {
399
+ h.classList.remove('sorted', 'sorted-asc', 'sorted-desc');
400
+ });
401
+ th.classList.add('sorted', asc ? 'sorted-asc' : 'sorted-desc');
402
+
403
+ rows.sort((a, b) => {
404
+ let aVal = a.cells[colIndex].textContent.trim();
405
+ let bVal = b.cells[colIndex].textContent.trim();
406
+
407
+ if (colIndex === 4) {
408
+ return asc ? Number(aVal) - Number(bVal) : Number(bVal) - Number(aVal);
409
+ }
410
+
411
+ return asc ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
412
+ });
413
+
414
+ rows.forEach(row => tbody.appendChild(row));
415
+ }
416
+
417
+ document.addEventListener('click', function(e) {
418
+ if (e.target.classList.contains('globalid') && e.target.textContent !== '\\u2014') {
419
+ navigator.clipboard.writeText(e.target.textContent).then(() => {
420
+ e.target.classList.add('copied');
421
+ setTimeout(() => e.target.classList.remove('copied'), 1000);
422
+ });
423
+ }
424
+ });
425
+
426
+ filterAll();
427
+ </script>
428
+ </body>
429
+ </html>`;
430
+ }
431
+
432
+ /**
433
+ * Trigger an HTML report download in the browser.
434
+ */
435
+ export function downloadReportHTML(report: IDSValidationReport, locale: SupportedLocale): void {
436
+ const html = buildReportHTML(report, locale);
437
+ const blob = new Blob([html], { type: 'text/html' });
438
+ const url = URL.createObjectURL(blob);
439
+ const a = globalThis.document.createElement('a');
440
+ a.href = url;
441
+ a.download = `ids-report-${new Date().toISOString().split('T')[0]}.html`;
442
+ a.click();
443
+ URL.revokeObjectURL(url);
444
+ }