@ifc-lite/viewer 1.19.1 → 1.21.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 (106) hide show
  1. package/.turbo/turbo-build.log +59 -44
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +488 -0
  4. package/dist/assets/{basketViewActivator-CA2CTcVo.js → basketViewActivator-Bzw51jhm.js} +6 -6
  5. package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
  6. package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
  7. package/dist/assets/exporters-u0sz2Upj.js +259119 -0
  8. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
  9. package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
  10. package/dist/assets/ids-B7AXEv7h.js +4067 -0
  11. package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
  12. package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
  13. package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
  14. package/dist/assets/index-CSWgTe1s.css +1 -0
  15. package/dist/assets/{index-D8Epw-e7.js → index-DVNSvEMh.js} +40146 -35823
  16. package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
  17. package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
  18. package/dist/assets/{native-bridge-DKmx1z95.js → native-bridge-BiD01jI9.js} +1 -1
  19. package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
  20. package/dist/assets/{sandbox-tccwm5Bo.js → sandbox-DPD1ROr0.js} +4 -4
  21. package/dist/assets/{server-client-LoWPK1N2.js → server-client-DP8fMPY9.js} +1 -1
  22. package/dist/assets/{wasm-bridge-BsJGgPMs.js → wasm-bridge-CErti6zX.js} +1 -1
  23. package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
  24. package/dist/index.html +8 -8
  25. package/index.html +1 -1
  26. package/package.json +10 -10
  27. package/src/components/viewer/BasketPresentationDock.tsx +3 -0
  28. package/src/components/viewer/CesiumOverlay.tsx +165 -120
  29. package/src/components/viewer/DeviationPanel.tsx +172 -0
  30. package/src/components/viewer/HierarchyPanel.tsx +29 -3
  31. package/src/components/viewer/HoverTooltip.tsx +5 -0
  32. package/src/components/viewer/IDSAuditSummary.tsx +389 -0
  33. package/src/components/viewer/IDSPanel.tsx +80 -26
  34. package/src/components/viewer/MainToolbar.tsx +60 -7
  35. package/src/components/viewer/MergeLayersBanner.tsx +108 -0
  36. package/src/components/viewer/MobileToolbar.tsx +326 -0
  37. package/src/components/viewer/PointCloudClasses.tsx +111 -0
  38. package/src/components/viewer/PointCloudLegend.tsx +119 -0
  39. package/src/components/viewer/PointCloudPanel.tsx +52 -1
  40. package/src/components/viewer/PropertiesPanel.tsx +37 -6
  41. package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
  42. package/src/components/viewer/StatusBar.tsx +14 -0
  43. package/src/components/viewer/ViewerLayout.tsx +288 -95
  44. package/src/components/viewer/Viewport.tsx +86 -18
  45. package/src/components/viewer/ViewportContainer.tsx +25 -11
  46. package/src/components/viewer/ViewportOverlays.tsx +41 -26
  47. package/src/components/viewer/mouseHandlerTypes.ts +22 -0
  48. package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
  49. package/src/components/viewer/properties/MaterialCard.tsx +2 -2
  50. package/src/components/viewer/selectionHandlers.ts +41 -0
  51. package/src/components/viewer/tools/SectionPanel.tsx +181 -24
  52. package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
  53. package/src/components/viewer/useAnimationLoop.ts +22 -0
  54. package/src/components/viewer/useMouseControls.ts +296 -3
  55. package/src/components/viewer/usePointCloudSync.ts +8 -1
  56. package/src/components/viewer/useRenderUpdates.ts +21 -1
  57. package/src/components/viewer/useTouchControls.ts +100 -41
  58. package/src/hooks/federationLoadGate.test.ts +90 -0
  59. package/src/hooks/federationLoadGate.ts +127 -0
  60. package/src/hooks/ids/idsDataAccessor.ts +11 -259
  61. package/src/hooks/ingest/pointCloudIngest.ts +127 -16
  62. package/src/hooks/useDrawingGeneration.ts +81 -8
  63. package/src/hooks/useIDS.ts +90 -10
  64. package/src/hooks/useIfcFederation.ts +94 -16
  65. package/src/hooks/useIfcLoader.ts +289 -64
  66. package/src/hooks/useViewerSelectors.ts +10 -0
  67. package/src/lib/geo/cesium-bridge.ts +84 -67
  68. package/src/lib/geo/clamp-anchor.test.ts +80 -0
  69. package/src/lib/geo/clamp-anchor.ts +57 -0
  70. package/src/lib/geo/effective-georef.test.ts +79 -1
  71. package/src/lib/geo/effective-georef.ts +83 -0
  72. package/src/lib/geo/reproject.ts +26 -13
  73. package/src/lib/geo/terrain-elevation.ts +166 -0
  74. package/src/lib/lens/adapter.ts +1 -1
  75. package/src/lib/llm/context-builder.ts +1 -1
  76. package/src/lib/perf/memoryAccounting.test.ts +92 -0
  77. package/src/lib/perf/memoryAccounting.ts +235 -0
  78. package/src/sdk/adapters/mutation-view.ts +1 -1
  79. package/src/store/constants.ts +39 -2
  80. package/src/store/index.ts +6 -1
  81. package/src/store/slices/cesiumSlice.ts +1 -1
  82. package/src/store/slices/idsSlice.ts +24 -0
  83. package/src/store/slices/loadingSlice.ts +12 -0
  84. package/src/store/slices/pointCloudSlice.ts +72 -1
  85. package/src/store/slices/sectionSlice.test.ts +590 -1
  86. package/src/store/slices/sectionSlice.ts +344 -17
  87. package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
  88. package/src/store/slices/uiSlice.ts +60 -2
  89. package/src/store/types.ts +42 -0
  90. package/src/store.ts +13 -0
  91. package/src/utils/acquireFileBuffer.test.ts +231 -0
  92. package/src/utils/acquireFileBuffer.ts +128 -0
  93. package/src/utils/ifcConfig.ts +24 -0
  94. package/src/utils/nativeSpatialDataStore.ts +20 -2
  95. package/src/utils/spatialHierarchy.test.ts +116 -0
  96. package/src/utils/spatialHierarchy.ts +23 -0
  97. package/tailwind.config.js +5 -0
  98. package/tsconfig.json +1 -0
  99. package/vite.config.ts +6 -0
  100. package/dist/assets/decode-worker-Collf_X_.js +0 -1320
  101. package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
  102. package/dist/assets/exporters-xbXqEDlO.js +0 -81590
  103. package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
  104. package/dist/assets/ids-2WdONLlu.js +0 -2033
  105. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  106. package/dist/assets/index-BXeEKqJG.css +0 -1
