@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
@@ -13,19 +13,12 @@
13
13
  * - Isolate failed/passed entities
14
14
  */
15
15
 
16
- import { useCallback, useMemo, useEffect, useRef } from 'react';
16
+ import { useCallback, useMemo, useEffect, useRef, useState } from 'react';
17
17
  import { useViewerStore } from '@/store';
18
18
  import type {
19
19
  IDSDocument,
20
20
  IDSValidationReport,
21
21
  IDSModelInfo,
22
- IFCDataAccessor,
23
- PropertyValueResult,
24
- PropertySetInfo,
25
- ClassificationInfo,
26
- MaterialInfo,
27
- ParentInfo,
28
- PartOfRelation,
29
22
  SupportedLocale,
30
23
  ValidationProgress,
31
24
  } from '@ifc-lite/ids';
@@ -35,6 +28,21 @@ import {
35
28
  createTranslationService,
36
29
  } from '@ifc-lite/ids';
37
30
  import type { IfcDataStore } from '@ifc-lite/parser';
31
+ import { createBCFFromIDSReport, writeBCF } from '@ifc-lite/bcf';
32
+ import type { EntityBoundsInput, IDSBCFExportOptions } from '@ifc-lite/bcf';
33
+ import type { IDSBCFExportSettings, IDSExportProgress } from '@/components/viewer/IDSExportDialog';
34
+ import { getEntityBounds } from '@/utils/viewportUtils';
35
+ import { getGlobalRenderer } from '@/hooks/useBCF';
36
+
37
+ import { createDataAccessor } from './ids/idsDataAccessor';
38
+ import {
39
+ DEFAULT_FAILED_COLOR,
40
+ DEFAULT_PASSED_COLOR,
41
+ buildValidationColorUpdates,
42
+ buildRestoreColorUpdates,
43
+ } from './ids/idsColorSystem';
44
+ import type { ColorTuple } from './ids/idsColorSystem';
45
+ import { downloadReportJSON, downloadReportHTML } from './ids/idsExportService';
38
46
 
39
47
  // ============================================================================
40
48
  // Types
