@ifc-lite/viewer 1.7.0 → 1.8.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 (43) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/assets/{Arrow.dom-BGPQieQQ.js → Arrow.dom-CwcRxist.js} +1 -1
  3. package/dist/assets/index-7WoQ-qVC.css +1 -0
  4. package/dist/assets/{index-dgdgiQ9p.js → index-BSANf7-H.js} +20926 -17587
  5. package/dist/assets/{native-bridge-DD0SNyQ5.js → native-bridge-5LbrYh3R.js} +1 -1
  6. package/dist/assets/{wasm-bridge-D54YMO7X.js → wasm-bridge-CgpLtj1h.js} +1 -1
  7. package/dist/index.html +2 -2
  8. package/package.json +18 -18
  9. package/src/components/viewer/Drawing2DCanvas.tsx +364 -1
  10. package/src/components/viewer/EntityContextMenu.tsx +47 -20
  11. package/src/components/viewer/ExportDialog.tsx +166 -17
  12. package/src/components/viewer/HierarchyPanel.tsx +3 -1
  13. package/src/components/viewer/LensPanel.tsx +848 -85
  14. package/src/components/viewer/MainToolbar.tsx +114 -81
  15. package/src/components/viewer/Section2DPanel.tsx +269 -29
  16. package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
  17. package/src/components/viewer/Viewport.tsx +57 -23
  18. package/src/components/viewer/ViewportContainer.tsx +2 -0
  19. package/src/components/viewer/hierarchy/HierarchyNode.tsx +1 -1
  20. package/src/components/viewer/hierarchy/types.ts +1 -1
  21. package/src/components/viewer/lists/ListResultsTable.tsx +53 -19
  22. package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
  23. package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
  24. package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
  25. package/src/components/viewer/tools/computePolygonArea.ts +72 -0
  26. package/src/components/viewer/useGeometryStreaming.ts +12 -4
  27. package/src/hooks/ids/idsExportService.ts +1 -1
  28. package/src/hooks/useAnnotation2D.ts +551 -0
  29. package/src/hooks/useDrawingExport.ts +83 -1
  30. package/src/hooks/useKeyboardShortcuts.ts +113 -14
  31. package/src/hooks/useLens.ts +39 -55
  32. package/src/hooks/useLensDiscovery.ts +46 -0
  33. package/src/hooks/useModelSelection.ts +5 -22
  34. package/src/index.css +7 -1
  35. package/src/lib/lens/adapter.ts +127 -1
  36. package/src/lib/lists/columnToAutoColor.ts +33 -0
  37. package/src/store/index.ts +14 -1
  38. package/src/store/resolveEntityRef.ts +44 -0
  39. package/src/store/slices/drawing2DSlice.ts +321 -0
  40. package/src/store/slices/lensSlice.ts +46 -4
  41. package/src/store/slices/pinboardSlice.ts +171 -38
  42. package/src/store.ts +3 -0
  43. package/dist/assets/index-yTqs8kgX.css +0 -1