@@ -3,271 +3,23 @@
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
5
  /**
6
- * IDS Data Accessor Factory
6
+ * IDS Data Accessor — thin wrapper around the canonical bridge.
7
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.
8
+ * The actual `IfcDataStore IFCDataAccessor` translation lives in
9
+ * `@ifc-lite/ids/bridge` so the viewer, the corpus-parity harness,
10
+ * and the MCP server share one implementation. Keeping this file as
11
+ * a re-export preserves the existing import path for callers that
12
+ * pass through `_modelId` (currently unused but preserved for API
13
+ * stability — the validator already takes a `modelInfo` separately).
11
14
  */
12
15
 
13
- import type {
14
- IFCDataAccessor,
15
- PropertyValueResult,
16
- PropertySetInfo,
17
- ClassificationInfo,
18
- MaterialInfo,
19
- ParentInfo,
20
- PartOfRelation,
21
- } from '@ifc-lite/ids';
22
- import {
23
- type IfcDataStore,
24
- extractAllEntityAttributes,
25
- extractTypeEntityOwnProperties,
26
- } from '@ifc-lite/parser';
16
+ import type { IFCDataAccessor } from '@ifc-lite/ids';
17
+ import { createDataAccessor as createBridgeAccessor } from '@ifc-lite/ids/bridge';
18
+ import type { IfcDataStore } from '@ifc-lite/parser';
27
19
 