@@ -142,243 +150,18 @@ export interface UseIDSResult {
142
150
  exportReportJSON: () => void;
143
151
  /** Export validation report to HTML */
144
152
  exportReportHTML: () => void;
145
- }
146
-
147
- // ============================================================================
148
- // IFC Data Accessor Factory
149
- // ============================================================================
150
-
151
- /**
152
- * Create an IFCDataAccessor from an IfcDataStore
153
- * This bridges the viewer's data store to the IDS validator's interface
154
- */
155
- function createDataAccessor(
156
- dataStore: IfcDataStore,
157
- modelId: string
158
- ): IFCDataAccessor {
159
- // Helper to get entity info
160
- const getEntityInfo = (expressId: number) => {
161
- return dataStore.entities?.getName ? {
162
- name: dataStore.entities.getName(expressId),
163
- type: dataStore.entities.getTypeName?.(expressId),
164
- globalId: dataStore.entities.getGlobalId?.(expressId),
165
- } : undefined;
166
- };
167
-
168
- return {
169
- getEntityType(expressId: number): string | undefined {
170
- // Try entities table first
171
- const entityType = dataStore.entities?.getTypeName?.(expressId);
172
- if (entityType) return entityType;
173
-
174
- // Fallback to entityIndex
175
- const byId = dataStore.entityIndex?.byId;
176
- if (byId) {
177
- const entry = byId.get(expressId);
178
- if (entry) {
179
- return typeof entry === 'object' && 'type' in entry ? String(entry.type) : undefined;
180
- }
181
- }
182
- return undefined;
183
- },
184
-
185
- getEntityName(expressId: number): string | undefined {
186
- return dataStore.entities?.getName?.(expressId);
187
- },
188
-
189
- getGlobalId(expressId: number): string | undefined {
190
- return dataStore.entities?.getGlobalId?.(expressId);
191
- },
192
-
193
- getDescription(expressId: number): string | undefined {
194
- return dataStore.entities?.getDescription?.(expressId);
195
- },
196
-
197
- getObjectType(expressId: number): string | undefined {
198
- return dataStore.entities?.getObjectType?.(expressId);
199
- },
200
-
201
- getEntitiesByType(typeName: string): number[] {
202
- const byType = dataStore.entityIndex?.byType;
203
- if (byType) {
204
- const ids = byType.get(typeName.toUpperCase());
205
- if (ids) return Array.from(ids);
206
- }
207
- return [];
208
- },
209
-
210
- getAllEntityIds(): number[] {
211
- const byId = dataStore.entityIndex?.byId;
212
- if (byId) {
213
- return Array.from(byId.keys());
214
- }
215
- return [];
216
- },
217
-
218
- getPropertyValue(
219
- expressId: number,
220
- propertySetName: string,
221
- propertyName: string
222
- ): PropertyValueResult | undefined {
223
- const propertiesStore = dataStore.properties;
224
- if (!propertiesStore) return undefined;
225
-
226
- // Get property sets for this entity using getForEntity (returns PropertySet[])
227
- const psets = propertiesStore.getForEntity?.(expressId);
228
- if (!psets) return undefined;
229
-
230
- for (const pset of psets) {
231
- if (pset.name.toLowerCase() === propertySetName.toLowerCase()) {
232
- const props = pset.properties || [];
233
- for (const prop of props) {
234
- if (prop.name.toLowerCase() === propertyName.toLowerCase()) {
235
- // Convert value: ensure it's a primitive type (not array)
236
- let value: string | number | boolean | null = null;
237
- if (Array.isArray(prop.value)) {
238
- // For arrays, convert to string representation
239
- value = JSON.stringify(prop.value);
240
- } else {
241
- value = prop.value as string | number | boolean | null;
242
- }
243
- return {
244
- value,
245
- dataType: String(prop.type || 'IFCLABEL'),
246
- propertySetName: pset.name,
247
- propertyName: prop.name,
248
- };
249
- }
250
- }
251
- }
252
- }
253
- return undefined;
254
- },
255
-
256
- getPropertySets(expressId: number): PropertySetInfo[] {
257
- const propertiesStore = dataStore.properties;
258
- if (!propertiesStore) return [];
259
-
260
- // Use getForEntity (returns PropertySet[])
261
- const psets = propertiesStore.getForEntity?.(expressId);
262
- if (!psets) return [];
263
-
264
- return psets.map((pset) => ({
265
- name: pset.name,
266
- properties: (pset.properties || []).map((prop) => {
267
- // Convert value: ensure it's a primitive type (not array)
268
- let value: string | number | boolean | null = null;
269
- if (Array.isArray(prop.value)) {
270
- value = JSON.stringify(prop.value);
271
- } else {
272
- value = prop.value as string | number | boolean | null;
273
- }
274
- return {
275
- name: prop.name,
276
- value,
277
- dataType: String(prop.type || 'IFCLABEL'),
278
- };
279
- }),
280
- }));
281
- },
282
-
283
- getClassifications(expressId: number): ClassificationInfo[] {
284
- // Classifications might be stored separately or in properties
285
- // This is a placeholder - implement based on actual data structure
286
- const classifications: ClassificationInfo[] = [];
287
-
288
- // Check if there's a classifications accessor
289
- const classStore = (dataStore as { classifications?: { getForEntity?: (id: number) => ClassificationInfo[] } }).classifications;
290
- if (classStore?.getForEntity) {
291
- return classStore.getForEntity(expressId);
292
- }
293
-
294
- return classifications;
295
- },
296
-
297
- getMaterials(expressId: number): MaterialInfo[] {
298
- // Materials might be stored separately or in relationships
299
- const materials: MaterialInfo[] = [];
300
-
301
- // Check if there's a materials accessor
302
- const matStore = (dataStore as { materials?: { getForEntity?: (id: number) => MaterialInfo[] } }).materials;
303
- if (matStore?.getForEntity) {
304
- return matStore.getForEntity(expressId);
305
- }
306
-
307
- return materials;
308
- },
309
-
310
- getParent(
311
- expressId: number,
312
- relationType: PartOfRelation
313
- ): ParentInfo | undefined {
314
- const relationships = dataStore.relationships;
315
- if (!relationships) return undefined;
316
-
317
- // Map IDS relation type to internal relation type
318
- const relationMap: Record<PartOfRelation, string> = {
319
- 'IfcRelAggregates': 'Aggregates',
320
- 'IfcRelContainedInSpatialStructure': 'ContainedInSpatialStructure',
321
- 'IfcRelNests': 'Nests',
322
- 'IfcRelVoidsElement': 'VoidsElement',
323
- 'IfcRelFillsElement': 'FillsElement',
324
- };
325
-
326
- const relType = relationMap[relationType];
327
- if (!relType) return undefined;
328
-
329
- // Get related entities (parent direction)
330
- const getRelated = relationships.getRelated;
331
- if (getRelated) {
332
- const parents = getRelated(expressId, relType as never, 'inverse');
333
- if (parents && parents.length > 0) {
334
- const parentId = parents[0];
335
- return {
336
- expressId: parentId,
337
- entityType: this.getEntityType(parentId) || 'Unknown',
338
- predefinedType: this.getObjectType(parentId),
339
- };
340
- }
341
- }
342
-
343
- return undefined;
344
- },
345
-
346
- getAttribute(expressId: number, attributeName: string): string | undefined {
347
- const lowerName = attributeName.toLowerCase();
348
-
349
- // Map common attribute names to accessor methods
350
- switch (lowerName) {
351
- case 'name':
352
- return this.getEntityName(expressId);
353
- case 'description':
354
- return this.getDescription(expressId);
355
- case 'globalid':
356
- return this.getGlobalId(expressId);
357
- case 'objecttype':
358
- case 'predefinedtype':
359
- return this.getObjectType(expressId);
360
- default: {
361
- // Try to get from entities table if available
362
- const entities = dataStore.entities as {
363
- getAttribute?: (id: number, attr: string) => string | undefined;
364
- };
365
- if (entities?.getAttribute) {
366
- return entities.getAttribute(expressId, attributeName);
367
- }
368
- return undefined;
369
- }
370
- }
371
- },
372
- };
153
+ /** Export validation report to BCF with configurable options */
154
+ exportReportBCF: (settings: IDSBCFExportSettings) => Promise<void>;
155
+ /** BCF export progress state */
156
+ bcfExportProgress: IDSExportProgress | null;
373
157
  }