@@ -0,0 +1,275 @@
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
+ * Revision cloud (scalloped border) path generation.
7
+ * Generates arc data for drawing cloud annotations on a canvas or SVG.
8
+ */
9
+
10
+ interface Point2D {
11
+ x: number;
12
+ y: number;
13
+ }
14
+
15
+ /** A single arc segment in the cloud border */
16
+ export interface CloudArc {
17
+ /** Start point of the arc */
18
+ start: Point2D;
19
+ /** End point of the arc */
20
+ end: Point2D;
21
+ /** Center of the arc circle */
22
+ center: Point2D;
23
+ /** Radius of the arc */
24
+ radius: number;
25
+ /** Start angle in radians */
26
+ startAngle: number;
27
+ /** End angle in radians */
28
+ endAngle: number;
29
+ }
30
+
31
+ /**
32
+ * Generate cloud arc data from two rectangle corner points.
33
+ * The two points define opposite corners of the rectangle.
34
+ * Arcs bulge outward from the rectangle edges.
35
+ *
36
+ * @param p1 First corner (e.g. top-left)
37
+ * @param p2 Second corner (e.g. bottom-right)
38
+ * @param arcRadius Radius of each scallop arc in drawing coords
39
+ * @returns Array of arc segments forming the cloud border
40
+ */
41
+ export function generateCloudArcs(
42
+ p1: Point2D,
43
+ p2: Point2D,
44
+ arcRadius: number
45
+ ): CloudArc[] {
46
+ // Build rectangle corners in clockwise order
47
+ const minX = Math.min(p1.x, p2.x);
48
+ const maxX = Math.max(p1.x, p2.x);
49
+ const minY = Math.min(p1.y, p2.y);
50
+ const maxY = Math.max(p1.y, p2.y);
51
+
52
+ const corners: Point2D[] = [
53
+ { x: minX, y: minY }, // top-left (in drawing coords, Y increases downward on screen)
54
+ { x: maxX, y: minY }, // top-right
55
+ { x: maxX, y: maxY }, // bottom-right
56
+ { x: minX, y: maxY }, // bottom-left
57
+ ];
58
+
59
+ const arcs: CloudArc[] = [];
60
+
61
+ // For each edge, generate scallop arcs
62
+ for (let i = 0; i < corners.length; i++) {
63
+ const edgeStart = corners[i];
64
+ const edgeEnd = corners[(i + 1) % corners.length];
65
+
66
+ const dx = edgeEnd.x - edgeStart.x;
67
+ const dy = edgeEnd.y - edgeStart.y;
68
+ const edgeLength = Math.sqrt(dx * dx + dy * dy);
69
+
70
+ if (edgeLength < 0.001) continue;
71
+
72
+ // Number of arcs along this edge
73
+ const arcDiameter = arcRadius * 2;
74
+ const arcCount = Math.max(1, Math.round(edgeLength / arcDiameter));
75
+ const segmentLength = edgeLength / arcCount;
76
+ const actualRadius = segmentLength / 2;
77
+
78
+ // Unit direction along edge
79
+ const ux = dx / edgeLength;
80
+ const uy = dy / edgeLength;
81
+
82
+ // Outward normal (perpendicular, pointing outward from rectangle)
83
+ // For clockwise winding, outward normal is to the right of the edge direction
84
+ const nx = uy;
85
+ const ny = -ux;
86
+
87
+ for (let j = 0; j < arcCount; j++) {
88
+ const t0 = j / arcCount;
89
+ const t1 = (j + 1) / arcCount;
90
+
91
+ const arcStart: Point2D = {
92
+ x: edgeStart.x + dx * t0,
93
+ y: edgeStart.y + dy * t0,
94
+ };
95
+ const arcEnd: Point2D = {
96
+ x: edgeStart.x + dx * t1,
97
+ y: edgeStart.y + dy * t1,
98
+ };
99
+
100
+ // Center of the arc is offset outward from the midpoint
101
+ const midX = (arcStart.x + arcEnd.x) / 2;
102
+ const midY = (arcStart.y + arcEnd.y) / 2;
103
+
104
+ // The arc center is at the midpoint of the segment (on the edge)
105
+ // The arc bulges outward by the radius amount
106
+ const center: Point2D = {
107
+ x: midX,
108
+ y: midY,
109
+ };
110
+
111
+ // Compute angles from center to start and end
112
+ const startAngle = Math.atan2(arcStart.y - center.y, arcStart.x - center.x);
113
+ const endAngle = Math.atan2(arcEnd.y - center.y, arcEnd.x - center.x);
114
+
115
+ arcs.push({
116
+ start: arcStart,
117
+ end: arcEnd,
118
+ center,
119
+ radius: actualRadius,
120
+ startAngle,
121
+ endAngle,
122
+ });
123
+ }
124
+ }
125
+
126
+ return arcs;
127
+ }
128
+
129
+ /**
130
+ * Draw cloud arcs on a Canvas 2D context.
131
+ * Arcs are drawn as semicircular bumps bulging outward.
132
+ */
133
+ export function drawCloudOnCanvas(
134
+ ctx: CanvasRenderingContext2D,
135
+ p1: Point2D,
136
+ p2: Point2D,
137
+ arcRadius: number,
138
+ toScreenX: (x: number) => number,
139
+ toScreenY: (y: number) => number,
140
+ screenScale: number
141
+ ): void {
142
+ const minX = Math.min(p1.x, p2.x);
143
+ const maxX = Math.max(p1.x, p2.x);
144
+ const minY = Math.min(p1.y, p2.y);
145
+ const maxY = Math.max(p1.y, p2.y);
146
+
147
+ const corners: Point2D[] = [
148
+ { x: minX, y: minY },
149
+ { x: maxX, y: minY },
150
+ { x: maxX, y: maxY },
151
+ { x: minX, y: maxY },
152
+ ];
153
+
154
+ ctx.beginPath();
155
+
156
+ for (let i = 0; i < corners.length; i++) {
157
+ const edgeStart = corners[i];
158
+ const edgeEnd = corners[(i + 1) % corners.length];
159
+
160
+ const dx = edgeEnd.x - edgeStart.x;
161
+ const dy = edgeEnd.y - edgeStart.y;
162
+ const edgeLength = Math.sqrt(dx * dx + dy * dy);
163
+
164
+ if (edgeLength < 0.001) continue;
165
+
166
+ const arcDiameter = arcRadius * 2;
167
+ const arcCount = Math.max(1, Math.round(edgeLength / arcDiameter));
168
+ const actualRadius = (edgeLength / arcCount) / 2;
169
+
170
+ // Unit direction along edge
171
+ const ux = dx / edgeLength;
172
+ const uy = dy / edgeLength;
173
+
174
+ // Outward normal for clockwise winding
175
+ const nx = uy;
176
+ const ny = -ux;
177
+
178
+ for (let j = 0; j < arcCount; j++) {
179
+ const t0 = j / arcCount;
180
+ const t1 = (j + 1) / arcCount;
181
+
182
+ const sx = edgeStart.x + dx * t0;
183
+ const sy = edgeStart.y + dy * t0;
184
+ const ex = edgeStart.x + dx * t1;
185
+ const ey = edgeStart.y + dy * t1;
186
+
187
+ // Arc center is on the edge at midpoint
188
+ const cx = (sx + ex) / 2;
189
+ const cy = (sy + ey) / 2;
190
+
191
+ // Convert to screen coords
192
+ const scx = toScreenX(cx);
193
+ const scy = toScreenY(cy);
194
+ const screenRadius = actualRadius * screenScale;
195
+
196
+ // Angles in screen space (Y may be flipped)
197
+ const ssx = toScreenX(sx);
198
+ const ssy = toScreenY(sy);
199
+ const sex = toScreenX(ex);
200
+ const sey = toScreenY(ey);
201
+
202
+ const startAngle = Math.atan2(ssy - scy, ssx - scx);
203
+ const endAngle = Math.atan2(sey - scy, sex - scx);
204
+
205
+ // Draw arc clockwise (false) so the semicircle bulges outward from the rectangle
206
+ ctx.arc(scx, scy, screenRadius, startAngle, endAngle, false);
207
+ }
208
+ }
209
+
210
+ ctx.closePath();
211
+ }
212
+
213
+ /**
214
+ * Generate SVG path data for a cloud annotation.
215
+ */
216
+ export function generateCloudSVGPath(
217
+ p1: Point2D,
218
+ p2: Point2D,
219
+ arcRadius: number,
220
+ transformX: (x: number) => number,
221
+ transformY: (y: number) => number,
222
+ ): string {
223
+ const minX = Math.min(p1.x, p2.x);
224
+ const maxX = Math.max(p1.x, p2.x);
225
+ const minY = Math.min(p1.y, p2.y);
226
+ const maxY = Math.max(p1.y, p2.y);
227
+
228
+ const corners: Point2D[] = [
229
+ { x: minX, y: minY },
230
+ { x: maxX, y: minY },
231
+ { x: maxX, y: maxY },
232
+ { x: minX, y: maxY },
233
+ ];
234
+
235
+ let path = '';
236
+
237
+ for (let i = 0; i < corners.length; i++) {
238
+ const edgeStart = corners[i];
239
+ const edgeEnd = corners[(i + 1) % corners.length];
240
+
241
+ const dx = edgeEnd.x - edgeStart.x;
242
+ const dy = edgeEnd.y - edgeStart.y;
243
+ const edgeLength = Math.sqrt(dx * dx + dy * dy);
244
+
245
+ if (edgeLength < 0.001) continue;
246
+
247
+ const arcDiameter = arcRadius * 2;
248
+ const arcCount = Math.max(1, Math.round(edgeLength / arcDiameter));
249
+ const segmentLength = edgeLength / arcCount;
250
+ const r = segmentLength / 2;
251
+
252
+ for (let j = 0; j < arcCount; j++) {
253
+ const t0 = j / arcCount;
254
+ const t1 = (j + 1) / arcCount;
255
+
256
+ const sx = transformX(edgeStart.x + (edgeEnd.x - edgeStart.x) * t0);
257
+ const sy = transformY(edgeStart.y + (edgeEnd.y - edgeStart.y) * t0);
258
+ const ex = transformX(edgeStart.x + (edgeEnd.x - edgeStart.x) * t1);
259
+ const ey = transformY(edgeStart.y + (edgeEnd.y - edgeStart.y) * t1);
260
+
261
+ // Move to start of first arc
262
+ if (i === 0 && j === 0) {
263
+ path += `M ${sx.toFixed(4)} ${sy.toFixed(4)}`;
264
+ }
265
+
266
+ // SVG arc: A rx ry x-rotation large-arc-flag sweep-flag x y
267
+ // sweep-flag=1 for clockwise (outward bulge from rectangle)
268
+ const trR = Math.sqrt((ex - sx) ** 2 + (ey - sy) ** 2) / 2;
269
+ path += ` A ${trR.toFixed(4)} ${trR.toFixed(4)} 0 0 1 ${ex.toFixed(4)} ${ey.toFixed(4)}`;
270
+ }
271
+ }
272
+
273
+ path += ' Z';
274
+ return path;
275
+ }
@@ -0,0 +1,165 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ import { describe, it } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import {
8
+ computePolygonArea,
9
+ computePolygonPerimeter,
10
+ computePolygonCentroid,
11
+ formatArea,
12
+ } from './computePolygonArea.js';
13
+
14
+ describe('computePolygonArea', () => {
15
+ it('returns 0 for fewer than 3 points', () => {
16
+ assert.strictEqual(computePolygonArea([]), 0);
17
+ assert.strictEqual(computePolygonArea([{ x: 0, y: 0 }]), 0);
18
+ assert.strictEqual(computePolygonArea([{ x: 0, y: 0 }, { x: 1, y: 0 }]), 0);
19
+ });
20
+
21
+ it('computes area of a unit square', () => {
22
+ const square = [
23
+ { x: 0, y: 0 },
24
+ { x: 1, y: 0 },
25
+ { x: 1, y: 1 },
26
+ { x: 0, y: 1 },
27
+ ];
28
+ assert.strictEqual(computePolygonArea(square), 1);
29
+ });
30
+
31
+ it('computes area of a right triangle', () => {
32
+ const triangle = [
33
+ { x: 0, y: 0 },
34
+ { x: 4, y: 0 },
35
+ { x: 0, y: 3 },
36
+ ];
37
+ assert.strictEqual(computePolygonArea(triangle), 6);
38
+ });
39
+
40
+ it('computes area of a rectangle', () => {
41
+ const rect = [
42
+ { x: 0, y: 0 },
43
+ { x: 5, y: 0 },
44
+ { x: 5, y: 3 },
45
+ { x: 0, y: 3 },
46
+ ];
47
+ assert.strictEqual(computePolygonArea(rect), 15);
48
+ });
49
+
50
+ it('returns positive area regardless of winding direction', () => {
51
+ // Counter-clockwise
52
+ const ccw = [
53
+ { x: 0, y: 0 },
54
+ { x: 0, y: 2 },
55
+ { x: 2, y: 2 },
56
+ { x: 2, y: 0 },
57
+ ];
58
+ assert.strictEqual(computePolygonArea(ccw), 4);
59
+ });
60
+
61
+ it('computes area of an irregular polygon', () => {
62
+ // L-shaped polygon (6 vertices)
63
+ const lShape = [
64
+ { x: 0, y: 0 },
65
+ { x: 3, y: 0 },
66
+ { x: 3, y: 1 },
67
+ { x: 1, y: 1 },
68
+ { x: 1, y: 2 },
69
+ { x: 0, y: 2 },
70
+ ];
71
+ // Area: 3*1 + 1*1 = 4
72
+ assert.strictEqual(computePolygonArea(lShape), 4);
73
+ });
74
+ });
75
+
76
+ describe('computePolygonPerimeter', () => {
77
+ it('returns 0 for fewer than 2 points', () => {
78
+ assert.strictEqual(computePolygonPerimeter([]), 0);
79
+ assert.strictEqual(computePolygonPerimeter([{ x: 0, y: 0 }]), 0);
80
+ });
81
+
82
+ it('computes perimeter of a unit square', () => {
83
+ const square = [
84
+ { x: 0, y: 0 },
85
+ { x: 1, y: 0 },
86
+ { x: 1, y: 1 },
87
+ { x: 0, y: 1 },
88
+ ];
89
+ assert.strictEqual(computePolygonPerimeter(square), 4);
90
+ });
91
+
92
+ it('computes perimeter of a 3-4-5 right triangle', () => {
93
+ const triangle = [
94
+ { x: 0, y: 0 },
95
+ { x: 4, y: 0 },
96
+ { x: 0, y: 3 },
97
+ ];
98
+ assert.strictEqual(computePolygonPerimeter(triangle), 12);
99
+ });
100
+
101
+ it('computes perimeter of a rectangle', () => {
102
+ const rect = [
103
+ { x: 0, y: 0 },
104
+ { x: 5, y: 0 },
105
+ { x: 5, y: 3 },
106
+ { x: 0, y: 3 },
107
+ ];
108
+ assert.strictEqual(computePolygonPerimeter(rect), 16);
109
+ });
110
+ });
111
+
112
+ describe('computePolygonCentroid', () => {
113
+ it('returns origin for empty polygon', () => {
114
+ const c = computePolygonCentroid([]);
115
+ assert.strictEqual(c.x, 0);
116
+ assert.strictEqual(c.y, 0);
117
+ });
118
+
119
+ it('returns the point for a single point', () => {
120
+ const c = computePolygonCentroid([{ x: 3, y: 7 }]);
121
+ assert.strictEqual(c.x, 3);
122
+ assert.strictEqual(c.y, 7);
123
+ });
124
+
125
+ it('computes centroid of a unit square at origin', () => {
126
+ const square = [
127
+ { x: 0, y: 0 },
128
+ { x: 2, y: 0 },
129
+ { x: 2, y: 2 },
130
+ { x: 0, y: 2 },
131
+ ];
132
+ const c = computePolygonCentroid(square);
133
+ assert.strictEqual(c.x, 1);
134
+ assert.strictEqual(c.y, 1);
135
+ });
136
+
137
+ it('computes centroid of a triangle', () => {
138
+ const triangle = [
139
+ { x: 0, y: 0 },
140
+ { x: 6, y: 0 },
141
+ { x: 0, y: 6 },
142
+ ];
143
+ const c = computePolygonCentroid(triangle);
144
+ assert.strictEqual(c.x, 2);
145
+ assert.strictEqual(c.y, 2);
146
+ });
147
+ });
148
+
149
+ describe('formatArea', () => {
150
+ it('formats small areas in cm²', () => {
151
+ assert.strictEqual(formatArea(0.005), '50.0 cm²');
152
+ assert.strictEqual(formatArea(0.001), '10.0 cm²');
153
+ });
154
+
155
+ it('formats medium areas in m²', () => {
156
+ assert.strictEqual(formatArea(1), '1.00 m²');
157
+ assert.strictEqual(formatArea(25.5), '25.50 m²');
158
+ assert.strictEqual(formatArea(100), '100.00 m²');
159
+ });
160
+
161
+ it('formats large areas in hectares', () => {
162
+ assert.strictEqual(formatArea(10000), '1.00 ha');
163
+ assert.strictEqual(formatArea(50000), '5.00 ha');
164
+ });
165
+ });
@@ -0,0 +1,72 @@
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
+ * Polygon area and perimeter computation utilities for 2D annotations.
7
+ * Uses the shoelace formula for area calculation.
8
+ */
9
+
10
+ interface Point2D {
11
+ x: number;
12
+ y: number;
13
+ }
14
+
15
+ /**
16
+ * Compute the signed area of a simple polygon using the shoelace formula.
17
+ * Returns absolute value (always positive).
18
+ */
19
+ export function computePolygonArea(points: Point2D[]): number {
20
+ if (points.length < 3) return 0;
21
+ let area = 0;
22
+ const n = points.length;
23
+ for (let i = 0; i < n; i++) {
24
+ const j = (i + 1) % n;
25
+ area += points[i].x * points[j].y;
26
+ area -= points[j].x * points[i].y;
27
+ }
28
+ return Math.abs(area) / 2;
29
+ }
30
+
31
+ /**
32
+ * Compute the perimeter of a closed polygon.
33
+ */
34
+ export function computePolygonPerimeter(points: Point2D[]): number {
35
+ if (points.length < 2) return 0;
36
+ let perimeter = 0;
37
+ const n = points.length;
38
+ for (let i = 0; i < n; i++) {
39
+ const j = (i + 1) % n;
40
+ const dx = points[j].x - points[i].x;
41
+ const dy = points[j].y - points[i].y;
42
+ perimeter += Math.sqrt(dx * dx + dy * dy);
43
+ }
44
+ return perimeter;
45
+ }
46
+
47
+ /**
48
+ * Compute the centroid (geometric center) of a polygon.
49
+ */
50
+ export function computePolygonCentroid(points: Point2D[]): Point2D {
51
+ if (points.length === 0) return { x: 0, y: 0 };
52
+ let cx = 0;
53
+ let cy = 0;
54
+ for (const p of points) {
55
+ cx += p.x;
56
+ cy += p.y;
57
+ }
58
+ return { x: cx / points.length, y: cy / points.length };
59
+ }
60
+
61
+ /**
62
+ * Format an area value for display with appropriate units.
63
+ */
64
+ export function formatArea(squareMeters: number): string {
65
+ if (squareMeters < 0.01) {
66
+ return `${(squareMeters * 10000).toFixed(1)} cm²`;
67
+ } else if (squareMeters < 10000) {
68
+ return `${squareMeters.toFixed(2)} m²`;
69
+ } else {
70
+ return `${(squareMeters / 10000).toFixed(2)} ha`;
71
+ }
72
+ }
@@ -372,10 +372,12 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
372
372
  prevIsStreamingRef.current = isStreaming;
