@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
@@ -1,4 +1,4 @@
1
- import { _ as c, __tla as __tla_0 } from "./index-dgdgiQ9p.js";
1
+ import { _ as c, __tla as __tla_0 } from "./index-BSANf7-H.js";
2
2
  let m;
3
3
  let __tla = Promise.all([
4
4
  (()=>{
@@ -1 +1 @@
1
- import{I as f,a as m}from"./index-dgdgiQ9p.js";class u{bridge;initialized=!1;constructor(){this.bridge=new f}async init(){this.initialized||(await this.bridge.init(),this.initialized=!0)}isInitialized(){return this.initialized}async processGeometry(s){this.initialized||await this.init(),performance.now();const i=new m(this.bridge.getApi(),s),n=i.collectMeshes(),r=i.getBuildingRotation();performance.now();let e=0,o=0;for(const c of n)e+=c.positions.length/3,o+=c.indices.length/3;return{meshes:n,totalVertices:e,totalTriangles:o,coordinateInfo:{originShift:{x:0,y:0,z:0},originalBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},shiftedBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},hasLargeCoordinates:!1,buildingRotation:r}}}async processGeometryStreaming(s,i){this.initialized||await this.init();const n=performance.now(),r=new m(this.bridge.getApi(),s);let e=0,o=0,a=0;try{for await(const t of r.collectMeshesStreaming(50)){if(t&&typeof t=="object"&&"type"in t&&t.type==="colorUpdate")continue;const l=t;e+=l.length;for(const d of l)o+=d.positions.length/3,a+=d.indices.length/3;i.onBatch?.({meshes:l,progress:{processed:e,total:e,currentType:"processing"}})}}catch(t){throw i.onError?.(t instanceof Error?t:new Error(String(t))),t}const h=performance.now()-n,g={totalMeshes:e,totalVertices:o,totalTriangles:a,parseTimeMs:h*.3,geometryTimeMs:h*.7};return i.onComplete?.(g),g}getApi(){return this.bridge.getApi()}}export{u as WasmBridge};
1
+ import{I as f,a as m}from"./index-BSANf7-H.js";class u{bridge;initialized=!1;constructor(){this.bridge=new f}async init(){this.initialized||(await this.bridge.init(),this.initialized=!0)}isInitialized(){return this.initialized}async processGeometry(s){this.initialized||await this.init(),performance.now();const i=new m(this.bridge.getApi(),s),n=i.collectMeshes(),r=i.getBuildingRotation();performance.now();let e=0,o=0;for(const c of n)e+=c.positions.length/3,o+=c.indices.length/3;return{meshes:n,totalVertices:e,totalTriangles:o,coordinateInfo:{originShift:{x:0,y:0,z:0},originalBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},shiftedBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},hasLargeCoordinates:!1,buildingRotation:r}}}async processGeometryStreaming(s,i){this.initialized||await this.init();const n=performance.now(),r=new m(this.bridge.getApi(),s);let e=0,o=0,a=0;try{for await(const t of r.collectMeshesStreaming(50)){if(t&&typeof t=="object"&&"type"in t&&t.type==="colorUpdate")continue;const l=t;e+=l.length;for(const d of l)o+=d.positions.length/3,a+=d.indices.length/3;i.onBatch?.({meshes:l,progress:{processed:e,total:e,currentType:"processing"}})}}catch(t){throw i.onError?.(t instanceof Error?t:new Error(String(t))),t}const h=performance.now()-n,g={totalMeshes:e,totalVertices:o,totalTriangles:a,parseTimeMs:h*.3,geometryTimeMs:h*.7};return i.onComplete?.(g),g}getApi(){return this.bridge.getApi()}}export{u as WasmBridge};
package/dist/index.html CHANGED
@@ -35,8 +35,8 @@
35
35
  <meta name="theme-color" content="#7aa2f7">
36
36
  <meta name="msapplication-TileColor" content="#1a1b26">
37
37
  <meta name="msapplication-TileImage" content="/favicon-192x192-cropped.png">
38
- <script type="module" crossorigin src="/assets/index-dgdgiQ9p.js"></script>
39
- <link rel="stylesheet" crossorigin href="/assets/index-yTqs8kgX.css">
38
+ <script type="module" crossorigin src="/assets/index-BSANf7-H.js"></script>
39
+ <link rel="stylesheet" crossorigin href="/assets/index-7WoQ-qVC.css">
40
40
  </head>
41
41
  <body>