374
158
 
375
159
  // ============================================================================
376
160
  // Hook Implementation
377
161
  // ============================================================================
378
162
 
379
- // Stable default color constants (moved outside hook to prevent recreations)
380
- const DEFAULT_FAILED_COLOR: [number, number, number, number] = [0.9, 0.2, 0.2, 1.0];
381
- const DEFAULT_PASSED_COLOR: [number, number, number, number] = [0.2, 0.8, 0.2, 1.0];
163
+ /** Dark background for BCF snapshot captures */
164
+ const SNAPSHOT_CLEAR_COLOR: [number, number, number, number] = [0.102, 0.106, 0.149, 1];
382
165
 
383
166
  export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
384
167
  const {
@@ -434,7 +217,7 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
434
217
  const geometryResult = useViewerStore((s) => s.geometryResult);
435
218
 
436
219
  // Ref to store original colors before IDS color overrides
437
- const originalColorsRef = useRef<Map<number, [number, number, number, number]>>(new Map());
220
+ const originalColorsRef = useRef<Map<number, ColorTuple>>(new Map());
438
221
 
439
222
  // Ref to access geometryResult without creating callback dependencies (prevents infinite loops)
440
223
  const geometryResultRef = useRef(geometryResult);
@@ -504,7 +287,6 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
504
287
  }
505
288
 
506
289
  // Determine model ID - use '__legacy__' for legacy single-model mode
507
- const isLegacyMode = ifcDataStore && models.size === 0;
508
290
  const modelId = activeModelId || (models.size > 0 ? Array.from(models.keys())[0] : '__legacy__');
509
291
 