373
373
  }, [isStreaming, isInitialized]);
374
374
 
375
- // Apply pending color updates to WebGPU scene
376
- // Note: Color updates may arrive before viewport is initialized, so we wait
375
+ // Apply pending color updates as overlay batches (lens coloring).
376
+ // Uses scene.setColorOverrides() which builds overlay batches rendered on top
377
+ // of original geometry via depthCompare 'equal'. Original batches are NEVER
378
+ // modified, so clearing lens is instant (no batch rebuild).
377
379
  useEffect(() => {
378
- if (!pendingColorUpdates || pendingColorUpdates.size === 0) return;
380
+ if (pendingColorUpdates === null) return;
379
381
 
380
382
  // Wait until viewport is initialized before applying color updates
381
383
  if (!isInitialized) return;
@@ -388,7 +390,13 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
388
390
  const scene = renderer.getScene();
389
391
 
390
392
  if (device && pipeline) {
391
- scene.updateMeshColors(pendingColorUpdates, device, pipeline);
393
+ if (pendingColorUpdates.size === 0) {
394
+ // Empty map = clear overrides (lens deactivated)
395
+ scene.clearColorOverrides();
396
+ } else {
397
+ // Non-empty map = set color overrides
398
+ scene.setColorOverrides(pendingColorUpdates, device, pipeline);
399
+ }
392
400
  renderer.render();
393
401
  clearPendingColorUpdates();
394
402
  }
@@ -331,7 +331,7 @@ export function buildReportHTML(report: IDSValidationReport, locale: SupportedLo
331
331
  <thead>
332
332
  <tr>
333
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>
334
+ <th class="col-type" onclick="sortTable(${i}, 1)">IFC Class <span class="sort-icon">&#x25B4;&#x25BE;</span></th>
335
335
  <th class="col-name" onclick="sortTable(${i}, 2)">Name <span class="sort-icon">&#x25B4;&#x25BE;</span></th>
336
336
  <th class="col-globalid" onclick="sortTable(${i}, 3)">GlobalId <span class="sort-icon">&#x25B4;&#x25BE;</span></th>
337
337
  <th class="col-expressid" onclick="sortTable(${i}, 4)">ID <span class="sort-icon">&#x25B4;&#x25BE;</span></th>