42
42
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ifc-lite/viewer",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "IFC-Lite viewer application",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -33,23 +33,23 @@
33
33
  "tailwind-merge": "^3.4.0",
34
34
  "tailwindcss": "^4.1.18",
35
35
  "zustand": "^4.4.0",
36
- "@ifc-lite/bcf": "^1.7.0",
37
- "@ifc-lite/cache": "^1.7.0",
38
- "@ifc-lite/data": "^1.7.0",
39
- "@ifc-lite/encoding": "^1.7.0",
40
- "@ifc-lite/ids": "^1.7.0",
41
- "@ifc-lite/lens": "^1.7.0",
42
- "@ifc-lite/lists": "^1.7.0",
43
- "@ifc-lite/drawing-2d": "^1.7.0",
44
- "@ifc-lite/export": "^1.7.0",
45
- "@ifc-lite/geometry": "^1.7.0",
46
- "@ifc-lite/mutations": "^1.7.0",
47
- "@ifc-lite/parser": "^1.7.0",
48
- "@ifc-lite/query": "^1.7.0",
49
- "@ifc-lite/renderer": "^1.7.0",
50
- "@ifc-lite/server-client": "^1.7.0",
51
- "@ifc-lite/spatial": "^1.7.0",
52
- "@ifc-lite/wasm": "^1.7.0"
36
+ "@ifc-lite/bcf": "^1.8.0",
37
+ "@ifc-lite/cache": "^1.8.0",
38
+ "@ifc-lite/data": "^1.8.0",
39
+ "@ifc-lite/encoding": "^1.8.0",
40
+ "@ifc-lite/ids": "^1.8.0",
41
+ "@ifc-lite/lens": "^1.8.0",
42
+ "@ifc-lite/lists": "^1.8.0",
43
+ "@ifc-lite/drawing-2d": "^1.8.0",
44
+ "@ifc-lite/export": "^1.8.0",
45
+ "@ifc-lite/geometry": "^1.8.0",
46
+ "@ifc-lite/mutations": "^1.8.0",
47
+ "@ifc-lite/parser": "^1.8.0",
48
+ "@ifc-lite/query": "^1.8.0",
49
+ "@ifc-lite/renderer": "^1.8.0",
50
+ "@ifc-lite/server-client": "^1.8.0",
51
+ "@ifc-lite/spatial": "^1.8.0",
52
+ "@ifc-lite/wasm": "^1.8.0"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@tailwindcss/postcss": "^4.1.18",
@@ -10,6 +10,9 @@ import {
10
10
  type ElementData,
11
11
  } from '@ifc-lite/drawing-2d';
12
12
  import { formatDistance } from './tools/formatDistance';
13
+ import { formatArea, computePolygonCentroid } from './tools/computePolygonArea';
14
+ import { drawCloudOnCanvas } from './tools/cloudPathGenerator';
15
+ import type { PolygonArea2DResult, TextAnnotation2D, CloudAnnotation2D, Annotation2DTool, Point2D, SelectedAnnotation2D } from '@/store/slices/drawing2DSlice';
13
16
 
14
17
  // Fill colors for IFC types (architectural convention)