28
- /**
29
- * Create an IFCDataAccessor from an IfcDataStore
30
- * This bridges the viewer's data store to the IDS validator's interface
31
- */
32
20
  export function createDataAccessor(
33
21
  dataStore: IfcDataStore,
34
22
  _modelId: string
35
23
  ): IFCDataAccessor {
36
- return {
37
- getEntityType(expressId: number): string | undefined {
38
- // Try entities table first
39
- const entityType = dataStore.entities?.getTypeName?.(expressId);
40
- if (entityType) return entityType;
41
-
42
- // Fallback to entityIndex
43
- const byId = dataStore.entityIndex?.byId;
44
- if (byId) {
45
- const entry = byId.get(expressId);
46
- if (entry) {
47
- return typeof entry === 'object' && 'type' in entry ? String(entry.type) : undefined;
48
- }
49
- }
50
- return undefined;
51
- },
52
-
53
- getEntityName(expressId: number): string | undefined {
54
- return dataStore.entities?.getName?.(expressId);
55
- },
56
-
57
- getGlobalId(expressId: number): string | undefined {
58
- return dataStore.entities?.getGlobalId?.(expressId);
59
- },
60
-
61
- getDescription(expressId: number): string | undefined {
62
- return dataStore.entities?.getDescription?.(expressId);
63
- },
64
-
65
- getObjectType(expressId: number): string | undefined {
66
- // Try the pre-computed ObjectType first (works for IfcObject subtypes)
67
- const objectType = dataStore.entities?.getObjectType?.(expressId);
68
- if (objectType) return objectType;
69
-
70
- // For IfcTypeObject subtypes (IfcWallType, etc.), ObjectType doesn't exist —
71
- // we need to extract PredefinedType from entity attributes instead.
72
- // Also handles IfcObject subtypes that have PredefinedType at different positions.
73
- const allAttrs = extractAllEntityAttributes(dataStore, expressId);
74
- const predefinedType = allAttrs.find(a => a.name === 'PredefinedType');
75
- if (predefinedType?.value && predefinedType.value !== 'NOTDEFINED') {
76
- return predefinedType.value;
77
- }
78
-
79
- // If PredefinedType is USERDEFINED or absent, check ObjectType from attributes
80
- const objTypeAttr = allAttrs.find(a => a.name === 'ObjectType');
81
- if (objTypeAttr?.value) {
82
- return objTypeAttr.value;
83
- }
84
-
85
- return undefined;
86
- },
87
-
88
- getEntitiesByType(typeName: string): number[] {
89
- const byType = dataStore.entityIndex?.byType;
90
- if (byType) {
91
- const ids = byType.get(typeName.toUpperCase());
92
- if (ids) return Array.from(ids);
93
- }
94
- return [];
95
- },
96
-
97
- getAllEntityIds(): number[] {
98
- const byId = dataStore.entityIndex?.byId;
99
- if (byId) {
100
- return Array.from(byId.keys());
101
- }
102
- return [];
103
- },
104
-
105
- getPropertyValue(
106
- expressId: number,
107
- propertySetName: string,
108
- propertyName: string
109
- ): PropertyValueResult | undefined {
110
- const propertiesStore = dataStore.properties;
111
- if (!propertiesStore) return undefined;
112
-
113
- // Get property sets for this entity using getForEntity (returns PropertySet[])
114
- const psets = propertiesStore.getForEntity?.(expressId);
115
- if (!psets) return undefined;
116
-
117
- for (const pset of psets) {
118
- if (pset.name.toLowerCase() === propertySetName.toLowerCase()) {
119
- const props = pset.properties || [];
120
- for (const prop of props) {
121
- if (prop.name.toLowerCase() === propertyName.toLowerCase()) {
122
- // Convert value: ensure it's a primitive type (not array)
123
- let value: string | number | boolean | null = null;
124
- if (Array.isArray(prop.value)) {
125
- // For arrays, convert to string representation
126
- value = JSON.stringify(prop.value);
127
- } else {
128
- value = prop.value as string | number | boolean | null;
129
- }
130
- return {
131
- value,
132
- dataType: String(prop.type || 'IFCLABEL'),
133
- propertySetName: pset.name,
134
- propertyName: prop.name,
135
- };
136
- }
137
- }
138
- }
139
- }
140
- return undefined;
141
- },
142
-
143
- getPropertySets(expressId: number): PropertySetInfo[] {
144
- const propertiesStore = dataStore.properties;
145
-
146
- // Try relationship-based properties first (works for IfcObject instances)
147
- const psets = propertiesStore?.getForEntity?.(expressId);
148
-
149
- // Convert property sets to the IDS format
150
- const mapPsets = (rawPsets: Array<{ name: string; properties: Array<{ name: string; value: unknown; type: unknown }> }>): PropertySetInfo[] =>
151
- rawPsets.map((pset) => ({
152
- name: pset.name,
153
- properties: (pset.properties || []).map((prop) => {
154
- let value: string | number | boolean | null = null;
155
- if (Array.isArray(prop.value)) {
156
- value = JSON.stringify(prop.value);
157
- } else {
158
- value = prop.value as string | number | boolean | null;
159
- }
160
- return {
161
- name: prop.name,
162
- value,
163
- dataType: String(prop.type || 'IFCLABEL'),
164
- };
165
- }),
166
- }));
167
-
168
- if (psets && psets.length > 0) {
169
- return mapPsets(psets);
170
- }
171
-
172
- // For IfcTypeObject subtypes (IfcWallType, IfcSlabType, etc.),
173
- // properties come from the HasPropertySets attribute (index 5),
174
- // not from IfcRelDefinesByProperties relationships.
175
- const typePsets = extractTypeEntityOwnProperties(dataStore, expressId);
176
- if (typePsets.length > 0) {
177
- return mapPsets(typePsets);
178
- }
179
-
180
- return [];
181
- },
182
-
183
- getClassifications(expressId: number): ClassificationInfo[] {
184
- // Classifications might be stored separately or in properties
185
- // This is a placeholder - implement based on actual data structure
186
- const classifications: ClassificationInfo[] = [];
187
-
188
- // Check if there's a classifications accessor
189
- const classStore = (dataStore as { classifications?: { getForEntity?: (id: number) => ClassificationInfo[] } }).classifications;
190
- if (classStore?.getForEntity) {
191
- return classStore.getForEntity(expressId);
192
- }
193
-
194
- return classifications;
195
- },
196
-
197
- getMaterials(expressId: number): MaterialInfo[] {
198
- // Materials might be stored separately or in relationships
199
- const materials: MaterialInfo[] = [];
200
-
201
- // Check if there's a materials accessor
202
- const matStore = (dataStore as { materials?: { getForEntity?: (id: number) => MaterialInfo[] } }).materials;
203
- if (matStore?.getForEntity) {
204
- return matStore.getForEntity(expressId);
205
- }
206
-
207
- return materials;
208
- },
209
-
210
- getParent(
211
- expressId: number,
212
- relationType: PartOfRelation
213
- ): ParentInfo | undefined {
214
- const relationships = dataStore.relationships;
215
- if (!relationships) return undefined;
216
-
217
- // Map IDS relation type to internal relation type
218
- const relationMap: Record<PartOfRelation, string> = {
219
- 'IfcRelAggregates': 'Aggregates',
220
- 'IfcRelContainedInSpatialStructure': 'ContainedInSpatialStructure',
221
- 'IfcRelNests': 'Nests',
222
- 'IfcRelVoidsElement': 'VoidsElement',
223
- 'IfcRelFillsElement': 'FillsElement',
224
- };
225
-
226
- const relType = relationMap[relationType];
227
- if (!relType) return undefined;
228
-
229
- // Get related entities (parent direction)
230
- const getRelated = relationships.getRelated;
231
- if (getRelated) {
232
- const parents = getRelated(expressId, relType as never, 'inverse');
233
- if (parents && parents.length > 0) {
234
- const parentId = parents[0];
235
- return {
236
- expressId: parentId,
237
- entityType: this.getEntityType(parentId) || 'Unknown',
238
- predefinedType: this.getObjectType(parentId),
239
- };
240
- }
241
- }
242
-
243
- return undefined;
244
- },
245
-
246
- getAttribute(expressId: number, attributeName: string): string | undefined {
247
- const lowerName = attributeName.toLowerCase();
248
-
249
- // Map common attribute names to accessor methods
250
- switch (lowerName) {
251
- case 'name':
252
- return this.getEntityName(expressId);
253
- case 'description':
254
- return this.getDescription(expressId);
255
- case 'globalid':
256
- return this.getGlobalId(expressId);
257
- case 'objecttype':
258
- case 'predefinedtype':
259
- return this.getObjectType(expressId);
260
- default: {
261
- // Try to get from entities table if available
262
- const entities = dataStore.entities as {
263
- getAttribute?: (id: number, attr: string) => string | undefined;
264
- };
265
- if (entities?.getAttribute) {
266
- return entities.getAttribute(expressId, attributeName);
267
- }
268
- return undefined;
269
- }
270
- }
271
- },
272
- };
24
+ return createBridgeAccessor(dataStore);
273
25
  }