510
292
  try {
@@ -635,50 +417,15 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
635
417
  const applyColors = useCallback(() => {
636
418
  if (!report) return;
637
419
 
638
- const colorUpdates = new Map<number, [number, number, number, number]>();
639
-
640
- // Get color options
641
- const failedClr = displayOptions.failedColor ?? defaultFailedColor;
642
- const passedClr = displayOptions.passedColor ?? defaultPassedColor;
643
-
644
- // Build a set of globalIds we'll be updating
645
- const globalIdsToUpdate = new Set<number>();
646
- for (const specResult of report.specificationResults) {
647
- for (const entityResult of specResult.entityResults) {
648
- const model = models.get(entityResult.modelId);
649
- const globalId = model
650
- ? entityResult.expressId + (model.idOffset ?? 0)
651
- : entityResult.expressId;
652
- globalIdsToUpdate.add(globalId);
653
- }
654
- }
655
-
656
- // Capture original colors before applying overrides (only if not already captured)
657
- // Use ref to avoid dependency on geometryResult which would cause infinite loops
658
- const currentGeometry = geometryResultRef.current;
659
- if (currentGeometry?.meshes && originalColorsRef.current.size === 0) {
660
- for (const mesh of currentGeometry.meshes) {
661
- if (globalIdsToUpdate.has(mesh.expressId)) {
662
- originalColorsRef.current.set(mesh.expressId, [...mesh.color] as [number, number, number, number]);
663
- }
664
- }
665
- }
666
-
667
- // Process all entity results
668
- for (const specResult of report.specificationResults) {
669
- for (const entityResult of specResult.entityResults) {
670
- const model = models.get(entityResult.modelId);
671
- const globalId = model
672
- ? entityResult.expressId + (model.idOffset ?? 0)
673
- : entityResult.expressId;
674
-
675
- if (entityResult.passed && displayOptions.highlightPassed) {
676
- colorUpdates.set(globalId, passedClr);
677
- } else if (!entityResult.passed && displayOptions.highlightFailed) {
678
- colorUpdates.set(globalId, failedClr);
679
- }
680
- }
681
- }
420
+ const colorUpdates = buildValidationColorUpdates(
421
+ report,
422
+ models,
423
+ displayOptions,
424
+ defaultFailedColor,
425
+ defaultPassedColor,
426
+ geometryResultRef.current,
427
+ originalColorsRef.current
428
+ );
682
429
 
683
430
  if (colorUpdates.size > 0) {
684
431
  updateMeshColors(colorUpdates);
@@ -686,18 +433,9 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
686
433
  }, [report, models, displayOptions, defaultFailedColor, defaultPassedColor, updateMeshColors]);
687
434
 
688
435
  const clearColors = useCallback(() => {
689
- // Restore original colors from the ref
690
- if (originalColorsRef.current.size === 0) {
691
- return;
692
- }
693
-
694
- // Create a new map with the original colors to restore
695
- const colorUpdates = new Map<number, [number, number, number, number]>(originalColorsRef.current);
696
-
697
- if (colorUpdates.size > 0) {
436
+ const colorUpdates = buildRestoreColorUpdates(originalColorsRef.current);
437
+ if (colorUpdates && colorUpdates.size > 0) {
698
438
  updateMeshColors(colorUpdates);
699
- // Clear the stored original colors after restoring
700
- originalColorsRef.current.clear();
701
439
  }
702
440
  }, [updateMeshColors]);
703
441
 
@@ -820,46 +558,7 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
820
558
  console.warn('[IDS] No report to export');
821
559
  return;
822
560
  }
823
-
824
- const exportData = {
825
- document: report.document,
826
- modelInfo: report.modelInfo,
827
- timestamp: report.timestamp.toISOString(),
828
- summary: report.summary,
829
- specificationResults: report.specificationResults.map(spec => ({
830
- specification: spec.specification,
831
- status: spec.status,
832
- applicableCount: spec.applicableCount,
833
- passedCount: spec.passedCount,
834
- failedCount: spec.failedCount,
835
- passRate: spec.passRate,
836
- entityResults: spec.entityResults.map(entity => ({
837
- expressId: entity.expressId,
838
- modelId: entity.modelId,
839
- entityType: entity.entityType,
840
- entityName: entity.entityName,
841
- globalId: entity.globalId,
842
- passed: entity.passed,
843
- requirementResults: entity.requirementResults.map(req => ({
844
- requirement: req.requirement,
845
- status: req.status,
846
- facetType: req.facetType,
847
- checkedDescription: req.checkedDescription,
848
- failureReason: req.failureReason,
849
- actualValue: req.actualValue,
850
- expectedValue: req.expectedValue,
851
- })),
852
- })),
853
- })),
854
- };
855
-
856
- const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
857
- const url = URL.createObjectURL(blob);
858
- const a = globalThis.document.createElement('a');
859
- a.href = url;
860
- a.download = `ids-report-${new Date().toISOString().split('T')[0]}.json`;
861
- a.click();
862
- URL.revokeObjectURL(url);
561
+ downloadReportJSON(report);
863
562
  }, [report]);