15
18
  const IFC_TYPE_FILL_COLORS: Record<string, string> = {
@@ -83,6 +86,17 @@ interface Drawing2DCanvasProps {
83
86
  // Pinned mode - keep model fixed in place on sheet
84
87
  isPinned?: boolean;
85
88
  cachedSheetTransformRef?: React.MutableRefObject<{ translateX: number; translateY: number; scaleFactor: number } | null>;
89
+ // Annotation props
90
+ annotation2DActiveTool?: Annotation2DTool;
91
+ annotation2DCursorPos?: Point2D | null;
92
+ polygonAreaPoints?: Point2D[];
93
+ polygonAreaResults?: PolygonArea2DResult[];
94
+ textAnnotations?: TextAnnotation2D[];
95
+ textAnnotationEditing?: string | null;
96
+ cloudAnnotationPoints?: Point2D[];
97
+ cloudAnnotations?: CloudAnnotation2D[];
98
+ // Selection
99
+ selectedAnnotation?: SelectedAnnotation2D | null;
86
100
  }
87
101
 
88
102
  export function Drawing2DCanvas({
@@ -103,6 +117,15 @@ export function Drawing2DCanvas({
103
117
  sectionAxis,
104
118
  isPinned = false,
105
119
  cachedSheetTransformRef,
120
+ annotation2DActiveTool = 'none',
121
+ annotation2DCursorPos = null,
122
+ polygonAreaPoints = [],
123
+ polygonAreaResults = [],
124
+ textAnnotations = [],
125
+ textAnnotationEditing = null,
126
+ cloudAnnotationPoints = [],
127
+ cloudAnnotations = [],
128
+ selectedAnnotation = null,
106
129
  }: Drawing2DCanvasProps): React.ReactElement {
107
130
  const canvasRef = useRef<HTMLCanvasElement>(null);
108
131
  const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
@@ -1036,7 +1059,347 @@ export function Drawing2DCanvas({
1036
1059
  ctx.arc(screenSnap.x, screenSnap.y, 6, 0, Math.PI * 2);
1037
1060
  ctx.stroke();
1038
1061
  }
1039
- }, [drawing, transform, showHiddenLines, canvasSize, overrideEngine, overridesEnabled, entityColorMap, useIfcMaterials, measureMode, measureStart, measureCurrent, measureResults, measureSnapPoint, sheetEnabled, activeSheet, sectionAxis, isPinned]);
1062
+
1063
+ // ═══════════════════════════════════════════════════════════════════════
1064
+ // 5. RENDER POLYGON AREA MEASUREMENTS (in screen space)
1065
+ // ═══════════════════════════════════════════════════════════════════════
1066
+ const annotScaleX = sectionAxis === 'side' ? -transform.scale : transform.scale;
1067
+ const annotScaleY = sectionAxis === 'down' ? transform.scale : -transform.scale;
1068
+ const drawingToScreenX = (x: number) => x * annotScaleX + transform.x;
1069
+ const drawingToScreenY = (y: number) => y * annotScaleY + transform.y;
1070
+
1071
+ // Draw completed polygon areas
1072
+ for (const result of polygonAreaResults) {
1073
+ if (result.points.length < 3) continue;
1074
+
1075
+ // Draw filled polygon
1076
+ ctx.globalAlpha = 0.1;
1077
+ ctx.fillStyle = '#2196F3';
1078
+ ctx.beginPath();
1079
+ const first = result.points[0];
1080
+ ctx.moveTo(drawingToScreenX(first.x), drawingToScreenY(first.y));
1081
+ for (let i = 1; i < result.points.length; i++) {
1082
+ ctx.lineTo(drawingToScreenX(result.points[i].x), drawingToScreenY(result.points[i].y));
1083
+ }
1084
+ ctx.closePath();
1085
+ ctx.fill();
1086
+ ctx.globalAlpha = 1;
1087
+
1088
+ // Draw outline
1089
+ ctx.strokeStyle = '#2196F3';
1090
+ ctx.lineWidth = 1.5;
1091
+ ctx.setLineDash([6, 3]);
1092
+ ctx.beginPath();
1093
+ ctx.moveTo(drawingToScreenX(first.x), drawingToScreenY(first.y));
1094
+ for (let i = 1; i < result.points.length; i++) {
1095
+ ctx.lineTo(drawingToScreenX(result.points[i].x), drawingToScreenY(result.points[i].y));
1096
+ }
1097
+ ctx.closePath();
1098
+ ctx.stroke();
1099
+ ctx.setLineDash([]);
1100
+
1101
+ // Draw vertex dots
1102
+ ctx.fillStyle = '#2196F3';
1103
+ for (const pt of result.points) {
1104
+ ctx.beginPath();
1105
+ ctx.arc(drawingToScreenX(pt.x), drawingToScreenY(pt.y), 3, 0, Math.PI * 2);
1106
+ ctx.fill();
1107
+ }
1108
+
1109
+ // Draw area label at centroid
1110
+ const centroid = computePolygonCentroid(result.points);
1111
+ const cx = drawingToScreenX(centroid.x);
1112
+ const cy = drawingToScreenY(centroid.y);
1113
+ const areaText = formatArea(result.area);
1114
+ const perimText = `P: ${formatDistance(result.perimeter)}`;
1115
+
1116
+ ctx.font = 'bold 12px system-ui, sans-serif';
1117
+ const areaMetrics = ctx.measureText(areaText);
1118
+ ctx.font = '10px system-ui, sans-serif';
1119
+ const perimMetrics = ctx.measureText(perimText);
1120
+ const labelW = Math.max(areaMetrics.width, perimMetrics.width) + 12;
1121
+ const labelH = 32;
1122
+
1123
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.92)';
1124
+ ctx.fillRect(cx - labelW / 2, cy - labelH / 2, labelW, labelH);
1125
+ ctx.strokeStyle = '#2196F3';
1126
+ ctx.lineWidth = 1;
1127
+ ctx.strokeRect(cx - labelW / 2, cy - labelH / 2, labelW, labelH);
1128
+
1129
+ ctx.fillStyle = '#000000';
1130
+ ctx.textAlign = 'center';
1131
+ ctx.textBaseline = 'middle';
1132
+ ctx.font = 'bold 12px system-ui, sans-serif';
1133
+ ctx.fillText(areaText, cx, cy - 6);
1134
+ ctx.font = '10px system-ui, sans-serif';
1135
+ ctx.fillStyle = '#666666';
1136
+ ctx.fillText(perimText, cx, cy + 8);
1137
+ }
1138
+
1139
+ // Draw in-progress polygon
1140
+ if (polygonAreaPoints.length > 0 && annotation2DActiveTool === 'polygon-area') {
1141
+ // Draw lines between placed vertices
1142
+ ctx.strokeStyle = '#FF5722';
1143
+ ctx.lineWidth = 2;
1144
+ ctx.setLineDash([6, 3]);
1145
+ ctx.beginPath();
1146
+ const first = polygonAreaPoints[0];
1147
+ ctx.moveTo(drawingToScreenX(first.x), drawingToScreenY(first.y));
1148
+ for (let i = 1; i < polygonAreaPoints.length; i++) {
1149
+ ctx.lineTo(drawingToScreenX(polygonAreaPoints[i].x), drawingToScreenY(polygonAreaPoints[i].y));
1150
+ }
1151
+
1152
+ // Draw preview line from last vertex to cursor
1153
+ if (annotation2DCursorPos) {
1154
+ ctx.lineTo(drawingToScreenX(annotation2DCursorPos.x), drawingToScreenY(annotation2DCursorPos.y));
1155
+ }
1156
+ ctx.stroke();
1157
+ ctx.setLineDash([]);
1158
+
1159
+ // If 3+ points and cursor is near first vertex, show closing preview
1160
+ if (polygonAreaPoints.length >= 3 && annotation2DCursorPos) {
1161
+ ctx.globalAlpha = 0.08;
1162
+ ctx.fillStyle = '#FF5722';
1163
+ ctx.beginPath();
1164
+ ctx.moveTo(drawingToScreenX(first.x), drawingToScreenY(first.y));
1165
+ for (let i = 1; i < polygonAreaPoints.length; i++) {
1166
+ ctx.lineTo(drawingToScreenX(polygonAreaPoints[i].x), drawingToScreenY(polygonAreaPoints[i].y));
1167
+ }
1168
+ ctx.lineTo(drawingToScreenX(annotation2DCursorPos.x), drawingToScreenY(annotation2DCursorPos.y));
1169
+ ctx.closePath();
1170
+ ctx.fill();
1171
+ ctx.globalAlpha = 1;
1172
+ }
1173
+
1174
+ // Draw vertex dots
1175
+ ctx.fillStyle = '#FF5722';
1176
+ for (const pt of polygonAreaPoints) {
1177
+ ctx.beginPath();
1178
+ ctx.arc(drawingToScreenX(pt.x), drawingToScreenY(pt.y), 4, 0, Math.PI * 2);
1179
+ ctx.fill();
1180
+ }
1181
+
1182
+ // First vertex indicator (larger, shows it can be clicked to close)
1183
+ if (polygonAreaPoints.length >= 3) {
1184
+ ctx.strokeStyle = '#FF5722';
1185
+ ctx.lineWidth = 2;
1186
+ ctx.beginPath();
1187
+ ctx.arc(drawingToScreenX(first.x), drawingToScreenY(first.y), 8, 0, Math.PI * 2);
1188
+ ctx.stroke();
1189
+ }
1190
+ }
1191
+
1192
+ // ═══════════════════════════════════════════════════════════════════════
1193
+ // 6. RENDER TEXT ANNOTATIONS (in screen space)
1194
+ // ═══════════════════════════════════════════════════════════════════════
1195
+ for (const textAnnotation of textAnnotations) {
1196
+ // Don't render text that is currently being edited (the editor overlay handles it)
1197
+ if (textAnnotation.id === textAnnotationEditing) continue;
1198
+ if (!textAnnotation.text.trim()) continue;
1199
+
1200
+ const sx = drawingToScreenX(textAnnotation.position.x);
1201
+ const sy = drawingToScreenY(textAnnotation.position.y);
1202
+
1203
+ ctx.font = `${textAnnotation.fontSize}px system-ui, sans-serif`;
1204
+ const lines = textAnnotation.text.split('\n');
1205
+ const lineHeight = textAnnotation.fontSize * 1.3;
1206
+ let maxWidth = 0;
1207
+ for (const line of lines) {
1208
+ const m = ctx.measureText(line);
1209
+ if (m.width > maxWidth) maxWidth = m.width;
1210
+ }
1211
+
1212
+ const padding = 6;
1213
+ const bgW = maxWidth + padding * 2;
1214
+ const bgH = lines.length * lineHeight + padding * 2;
1215
+
1216
+ // Background
1217
+ ctx.fillStyle = textAnnotation.backgroundColor;
1218
+ ctx.fillRect(sx, sy, bgW, bgH);
1219
+
1220
+ // Border
1221
+ ctx.strokeStyle = textAnnotation.borderColor;
1222
+ ctx.lineWidth = 1;
1223
+ ctx.strokeRect(sx, sy, bgW, bgH);
1224
+
1225
+ // Text
1226
+ ctx.fillStyle = textAnnotation.color;
1227
+ ctx.textAlign = 'left';
1228
+ ctx.textBaseline = 'top';
1229
+ for (let i = 0; i < lines.length; i++) {
1230
+ ctx.fillText(lines[i], sx + padding, sy + padding + i * lineHeight);
1231
+ }
1232
+ }
1233
+
1234
+ // ═══════════════════════════════════════════════════════════════════════
1235
+ // 7. RENDER CLOUD ANNOTATIONS (in screen space)
1236
+ // ═══════════════════════════════════════════════════════════════════════
1237
+ const screenScale = Math.abs(transform.scale);
1238
+
1239
+ // Draw completed clouds
1240
+ for (const cloud of cloudAnnotations) {
1241
+ if (cloud.points.length < 2) continue;
1242
+ const p1 = cloud.points[0];
1243
+ const p2 = cloud.points[1];
1244
+
1245
+ // Determine arc radius based on rectangle size (in drawing coords)
1246
+ const rectW = Math.abs(p2.x - p1.x);
1247
+ const rectH = Math.abs(p2.y - p1.y);
1248
+ const arcRadius = Math.min(rectW, rectH) * 0.15 || 0.2;
1249
+
1250
+ // Draw cloud fill
1251
+ ctx.globalAlpha = 0.05;
1252
+ ctx.fillStyle = cloud.color;
1253
+ drawCloudOnCanvas(ctx, p1, p2, arcRadius, drawingToScreenX, drawingToScreenY, screenScale);
1254
+ ctx.fill();
1255
+ ctx.globalAlpha = 1;
1256
+
1257
+ // Draw cloud stroke
1258
+ ctx.strokeStyle = cloud.color;
1259
+ ctx.lineWidth = 2;
1260
+ drawCloudOnCanvas(ctx, p1, p2, arcRadius, drawingToScreenX, drawingToScreenY, screenScale);
1261
+ ctx.stroke();
1262
+
1263
+ // Draw label at center
1264
+ if (cloud.label) {
1265
+ const labelX = drawingToScreenX((p1.x + p2.x) / 2);
1266
+ const labelY = drawingToScreenY((p1.y + p2.y) / 2);
1267
+
1268
+ ctx.font = 'bold 12px system-ui, sans-serif';
1269
+ const labelMetrics = ctx.measureText(cloud.label);
1270
+ const lW = labelMetrics.width + 10;
1271
+ const lH = 20;
1272
+
1273
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
1274
+ ctx.fillRect(labelX - lW / 2, labelY - lH / 2, lW, lH);
1275
+ ctx.strokeStyle = cloud.color;
1276
+ ctx.lineWidth = 1;
1277
+ ctx.strokeRect(labelX - lW / 2, labelY - lH / 2, lW, lH);
1278
+
1279
+ ctx.fillStyle = cloud.color;
1280
+ ctx.textAlign = 'center';
1281
+ ctx.textBaseline = 'middle';
1282
+ ctx.fillText(cloud.label, labelX, labelY);
1283
+ }
1284
+ }
1285
+
1286
+ // Draw in-progress cloud (rectangle preview from first corner to cursor)
1287
+ if (cloudAnnotationPoints.length === 1 && annotation2DCursorPos && annotation2DActiveTool === 'cloud') {
1288
+ const p1 = cloudAnnotationPoints[0];
1289
+ const p2 = annotation2DCursorPos;
1290
+
1291
+ const sx1 = drawingToScreenX(p1.x);
1292
+ const sy1 = drawingToScreenY(p1.y);
1293
+ const sx2 = drawingToScreenX(p2.x);
1294
+ const sy2 = drawingToScreenY(p2.y);
1295
+
1296
+ ctx.strokeStyle = '#E53935';
1297
+ ctx.lineWidth = 1.5;
1298
+ ctx.setLineDash([6, 3]);
1299
+ ctx.strokeRect(
1300
+ Math.min(sx1, sx2), Math.min(sy1, sy2),
1301
+ Math.abs(sx2 - sx1), Math.abs(sy2 - sy1)
1302
+ );
1303
+ ctx.setLineDash([]);
1304
+ }
1305
+
1306
+ // ═══════════════════════════════════════════════════════════════════════
1307
+ // 8. RENDER SELECTION HIGHLIGHT
1308
+ // ═══════════════════════════════════════════════════════════════════════
1309
+ if (selectedAnnotation) {
1310
+ const SEL_COLOR = '#1976D2';
1311
+ const SEL_HANDLE_SIZE = 5;
1312
+
1313
+ const drawSelectionRect = (x: number, y: number, w: number, h: number) => {
1314
+ const margin = 4;
1315
+ ctx.strokeStyle = SEL_COLOR;
1316
+ ctx.lineWidth = 2;
1317
+ ctx.setLineDash([4, 3]);
1318
+ ctx.strokeRect(x - margin, y - margin, w + margin * 2, h + margin * 2);
1319
+ ctx.setLineDash([]);
1320
+
1321
+ // Corner handles
1322
+ ctx.fillStyle = '#ffffff';
1323
+ ctx.strokeStyle = SEL_COLOR;
1324
+ ctx.lineWidth = 1.5;
1325
+ const corners = [
1326
+ [x - margin, y - margin],
1327
+ [x + w + margin, y - margin],
1328
+ [x - margin, y + h + margin],
1329
+ [x + w + margin, y + h + margin],
1330
+ ];
1331
+ for (const [cx, cy] of corners) {
1332
+ ctx.fillRect(cx - SEL_HANDLE_SIZE, cy - SEL_HANDLE_SIZE, SEL_HANDLE_SIZE * 2, SEL_HANDLE_SIZE * 2);
1333
+ ctx.strokeRect(cx - SEL_HANDLE_SIZE, cy - SEL_HANDLE_SIZE, SEL_HANDLE_SIZE * 2, SEL_HANDLE_SIZE * 2);
1334
+ }
1335
+ };
1336
+
1337
+ switch (selectedAnnotation.type) {
1338
+ case 'measure': {
1339
+ const result = measureResults.find((r) => r.id === selectedAnnotation.id);
1340
+ if (result) {
1341
+ const sa = { x: drawingToScreenX(result.start.x), y: drawingToScreenY(result.start.y) };
1342
+ const sb = { x: drawingToScreenX(result.end.x), y: drawingToScreenY(result.end.y) };
1343
+ const minX = Math.min(sa.x, sb.x);
1344
+ const minY = Math.min(sa.y, sb.y);
1345
+ const w = Math.abs(sb.x - sa.x);
1346
+ const h = Math.abs(sb.y - sa.y);
1347
+ drawSelectionRect(minX, minY, w, h);
1348
+ }
1349
+ break;
1350
+ }
1351
+ case 'polygon': {
1352
+ const result = polygonAreaResults.find((r) => r.id === selectedAnnotation.id);
1353
+ if (result && result.points.length >= 3) {
1354
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1355
+ for (const pt of result.points) {
1356
+ const sx = drawingToScreenX(pt.x);
1357
+ const sy = drawingToScreenY(pt.y);
1358
+ if (sx < minX) minX = sx;
1359
+ if (sy < minY) minY = sy;
1360
+ if (sx > maxX) maxX = sx;
1361
+ if (sy > maxY) maxY = sy;
1362
+ }
1363
+ drawSelectionRect(minX, minY, maxX - minX, maxY - minY);
1364
+ }
1365
+ break;
1366
+ }
1367
+ case 'text': {
1368
+ const annotation = textAnnotations.find((a) => a.id === selectedAnnotation.id);
1369
+ if (annotation && annotation.text.trim()) {
1370
+ const sx = drawingToScreenX(annotation.position.x);
1371
+ const sy = drawingToScreenY(annotation.position.y);
1372
+ ctx.font = `${annotation.fontSize}px system-ui, sans-serif`;
1373
+ const lines = annotation.text.split('\n');
1374
+ const lineHeight = annotation.fontSize * 1.3;
1375
+ const padding = 6;
1376
+ let maxWidth = 0;
1377
+ for (const line of lines) {
1378
+ const m = ctx.measureText(line);
1379
+ if (m.width > maxWidth) maxWidth = m.width;
1380
+ }
1381
+ const bgW = maxWidth + padding * 2;
1382
+ const bgH = lines.length * lineHeight + padding * 2;
1383
+ drawSelectionRect(sx, sy, bgW, bgH);
1384
+ }
1385
+ break;
1386
+ }
1387
+ case 'cloud': {
1388
+ const cloud = cloudAnnotations.find((a) => a.id === selectedAnnotation.id);
1389
+ if (cloud && cloud.points.length >= 2) {
1390
+ const sp1x = drawingToScreenX(cloud.points[0].x);
1391
+ const sp1y = drawingToScreenY(cloud.points[0].y);
1392
+ const sp2x = drawingToScreenX(cloud.points[1].x);
1393
+ const sp2y = drawingToScreenY(cloud.points[1].y);
1394
+ const minX = Math.min(sp1x, sp2x);
1395
+ const minY = Math.min(sp1y, sp2y);
1396
+ drawSelectionRect(minX, minY, Math.abs(sp2x - sp1x), Math.abs(sp2y - sp1y));
1397
+ }
1398
+ break;
1399
+ }
1400
+ }
1401
+ }
1402
+ }, [drawing, transform, showHiddenLines, canvasSize, overrideEngine, overridesEnabled, entityColorMap, useIfcMaterials, measureMode, measureStart, measureCurrent, measureResults, measureSnapPoint, sheetEnabled, activeSheet, sectionAxis, isPinned, annotation2DActiveTool, annotation2DCursorPos, polygonAreaPoints, polygonAreaResults, textAnnotations, textAnnotationEditing, cloudAnnotationPoints, cloudAnnotations, selectedAnnotation]);
1040
1403
 