@@ -23,7 +23,7 @@ import type { IfcDataStore } from '@ifc-lite/parser';
23
23
  import type { SchemaVersion } from '../../store/types.js';
24
24
  import { createCoordinateInfo } from '../../utils/localParsingUtils.js';
25
25
 
26
- export type PointCloudFormat = 'las' | 'laz' | 'ply' | 'pcd' | 'e57';
26
+ export type PointCloudFormat = 'las' | 'laz' | 'ply' | 'pcd' | 'e57' | 'pts' | 'xyz';
27
27
 
28
28
  /**
29
29
  * IfcTypeEnum.IfcGeographicElement — the closest IFC4 entity for a scan
@@ -182,27 +182,60 @@ export function detectPointCloudFormat(
182
182
  fileName: string,
183
183
  buffer: ArrayBuffer | null,
184
184
  ): PointCloudFormat | null {
185
- const lower = fileName.toLowerCase();
186
- if (lower.endsWith('.las')) return 'las';
187
- if (lower.endsWith('.laz')) return 'laz';
188
- if (lower.endsWith('.ply')) return 'ply';
189
- if (lower.endsWith('.pcd')) return 'pcd';
190
- if (lower.endsWith('.e57')) return 'e57';
185
+ // Magic bytes win over extension when both are available — a LAS
186
+ // file dropped as `*.ply` should still load as LAS, not be forced
187
+ // through the wrong decoder. PTS / XYZ are ASCII so they have no
188
+ // distinctive magic and stay extension-only at the bottom.
191
189
  if (buffer && buffer.byteLength >= 8) {
192
190
  const view = new DataView(buffer, 0, Math.min(buffer.byteLength, 32));
193
- if (view.getUint32(0, true) === 0x4653414c) return 'las';
194
- // ASCII probe first three bytes "ply" PLY; "# .P" or ".PCD" → PCD.
195
- const b0 = view.getUint8(0), b1 = view.getUint8(1), b2 = view.getUint8(2);
196
- if (b0 === 0x70 /* p */ && b1 === 0x6c /* l */ && b2 === 0x79 /* y */) return 'ply';
197
- if (b0 === 0x23 /* # */ && view.byteLength > 4 && view.getUint8(2) === 0x2e /* . */) return 'pcd';
198
- // E57 magic = "ASTM-E57" (8 bytes)
191
+ // E57 magic = "ASTM-E57" (8 bytes) check before LAS so files
192
+ // can't accidentally match on the LAS magic in their first 4 bytes.
199
193
  if (
200
194
  view.getUint8(0) === 0x41 && view.getUint8(1) === 0x53
201
195
  && view.getUint8(2) === 0x54 && view.getUint8(3) === 0x4d
202
196
  && view.getUint8(4) === 0x2d && view.getUint8(5) === 0x45
203
197
  && view.getUint8(6) === 0x35 && view.getUint8(7) === 0x37
204
198
  ) return 'e57';
