@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.
- package/CHANGELOG.md +35 -0
- package/dist/assets/{Arrow.dom-BGPQieQQ.js → Arrow.dom-CwcRxist.js} +1 -1
- package/dist/assets/index-7WoQ-qVC.css +1 -0
- package/dist/assets/{index-dgdgiQ9p.js → index-BSANf7-H.js} +20926 -17587
- package/dist/assets/{native-bridge-DD0SNyQ5.js → native-bridge-5LbrYh3R.js} +1 -1
- package/dist/assets/{wasm-bridge-D54YMO7X.js → wasm-bridge-CgpLtj1h.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +18 -18
- package/src/components/viewer/Drawing2DCanvas.tsx +364 -1
- package/src/components/viewer/EntityContextMenu.tsx +47 -20
- package/src/components/viewer/ExportDialog.tsx +166 -17
- package/src/components/viewer/HierarchyPanel.tsx +3 -1
- package/src/components/viewer/LensPanel.tsx +848 -85
- package/src/components/viewer/MainToolbar.tsx +114 -81
- package/src/components/viewer/Section2DPanel.tsx +269 -29
- package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
- package/src/components/viewer/Viewport.tsx +57 -23
- package/src/components/viewer/ViewportContainer.tsx +2 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +1 -1
- package/src/components/viewer/hierarchy/types.ts +1 -1
- package/src/components/viewer/lists/ListResultsTable.tsx +53 -19
- package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
- package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
- package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
- package/src/components/viewer/tools/computePolygonArea.ts +72 -0
- package/src/components/viewer/useGeometryStreaming.ts +12 -4
- package/src/hooks/ids/idsExportService.ts +1 -1
- package/src/hooks/useAnnotation2D.ts +551 -0
- package/src/hooks/useDrawingExport.ts +83 -1
- package/src/hooks/useKeyboardShortcuts.ts +113 -14
- package/src/hooks/useLens.ts +39 -55
- package/src/hooks/useLensDiscovery.ts +46 -0
- package/src/hooks/useModelSelection.ts +5 -22
- package/src/index.css +7 -1
- package/src/lib/lens/adapter.ts +127 -1
- package/src/lib/lists/columnToAutoColor.ts +33 -0
- package/src/store/index.ts +14 -1
- package/src/store/resolveEntityRef.ts +44 -0
- package/src/store/slices/drawing2DSlice.ts +321 -0
- package/src/store/slices/lensSlice.ts +46 -4
- package/src/store/slices/pinboardSlice.ts +171 -38
- package/src/store.ts +3 -0
- package/dist/assets/index-yTqs8kgX.css +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{I as f,a as m}from"./index-
|
|
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-
|
|
39
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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.
|
|
37
|
-
"@ifc-lite/cache": "^1.
|
|
38
|
-
"@ifc-lite/data": "^1.
|
|
39
|
-
"@ifc-lite/encoding": "^1.
|
|
40
|
-
"@ifc-lite/ids": "^1.
|
|
41
|
-
"@ifc-lite/lens": "^1.
|
|
42
|
-
"@ifc-lite/lists": "^1.
|
|
43
|
-
"@ifc-lite/drawing-2d": "^1.
|
|
44
|
-
"@ifc-lite/export": "^1.
|
|
45
|
-
"@ifc-lite/geometry": "^1.
|
|
46
|
-
"@ifc-lite/mutations": "^1.
|
|
47
|
-
"@ifc-lite/parser": "^1.
|
|
48
|
-
"@ifc-lite/query": "^1.
|
|
49
|
-
"@ifc-lite/renderer": "^1.
|
|
50
|
-
"@ifc-lite/server-client": "^1.
|
|
51
|
-
"@ifc-lite/spatial": "^1.
|
|
52
|
-
"@ifc-lite/wasm": "^1.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
const model = models.get(
|
|
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:
|
|
51
|
+
resolvedExpressId: ref.expressId,
|
|
52
|
+
resolvedModelId: ref.modelId,
|
|
48
53
|
activeDataStore: model?.ifcDataStore ?? ifcDataStore,
|
|
49
54
|
};
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
}, [
|
|
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
|
|