1041
1404
  return (
1042
1405
  <canvas
@@ -8,7 +8,9 @@
8
8
 
9
9
  import { useCallback, useEffect, useRef, useMemo } from 'react';
10
10
  import {
11
- Focus,
11
+ Equal,
12
+ Plus,
13
+ Minus,
12
14
  EyeOff,
13
15
  Eye,
14
16
  Layers,
@@ -16,42 +18,44 @@ import {
16
18
  Maximize2,
17
19
  Building2,
18
20
  } from 'lucide-react';
19
- import { useViewerStore } from '@/store';
21
+ import { useViewerStore, resolveEntityRef } from '@/store';
20
22
  import { useIfc } from '@/hooks/useIfc';
21
23
 
22
24
  export function EntityContextMenu() {
23
25
  const contextMenu = useViewerStore((s) => s.contextMenu);
24
26
  const closeContextMenu = useViewerStore((s) => s.closeContextMenu);
25
- const isolateEntity = useViewerStore((s) => s.isolateEntity);
26
27
  const hideEntity = useViewerStore((s) => s.hideEntity);
27
28
  const showAll = useViewerStore((s) => s.showAll);
28
29
  const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
29
30
  const setSelectedEntityIds = useViewerStore((s) => s.setSelectedEntityIds);
30
31
  const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks);
31
- const resolveGlobalIdFromModels = useViewerStore((s) => s.resolveGlobalIdFromModels);
32
+ // Basket actions
33
+ const setBasket = useViewerStore((s) => s.setBasket);
34
+ const addToBasket = useViewerStore((s) => s.addToBasket);
35
+ const removeFromBasket = useViewerStore((s) => s.removeFromBasket);
32
36
  const menuRef = useRef<HTMLDivElement>(null);
33
37
  const { ifcDataStore, models } = useIfc();
34
38
 
35
39
  // Resolve contextMenu.entityId (globalId) to original expressId and model
36
40
  // This is needed because IfcDataStore uses original expressIds, not globalIds
37
- const { resolvedExpressId, activeDataStore } = useMemo(() => {
41
+ const { resolvedExpressId, resolvedModelId, activeDataStore } = useMemo(() => {
38
42
  if (!contextMenu.entityId) {
39
- return { resolvedExpressId: null, activeDataStore: ifcDataStore };
43
+ return { resolvedExpressId: null, resolvedModelId: null, activeDataStore: ifcDataStore };
40
44
  }
41
45
 
42
- // Use store-based resolver (more reliable than singleton)
43
- const resolved = resolveGlobalIdFromModels(contextMenu.entityId);
44
- if (resolved) {
45
- const model = models.get(resolved.modelId);
46
+ // Single source of truth for globalId → EntityRef resolution
47
+ const ref = resolveEntityRef(contextMenu.entityId);
48
+ if (ref) {
49
+ const model = models.get(ref.modelId);
46
50
  return {
47
- resolvedExpressId: resolved.expressId,
51
+ resolvedExpressId: ref.expressId,
52
+ resolvedModelId: ref.modelId,
48
53
  activeDataStore: model?.ifcDataStore ?? ifcDataStore,
49
54
  };
50
55
  }
51
56
 
52
- // Fallback for single-model mode (offset = 0)
53
- return { resolvedExpressId: contextMenu.entityId, activeDataStore: ifcDataStore };
54
- }, [contextMenu.entityId, models, ifcDataStore, resolveGlobalIdFromModels]);
57
+ return { resolvedExpressId: contextMenu.entityId, resolvedModelId: null, activeDataStore: ifcDataStore };
58
+ }, [contextMenu.entityId, models, ifcDataStore]);
55
59
 
56
60
  // Close menu when clicking outside
57
61
  useEffect(() => {
@@ -89,12 +93,29 @@ export function EntityContextMenu() {
89
93
  closeContextMenu();
90
94
  }, [contextMenu.entityId, setSelectedEntityId, cameraCallbacks, closeContextMenu]);
91
95
 
92
- const handleIsolate = useCallback(() => {
93
- if (contextMenu.entityId) {
94
- isolateEntity(contextMenu.entityId);
96
+ // Basket: = Set basket to this entity
97
+ const handleSetBasket = useCallback(() => {
98
+ if (resolvedExpressId !== null && resolvedModelId !== null) {
99
+ setBasket([{ modelId: resolvedModelId, expressId: resolvedExpressId }]);
100
+ }
101
+ closeContextMenu();
102
+ }, [resolvedExpressId, resolvedModelId, setBasket, closeContextMenu]);
103
+
104
+ // Basket: + Add to basket
105
+ const handleAddToBasket = useCallback(() => {
106
+ if (resolvedExpressId !== null && resolvedModelId !== null) {
107
+ addToBasket([{ modelId: resolvedModelId, expressId: resolvedExpressId }]);
108
+ }
109
+ closeContextMenu();
110
+ }, [resolvedExpressId, resolvedModelId, addToBasket, closeContextMenu]);
111
+
112
+ // Basket: − Remove from basket
113
+ const handleRemoveFromBasket = useCallback(() => {
114
+ if (resolvedExpressId !== null && resolvedModelId !== null) {
115
+ removeFromBasket([{ modelId: resolvedModelId, expressId: resolvedExpressId }]);
95
116
  }
96
117
  closeContextMenu();
97
- }, [contextMenu.entityId, isolateEntity, closeContextMenu]);
118
+ }, [resolvedExpressId, resolvedModelId, removeFromBasket, closeContextMenu]);
98
119
 
99
120
  const handleHide = useCallback(() => {
100
121
  if (contextMenu.entityId) {
@@ -104,7 +125,7 @@ export function EntityContextMenu() {
104
125
  }, [contextMenu.entityId, hideEntity, closeContextMenu]);
105
126
 
106
127
  const handleShowAll = useCallback(() => {
107
- showAll();
128
+ showAll(); // Clear hidden + isolation (basket preserved)
108
129
  closeContextMenu();
109
130
  }, [showAll, closeContextMenu]);
110
131
 
@@ -204,11 +225,17 @@ export function EntityContextMenu() {
204
225
  </div>
205
226
 
206
227
  <MenuItem icon={Maximize2} label="Zoom to" onClick={handleZoomTo} />
207
- <MenuItem icon={Focus} label="Isolate" onClick={handleIsolate} />
208
228
  <MenuItem icon={EyeOff} label="Hide" onClick={handleHide} />
209
229
 
210
230
  <div className="h-px bg-border my-1" />
211
231
 
232
+ {/* Basket operations */}
233
+ <MenuItem icon={Equal} label="Set as Basket (=)" onClick={handleSetBasket} />
234
+ <MenuItem icon={Plus} label="Add to Basket (+)" onClick={handleAddToBasket} />
235
+ <MenuItem icon={Minus} label="Remove from Basket (−)" onClick={handleRemoveFromBasket} />
236
+
237
+ <div className="h-px bg-border my-1" />
238
+
212
239
  <MenuItem icon={Layers} label={`Select all ${entityType}`} onClick={handleSelectSimilar} />
213
240
  <MenuItem icon={Building2} label="Select same storey" onClick={handleSelectSameStorey} />
214
241