199
+ if (view.getUint32(0, true) === 0x4653414c /* "LASF" little-endian */) {
200
+ // LAS and LAZ share the LASF magic; differentiate by extension
201
+ // when available, otherwise default to LAS (laz-perf will throw
202
+ // a clear error on a non-LAZ payload).
203
+ const lower = fileName.toLowerCase();
204
+ if (lower.endsWith('.laz')) return 'laz';
205
+ return 'las';
206
+ }
207
+ // ASCII probes: "ply" header / PCD header line.
208
+ const b0 = view.getUint8(0), b1 = view.getUint8(1), b2 = view.getUint8(2);
209
+ if (b0 === 0x70 /* p */ && b1 === 0x6c /* l */ && b2 === 0x79 /* y */) return 'ply';
210
+ // PCDs in the wild use three header shapes:
211
+ // 1. `# .PCD v0.7\n…` — original commented header
212
+ // 2. `VERSION 0.7\n…` — version-first (PCL pcl_io)
213
+ // 3. `FIELDS x y z\n…` — fields-first (some converters)
214
+ // Match all three so a renamed PCD doesn't fall through to the
215
+ // extension-based detector.
216
+ if (b0 === 0x23 /* # */ && view.byteLength > 4 && view.getUint8(2) === 0x2e /* . */) return 'pcd';
217
+ if (
218
+ b0 === 0x56 /* V */ && b1 === 0x45 /* E */ && b2 === 0x52 /* R */
219
+ && view.byteLength > 7 && view.getUint8(3) === 0x53 /* S */
220
+ && view.getUint8(4) === 0x49 /* I */ && view.getUint8(5) === 0x4f /* O */
221
+ && view.getUint8(6) === 0x4e /* N */
222
+ ) return 'pcd';
223
+ if (
224
+ b0 === 0x46 /* F */ && b1 === 0x49 /* I */ && b2 === 0x45 /* E */
225
+ && view.byteLength > 6 && view.getUint8(3) === 0x4c /* L */
226
+ && view.getUint8(4) === 0x44 /* D */ && view.getUint8(5) === 0x53 /* S */
227
+ ) return 'pcd';
205
228
  }
229
+ // Fall back to extension when the buffer is missing / too short
230
+ // OR for ASCII formats (PTS / XYZ) that don't carry a magic header.
231
+ const lower = fileName.toLowerCase();
232
+ if (lower.endsWith('.las')) return 'las';
233
+ if (lower.endsWith('.laz')) return 'laz';
234
+ if (lower.endsWith('.ply')) return 'ply';
235
+ if (lower.endsWith('.pcd')) return 'pcd';
236
+ if (lower.endsWith('.e57')) return 'e57';
237
+ if (lower.endsWith('.pts')) return 'pts';
238
+ if (lower.endsWith('.xyz')) return 'xyz';
206
239
  return null;
207
240
  }
208
241
 