864
563
 
865
564
  const exportReportHTML = useCallback(() => {
@@ -867,142 +566,248 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
867
566
  console.warn('[IDS] No report to export');
868
567
  return;
869
568
  }
569
+ downloadReportHTML(report, locale);
570
+ }, [report, locale]);
870
571
 
871
- // HTML escape helper to prevent XSS
872
- const escapeHtml = (str: string | undefined | null): string => {
873
- if (str == null) return '';
874
- return String(str)
875
- .replace(/&/g, '&amp;')
876
- .replace(/</g, '&lt;')
877
- .replace(/>/g, '&gt;')
878
- .replace(/"/g, '&quot;')
879
- .replace(/'/g, '&#39;');
880
- };
881
572
 
882
- const statusClass = (status: string) => {
883
- if (status === 'pass') return 'color: #22c55e;';
884
- if (status === 'fail') return 'color: #ef4444;';
885
- return 'color: #eab308;';
573
+ // BCF export progress state
574
+ const [bcfExportProgress, setBcfExportProgress] = useState<IDSExportProgress | null>(null);
575
+
576
+ // BCF store actions for 'load into panel'
577
+ const setBcfProject = useViewerStore((s) => s.setBcfProject);
578
+ const setBcfPanelVisible = useViewerStore((s) => s.setBcfPanelVisible);
579
+ const bcfAuthor = useViewerStore((s) => s.bcfAuthor);
580
+
581
+ const exportReportBCF = useCallback(async (settings: IDSBCFExportSettings) => {
582
+ if (!report) {
583
+ console.warn('[IDS] No report to export');
584
+ return;
585
+ }
586
+
587
+ try {
588
+ const {
589
+ topicGrouping,
590
+ includePassingEntities,
591
+ includeCamera,
592
+ includeSnapshots,
593
+ loadIntoBcfPanel,
594
+ } = settings;
595
+
596
+ // Phase 1: Collect entity bounds (needed for both camera and snapshots)
597
+ let entityBounds: Map<string, EntityBoundsInput> | undefined;
598
+
599
+ if (includeCamera || includeSnapshots) {
600
+ setBcfExportProgress({ phase: 'building', current: 0, total: 1, message: 'Computing entity bounds...' });
601
+
602
+ entityBounds = new Map();
603
+ const geomResult = geometryResultRef.current;
604
+
605
+ // Collect geometry from all models
606
+ const allMeshData: Array<{ meshes: unknown[]; idOffset: number; modelId: string }> = [];
607
+ for (const [modelId, model] of models.entries()) {
608
+ if (model.geometryResult?.meshes) {
609
+ allMeshData.push({
610
+ meshes: model.geometryResult.meshes,
611
+ idOffset: model.idOffset ?? 0,
612
+ modelId,
613
+ });
614
+ }
615
+ }
616
+
617
+ // Also include legacy single-model geometry
618
+ if (geomResult?.meshes && allMeshData.length === 0) {
619
+ allMeshData.push({
620
+ meshes: geomResult.meshes,
621
+ idOffset: 0,
622
+ modelId: 'default',
623
+ });
624
+ }
625
+
626
+ // Compute bounds for each entity that appears in the report
627
+ for (const specResult of report.specificationResults) {
628
+ for (const entity of specResult.entityResults) {
629
+ if (entity.passed && !includePassingEntities) continue;
630
+ const boundsKey = `${entity.modelId}:${entity.expressId}`;
631
+ if (entityBounds.has(boundsKey)) continue;
632
+
633
+ // Find matching model geometry
634
+ for (const modelData of allMeshData) {
635
+ if (modelData.modelId === entity.modelId || allMeshData.length === 1) {
636
+ const globalExpressId = entity.expressId + modelData.idOffset;
637
+ const bounds = getEntityBounds(
638
+ modelData.meshes as Parameters<typeof getEntityBounds>[0],
639
+ globalExpressId,
640
+ );
641
+ if (bounds) {
642
+ entityBounds.set(boundsKey, bounds);
643
+ }
644
+ break;
645
+ }
646
+ }
647
+ }
648
+ }
649
+ }
650
+
651
+ // Phase 2: Batch snapshots if requested
652
+ let entitySnapshots: Map<string, string> | undefined;
653
+
654
+ if (includeSnapshots) {
655
+ entitySnapshots = new Map();
656
+
657
+ // Get renderer for direct rendering control (no selection highlight)
658
+ const renderer = getGlobalRenderer();
659
+ if (!renderer) {
660
+ console.warn('[IDS] No renderer available for snapshot capture');
661
+ } else {
662
+ const camera = renderer.getCamera();
663
+
664
+ // Collect all unique entities that need snapshots (Set-based O(1) dedup)
665
+ const seenKeys = new Set<string>();
666
+ const entitiesToSnapshot: Array<{ modelId: string; expressId: number; boundsKey: string }> = [];
667
+ for (const specResult of report.specificationResults) {
668
+ for (const entity of specResult.entityResults) {
669
+ if (entity.passed && !includePassingEntities) continue;
670
+ const boundsKey = `${entity.modelId}:${entity.expressId}`;
671
+ if (!seenKeys.has(boundsKey)) {
672
+ seenKeys.add(boundsKey);
673
+ entitiesToSnapshot.push({
674
+ modelId: entity.modelId,
675
+ expressId: entity.expressId,
676
+ boundsKey,
677
+ });
678
+ }
679
+ }
680
+ }
681
+
682
+ const total = entitiesToSnapshot.length;
683
+
684
+ // Save current viewer state to restore after snapshot batch
685
+ const storeState = useViewerStore.getState();
686
+ const savedSelection = storeState.selectedEntityId;
687
+ const savedIsolation = storeState.isolatedEntities;
688
+ const savedHidden = storeState.hiddenEntities;
689
+
690
+ for (let i = 0; i < total; i++) {
691
+ const entity = entitiesToSnapshot[i];
692
+ setBcfExportProgress({
693
+ phase: 'snapshots',
694
+ current: i + 1,
695
+ total,
696
+ message: `Capturing snapshot ${i + 1}/${total}...`,
697
+ });
698
+
699
+ // Get the entity's bounds for framing
700
+ const bounds = entityBounds?.get(entity.boundsKey);
701
+ if (!bounds) continue;
702
+
703
+ // Find the global expressId for isolation (direct Map lookup)
704
+ const model = models.get(entity.modelId);
705
+ const globalExpressId = entity.expressId + (model?.idOffset ?? 0);
706
+
707
+ // Frame the entity bounds directly via camera (properly centers the object)
708
+ // duration=1 (not 0) because the animator skips updates when duration===0,
709
+ // causing the camera to never move. 1ms is effectively instant.
710
+ await camera.frameBounds(bounds.min, bounds.max, 1);
711
+
712
+ // Render with: entity isolated, NO selection highlight (no cyan), IDS colors intact
713
+ const isolationSet = new Set([globalExpressId]);
714
+ renderer.render({
715
+ isolatedIds: isolationSet,
716
+ selectedId: null, // No cyan selection highlight
717
+ clearColor: SNAPSHOT_CLEAR_COLOR,
718
+ });
719
+
720
+ // Wait for GPU commands to complete
721
+ const device = renderer.getGPUDevice();
722
+ if (device) {
723
+ await device.queue.onSubmittedWorkDone();
724
+ }
725
+
726
+ // Wait for the browser compositor to present the frame to the canvas.
727
+ // Without this, toDataURL() reads a stale canvas — only the last snapshot
728
+ // would show the entity because previous frames haven't been composited yet.
729
+ await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
730
+
731
+ // Capture the now-presented frame
732
+ const dataUrl = await renderer.captureScreenshot();
733
+ if (dataUrl) {
734
+ entitySnapshots.set(entity.boundsKey, dataUrl);
735
+ }
736
+ }
737
+
738
+ // Restore viewer state — set store back to saved state directly
739
+ useViewerStore.setState({
740
+ selectedEntityId: savedSelection,
741
+ isolatedEntities: savedIsolation,
742
+ hiddenEntities: savedHidden,
743
+ });
744
+
745
+ // Re-render with restored state (original clearColor restored by omitting it)
746
+ renderer.render({
747
+ hiddenIds: savedHidden,
748
+ isolatedIds: savedIsolation,
749
+ selectedId: savedSelection,
750
+ });
751
+ }
752
+ }
753
+
754
+ // Phase 3: Build BCF project
755
+ setBcfExportProgress({ phase: 'writing', current: 0, total: 1, message: 'Building BCF project...' });
756
+
757
+ const exportOptions: IDSBCFExportOptions = {
758
+ author: bcfAuthor || report.document.info.author || 'ids-validator@ifc-lite',
759
+ projectName: `IDS Report - ${report.document.info.title}`,
760
+ topicGrouping,
761
+ includePassingEntities,
762
+ entityBounds,
763
+ entitySnapshots,
886
764
  };
887
765
 
888
- const html = `<!DOCTYPE html>
889
- <html lang="${escapeHtml(locale)}">
890
- <head>
891
- <meta charset="UTF-8">
892
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
893
- <title>IDS Validation Report - ${escapeHtml(report.document.info.title)}</title>
894
- <style>
895
- * { box-sizing: border-box; }
896
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
897
- h1, h2, h3 { margin-top: 0; }
898
- .card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
899
- .summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; }
900
- .summary-item { text-align: center; padding: 16px; background: #f9fafb; border-radius: 8px; }
901
- .summary-item .value { font-size: 24px; font-weight: bold; }
902
- .summary-item .label { color: #6b7280; font-size: 14px; }
903
- .pass { color: #22c55e; }
904
- .fail { color: #ef4444; }
905
- .progress-bar { height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; }
906
- .progress-fill { height: 100%; transition: width 0.3s; }
907
- .spec-card { border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 12px; }
908
- .spec-header { padding: 16px; cursor: pointer; }
909
- .spec-header:hover { background: #f9fafb; }
910
- .entity-list { border-top: 1px solid #e5e7eb; max-height: 400px; overflow-y: auto; }
911
- .entity-row { padding: 12px 16px; border-bottom: 1px solid #f3f4f6; }
912
- .entity-row:last-child { border-bottom: none; }
913
- .requirement { font-size: 13px; padding: 4px 0; color: #6b7280; }
914
- .failure-reason { color: #ef4444; font-size: 12px; margin-top: 2px; }
915
- table { width: 100%; border-collapse: collapse; }
916
- th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #e5e7eb; }
917
- th { background: #f9fafb; font-weight: 600; }
918
- </style>
919
- </head>
920
- <body>
921
- <div class="card">
922
- <h1>${escapeHtml(report.document.info.title)}</h1>
923
- ${report.document.info.description ? `<p>${escapeHtml(report.document.info.description)}</p>` : ''}
924
- <p><strong>Model:</strong> ${escapeHtml(report.modelInfo.modelId)} | <strong>Schema:</strong> ${escapeHtml(report.modelInfo.schemaVersion)} | <strong>Date:</strong> ${escapeHtml(report.timestamp.toLocaleString())}</p>
925
- </div>
926
-
927
- <div class="card">
928
- <h2>Summary</h2>
929
- <div class="summary">
930
- <div class="summary-item">
931
- <div class="value">${report.summary.totalSpecifications}</div>
932
- <div class="label">Specifications</div>
933
- </div>
934
- <div class="summary-item">
935
- <div class="value pass">${report.summary.passedSpecifications}</div>
936
- <div class="label">Passed</div>
937
- </div>
938
- <div class="summary-item">
939
- <div class="value fail">${report.summary.failedSpecifications}</div>
940
- <div class="label">Failed</div>
941
- </div>
942
- <div class="summary-item">
943
- <div class="value">${report.summary.totalEntitiesChecked}</div>
944
- <div class="label">Entities Checked</div>
945
- </div>
946
- <div class="summary-item">
947
- <div class="value">${report.summary.overallPassRate}%</div>
948
- <div class="label">Pass Rate</div>
949
- </div>
950
- </div>
951
- </div>
952
-
953
- <div class="card">
954
- <h2>Specifications</h2>
955
- ${report.specificationResults.map(spec => `
956
- <div class="spec-card">
957
- <div class="spec-header">
958
- <h3 style="${statusClass(spec.status)}">${spec.status === 'pass' ? '✓' : '✗'} ${escapeHtml(spec.specification.name)}</h3>
959
- ${spec.specification.description ? `<p style="margin: 8px 0; color: #6b7280;">${escapeHtml(spec.specification.description)}</p>` : ''}
960
- <div style="display: flex; gap: 16px; font-size: 14px; color: #6b7280;">
961
- <span>${spec.applicableCount} entities</span>
962
- <span class="pass">${spec.passedCount} passed</span>
963
- <span class="fail">${spec.failedCount} failed</span>
964
- </div>
965
- <div class="progress-bar" style="margin-top: 8px;">
966
- <div class="progress-fill" style="width: ${spec.passRate}%; background: ${spec.passRate >= 80 ? '#22c55e' : spec.passRate >= 50 ? '#eab308' : '#ef4444'};"></div>
967
- </div>
968
- </div>
969
- ${spec.entityResults.length > 0 ? `
970
- <div class="entity-list">
971
- ${spec.entityResults.slice(0, 50).map(entity => `
972
- <div class="entity-row">
973
- <div style="${statusClass(entity.passed ? 'pass' : 'fail')}">
974
- ${entity.passed ? '✓' : '✗'} <strong>${escapeHtml(entity.entityName) || '#' + entity.expressId}</strong>
975
- <span style="color: #6b7280; font-size: 13px;"> - ${escapeHtml(entity.entityType)}${entity.globalId ? ' · ' + escapeHtml(entity.globalId) : ''}</span>
976
- </div>
977
- ${entity.requirementResults.filter(r => r.status === 'fail').map(req => `
978
- <div class="requirement">
979
- ${escapeHtml(req.checkedDescription)}
980
- ${req.failureReason ? `<div class="failure-reason">${escapeHtml(req.failureReason)}</div>` : ''}
981
- </div>
982
- `).join('')}
983
- </div>
984
- `).join('')}
985
- ${spec.entityResults.length > 50 ? `<div class="entity-row" style="text-align: center; color: #6b7280;">... and ${spec.entityResults.length - 50} more entities</div>` : ''}
986
- </div>
987
- ` : ''}
988
- </div>
989
- `).join('')}
990
- </div>
991
-
992
- <footer style="text-align: center; color: #6b7280; padding: 20px;">
993
- Generated by IFC-Lite IDS Validator
994
- </footer>
995
- </body>
996
- </html>`;
997
-
998
- const blob = new Blob([html], { type: 'text/html' });
766
+ const bcfProject = createBCFFromIDSReport(
767
+ {
768
+ title: report.document.info.title,
769
+ description: report.document.info.description,
770
+ specificationResults: report.specificationResults,
771
+ },
772
+ exportOptions,
773
+ );
774
+
775
+ // Phase 4: Write BCF and download
776
+ setBcfExportProgress({ phase: 'writing', current: 1, total: 2, message: 'Writing BCF file...' });
777
+
778
+ const blob = await writeBCF(bcfProject);
999
779
  const url = URL.createObjectURL(blob);
1000
780
  const a = globalThis.document.createElement('a');
1001
781
  a.href = url;
1002
- a.download = `ids-report-${new Date().toISOString().split('T')[0]}.html`;
782
+ a.download = `ids-report-${new Date().toISOString().split('T')[0]}.bcf`;
1003
783
  a.click();
1004
784
  URL.revokeObjectURL(url);
1005
- }, [report, locale]);
785
+
786
+ // Phase 5: Load into BCF panel if requested
787
+ if (loadIntoBcfPanel) {
788
+ setBcfProject(bcfProject);
789
+ setBcfPanelVisible(true);
790
+ }
791
+
792
+ setBcfExportProgress({ phase: 'done', current: 1, total: 1, message: 'Export complete!' });
793
+
794
+ // Clear progress after a delay
795
+ setTimeout(() => setBcfExportProgress(null), 2000);
796
+
797
+ } catch (err) {
798
+ const message = err instanceof Error ? err.message : 'BCF export failed';
799
+ setIdsError(message);
800
+ console.error('[IDS] BCF export error:', err);
801
+ setBcfExportProgress(null);
802
+ }
803
+ }, [
804
+ report,
805
+ models,
806
+ bcfAuthor,
807
+ setIdsError,
808
+ setBcfProject,
809
+ setBcfPanelVisible,
810
+ ]);
1006
811
 
1007
812
  // ============================================================================
1008
813
  // Return
@@ -1061,5 +866,7 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
1061
866
  // Export actions
1062
867
  exportReportJSON,
1063
868
  exportReportHTML,
869
+ exportReportBCF,
870
+ bcfExportProgress,
1064
871
  };
1065
872
  }