@@ -228,9 +261,6 @@ export function describeUnsupportedFormat(fileName: string): string | null {
228
261
  if (lower.endsWith('.fls') || lower.endsWith('.lsproj')) {
229
262
  return 'Faro Scene project — export to E57 from Scene to load it here.';
230
263
  }
231
- if (lower.endsWith('.pts') || lower.endsWith('.xyz')) {
232
- return 'PTS / XYZ ASCII points — not yet supported (export to PLY or LAS).';
233
- }
234
264
  return null;
235
265
  }
236
266
 
@@ -243,6 +273,76 @@ export function describeUnsupportedFormat(fileName: string): string | null {
243
273
  */
244
274
  let nextSyntheticExpressId = 1;
245
275
 
276
+ /**
277
+ * Counter shared across all in-flight ingests. We log up to
278
+ * `DEBUG_CLASS_LOG_LIMIT` chunks total per page session — enough to
279
+ * see whether the first scan's classifications are reaching the
280
+ * renderer without spamming the console for users with many files.
281
+ *
282
+ * Reset to zero on a hot module reload (HMR re-evaluates the module),
283
+ * so the dev workflow is "load file → see ≤ 3 chunk diagnostics".
284
+ */
285
+ const DEBUG_CLASS_LOG_LIMIT = 3;
286
+ let debugClassChunkLogs = 0;
287
+
288
+ /**
289
+ * Log presence + 16-bin histogram of the chunk's classification IDs.
290
+ * Used to debug "classification colour mode shows everything as
291
+ * unclassified". Common causes the histogram surfaces immediately:
292
+ * - chunk.classifications is undefined → format / decoder didn't
293
+ * emit it (look at the format's streaming source).
294
+ * - All values 0 or 1 → file is genuinely unclassified (LAS spec
295
+ * classes 0 = "Created, never classified", 1 = "Unclassified");
296
+ * not a viewer bug.
297
+ * - Non-trivial spread but rendering is grey → packing or shader
298
+ * read is wrong.
299
+ */
300
+ function logChunkClassHistogram(
301
+ fileName: string,
302
+ format: PointCloudFormat,
303
+ chunk: DecodedPointChunk,
304
+ ): void {
305
+ const classes = chunk.classifications;
306
+ if (!classes) {
307
+ // E57 has no standard classification field per ASTM E2807, so
308
+ // most scans (Faro Focus, Leica BLK, Trimble) won't carry one.
309
+ // A non-standard `classification` prototype field IS now read
310
+ // when present; absence here means the file genuinely doesn't
311
+ // include per-point class IDs.
312
+ const hint = format === 'e57'
313
+ ? ' (E57 spec doesn\'t define classification — file must be from CloudCompare or a custom LIDAR pipeline to have it)'
314
+ : ' (decoder didn\'t emit any per-point class IDs)';
315
+ console.log(
316
+ `[pointcloud-debug] ${format} ${fileName} chunk #${debugClassChunkLogs}: `
317
+ + `pointCount=${chunk.pointCount} classifications=undefined${hint}`,
318
+ );
319
+ return;
320
+ }
321
+ // 32-wide histogram (covers the ASPRS LAS 1.4 standard range).
322
+ // Anything past 31 lands in `overflow` so misclassified high
323
+ // values still surface.
324
+ const hist = new Uint32Array(32);
325
+ let overflow = 0;
326
+ let sample: number[] = [];
327
+ const n = Math.min(classes.length, chunk.pointCount);
328
+ for (let i = 0; i < n; i++) {
329
+ const c = classes[i];
330
+ if (c < 32) hist[c]++;
331
+ else overflow++;
332
+ if (sample.length < 8) sample.push(c);
333
+ }
334
+ const nonZero: string[] = [];
335
+ for (let c = 0; c < 32; c++) {
336
+ if (hist[c] > 0) nonZero.push(`${c}=${hist[c]}`);
337
+ }
338
+ if (overflow > 0) nonZero.push(`>31:${overflow}`);
339
+ console.log(
340
+ `[pointcloud-debug] ${format} ${fileName} chunk #${debugClassChunkLogs}: `
341
+ + `pointCount=${chunk.pointCount} classes.length=${classes.length} `
342
+ + `first8=[${sample.join(',')}] hist={${nonZero.join(', ')}}`,
343
+ );
344
+ }
345
+
246
346
  /**
247
347
  * Stream a point cloud into the renderer. Returns immediately; await
248
348
  * `result.done` for completion.
@@ -284,6 +384,17 @@ export function ingestPointCloud(opts: PointCloudIngestOptions): PointCloudInges
284
384
  });
285
385
  },
286
386
  onChunk: (chunk) => {
387
+ // Per-chunk classification diagnostic. Logs whether the
388
+ // chunk carries a classifications buffer and a 16-bin class
389
+ // histogram for the first few chunks of each stream so it's
390
+ // easy to see whether the source actually carries class IDs
391
+ // (LAS files often have everything as 0/1 for "unclassified").
392
+ // Capped at 3 logs per stream to keep the console readable;
393
+ // further debug-on-demand can be done from devtools.
394
+ if (debugClassChunkLogs < DEBUG_CLASS_LOG_LIMIT) {
395
+ debugClassChunkLogs++;
396
+ logChunkClassHistogram(opts.fileName, opts.format, chunk);
397
+ }
287
398
  // LAS / LAZ / E57 / typical scan-style PLY + PCD all store data
288
399
  // Z-up by convention (LIDAR / surveying tradition). The renderer
289
400
  // is Y-up internally — the IFCx ingest path applies the same
@@ -23,6 +23,7 @@ import {
23
23
  type SectionConfig,
24
24
  } from '@ifc-lite/drawing-2d';
25
25
  import { GeometryProcessor, type GeometryResult } from '@ifc-lite/geometry';
26
+ import { customPlaneCenter } from '@/store';
26
27
 
27
28
  // Axis conversion from semantic (down/front/side) to geometric (x/y/z)
28
29
  export const AXIS_MAP: Record<'down' | 'front' | 'side', 'x' | 'y' | 'z'> = {
@@ -34,7 +35,24 @@ export const AXIS_MAP: Record<'down' | 'front' | 'side', 'x' | 'y' | 'z'> = {
34
35
  interface UseDrawingGenerationParams {
35
36
  geometryResult: GeometryResult | null | undefined;
36
37
  ifcDataStore: { source: Uint8Array } | null;
37
- sectionPlane: { axis: 'down' | 'front' | 'side'; position: number; flipped: boolean };
38
+ /**
39
+ * Section plane state. `custom` is the optional face-pick override
40
+ * (issue #243); when set the cutter cuts on that arbitrary plane and
41
+ * the cap basis flows from `custom.tangent`/`bitangent` so the cap
42
+ * silhouette lands precisely on the tilted plane.
43
+ */
44
+ sectionPlane: {
45
+ axis: 'down' | 'front' | 'side';
46
+ position: number;
47
+ flipped: boolean;
48
+ custom?: {
49
+ normal: [number, number, number];
50
+ distance: number;
51
+ pickedAt: [number, number, number];
52
+ tangent: [number, number, number];
53
+ bitangent: [number, number, number];
54
+ };
55
+ };
38
56
  displayOptions: { showHiddenLines: boolean; useSymbolicRepresentations: boolean; show3DOverlay: boolean; scale: number };
39
57
  combinedHiddenIds: Set<number>;
40
58
  combinedIsolatedIds: Set<number> | null;
@@ -275,6 +293,31 @@ export function useDrawingGeneration({
275
293
  // Override the flipped setting
276
294
  config.plane.flipped = sectionPlane.flipped;
277
295
 
296
+ // Face-pick custom plane (issue #243): hand the cutter the explicit
297
+ // basis so its 2D output sits in the same coordinate system the cap
298
+ // shader will lift back to 3D — without this the polygon and the
299
+ // shader-clipped silhouette would disagree on every non-cardinal
300
+ // pick (PR #581's bug).
301
+ if (sectionPlane.custom) {
302
+ const c = sectionPlane.custom;
303
+ // Use the LIVE plane anchor (pickedAt projected onto the current
304
+ // plane), not pickedAt itself. As the user drags the gizmo only
305
+ // `distance` changes — pickedAt sits off the live plane, and
306
+ // using it as the basis origin makes the round-trip lift drop
307
+ // the normal-component, freezing the cap polygons at the
308
+ // original pick location while the geometry clip slides. Using
309
+ // the projected center keeps the basis origin ON the live plane
310
+ // so the cutter's 2D points lift back to the actual cut surface.
311
+ const origin = customPlaneCenter(c);
312
+ config.plane.customPlane = {
313
+ normal: { x: c.normal[0], y: c.normal[1], z: c.normal[2] },
314
+ distance: c.distance,
315
+ origin: { x: origin[0], y: origin[1], z: origin[2] },
316
+ tangent: { x: c.tangent[0], y: c.tangent[1], z: c.tangent[2] },
317
+ bitangent: { x: c.bitangent[0], y: c.bitangent[1], z: c.bitangent[2] },
318
+ };
319
+ }
320
+
278
321
  // Filter meshes by visibility (respect 3D hiding/isolation)
279
322
  let meshesToProcess = geometryResult.meshes;
280
323
 
@@ -553,9 +596,26 @@ export function useDrawingGeneration({
553
596
  // Auto-regenerate when section plane changes
554
597
  // Strategy: INSTANT - no debounce, but prevent overlapping computations
555
598
  // The generation time itself acts as natural batching for fast slider movements
556
- const sectionRef = useRef({ axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped });
599
+ //
600
+ // For face-picked custom planes (issue #243), `customKey` collapses the
601
+ // plane's normal+distance into a string we can compare cheaply — without
602
+ // it dragging the gizmo wouldn't trigger regeneration because the
603
+ // cardinal axis/position/flipped triple stays the same.
604
+ const customKey = (sp: { custom?: { normal: [number, number, number]; distance: number } }) =>
605
+ sp.custom ? `${sp.custom.normal.join(',')}|${sp.custom.distance}` : '';
606
+ const sectionRef = useRef({
607
+ axis: sectionPlane.axis,
608
+ position: sectionPlane.position,
609
+ flipped: sectionPlane.flipped,
610
+ customKey: customKey(sectionPlane),
611
+ });
557
612
  const isGeneratingRef = useRef(false);
558
- const latestSectionRef = useRef({ axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped });
613
+ const latestSectionRef = useRef({
614
+ axis: sectionPlane.axis,
615
+ position: sectionPlane.position,
616
+ flipped: sectionPlane.flipped,
617
+ customKey: customKey(sectionPlane),
618
+ });
559
619
  const [isRegenerating, setIsRegenerating] = useState(false);
560
620
 
561
621
  // Stable regenerate function that handles overlapping calls
@@ -583,7 +643,8 @@ export function useDrawingGeneration({
583
643
  if (
584
644
  current.axis !== targetSection.axis ||
585
645
  current.position !== targetSection.position ||
586
- current.flipped !== targetSection.flipped
646
+ current.flipped !== targetSection.flipped ||
647
+ current.customKey !== targetSection.customKey
587
648
  ) {
588
649
  // Position changed during generation - regenerate immediately with latest
589
650
  // Use microtask to avoid blocking
@@ -592,22 +653,34 @@ export function useDrawingGeneration({
592
653
  }
593
654
  }, [generateDrawing]);
594
655
 
656
+ const customKeyValue = customKey(sectionPlane);
595
657
  useEffect(() => {
596
658
  // Always update latest section ref (even if generating)
597
- latestSectionRef.current = { axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped };
659
+ latestSectionRef.current = {
660
+ axis: sectionPlane.axis,
661
+ position: sectionPlane.position,
662
+ flipped: sectionPlane.flipped,
663
+ customKey: customKeyValue,
664
+ };
598
665
 
599
666
  // Check if section plane actually changed from last processed
600
667
  const prev = sectionRef.current;
601
668
  if (
602
669
  prev.axis === sectionPlane.axis &&
603
670
  prev.position === sectionPlane.position &&
604
- prev.flipped === sectionPlane.flipped
671
+ prev.flipped === sectionPlane.flipped &&
672
+ prev.customKey === customKeyValue
605
673
  ) {
606
674
  return;
607
675
  }
608
676
 
609
677
  // Update processed ref
610
- sectionRef.current = { axis: sectionPlane.axis, position: sectionPlane.position, flipped: sectionPlane.flipped };
678
+ sectionRef.current = {
679
+ axis: sectionPlane.axis,
680
+ position: sectionPlane.position,
681
+ flipped: sectionPlane.flipped,
682
+ customKey: customKeyValue,
683
+ };
611
684
 
612
685
  // If panel is visible OR 3D overlay is enabled, and we have geometry, regenerate INSTANTLY
613
686
  if ((panelVisible || displayOptions.show3DOverlay) && geometryResult?.meshes) {
@@ -615,7 +688,7 @@ export function useDrawingGeneration({
615
688
  // doRegenerate handles preventing overlaps and will auto-regenerate with latest when done
616
689
  doRegenerate();
617
690
  }
618
- }, [panelVisible, displayOptions.show3DOverlay, sectionPlane.axis, sectionPlane.position, sectionPlane.flipped, geometryResult, combinedHiddenIds, combinedIsolatedIds, computedIsolatedIds, doRegenerate]);
691
+ }, [panelVisible, displayOptions.show3DOverlay, sectionPlane.axis, sectionPlane.position, sectionPlane.flipped, customKeyValue, geometryResult, combinedHiddenIds, combinedIsolatedIds, computedIsolatedIds, doRegenerate]);
619
692
 
620
693
  return {
621
694
  generateDrawing,