@ifc-lite/viewer 1.21.0 → 1.22.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/.turbo/turbo-build.log +57 -50
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +10 -0
- package/dist/assets/arrow-fie-E7fe.js +20 -0
- package/dist/assets/ascii-points-source-bTjLVmUX.js +1 -0
- package/dist/assets/{basketViewActivator-Bzw51jhm.js → basketViewActivator-EHAhHlwN.js} +12 -13
- package/dist/assets/bcf-Bhx-K17f.js +281 -0
- package/dist/assets/{browser-C5TFR7sH.js → browser-CVf8ATeW.js} +6 -6
- package/dist/assets/cesium-B4ZIU9jS.js +17742 -0
- package/dist/assets/decode-worker-CYqSjk1n.js +172 -0
- package/dist/assets/e57-source-CQHxE8n3.js +1 -0
- package/dist/assets/emscripten-module.browser-DcFZLAUx.js +1 -0
- package/dist/assets/exporters-KTio0Tdm.js +5723 -0
- package/dist/assets/geometry-controller.worker-Cm2P_EJr.js +7 -0
- package/dist/assets/geometry.worker-DchLBqZ8.js +1 -0
- package/dist/assets/{ids-B7AXEv7h.js → ids-CS7VCFin.js} +5 -5
- package/dist/assets/ifc-lite-C6wEhXa6.js +7 -0
- package/dist/assets/{ifc-lite_bg-DlKs5-yM.wasm → ifc-lite_bg-CSeT3fNI.wasm} +0 -0
- package/dist/assets/{ifc-lite_bg-PqmRe3Ph.wasm → ifc-lite_bg-ns4cSnX2.wasm} +0 -0
- package/dist/assets/{index-DVNSvEMh.js → index-8k9h-ANq.js} +60997 -59926
- package/dist/assets/index-BZC2YaOP.css +1 -0
- package/dist/assets/index-HqAIQkr6.js +22 -0
- package/dist/assets/inline-worker-BpBzlmd6.js +1 -0
- package/dist/assets/las-BW6LIc_j.js +1 -0
- package/dist/assets/las-source-C_IGrgRq.js +1 -0
- package/dist/assets/laz-source-jj3xI5Y4.js +125 -0
- package/dist/assets/maplibre-gl-C4LXKM6c.js +808 -0
- package/dist/assets/{native-bridge-BiD01jI9.js → native-bridge-DNrEhx2R.js} +5 -8
- package/dist/assets/{parser.worker-Bnbrl6gy.js → parser.worker-BcjkIo89.js} +2 -2
- package/dist/assets/pcd-source-Ck0UnVDn.js +3 -0
- package/dist/assets/ply-source-C8jjyzxE.js +4 -0
- package/dist/assets/{exporters-u0sz2Upj.js → sandbox-BSn5MyEJ.js} +11745 -7412
- package/dist/assets/{server-client-DP8fMPY9.js → server-client-D-kU2XAF.js} +4 -4
- package/dist/assets/{three-CDRZThFA.js → three-DwNDHx9-.js} +163 -171
- package/dist/assets/wasm-bridge-Cha08LdC.js +1 -0
- package/dist/assets/{workerHelpers-CBbWSJmd.js → workerHelpers-pUUnk9Wc.js} +1 -1
- package/dist/assets/zip-BJqVbRkU.js +2 -0
- package/dist/index.html +10 -12
- package/package.json +11 -11
- package/src/components/mcp/PlaygroundChat.tsx +90 -52
- package/src/components/viewer/CesiumOverlay.tsx +150 -91
- package/src/components/viewer/CesiumPlacementEditor.tsx +1009 -0
- package/src/components/viewer/ChatPanel.tsx +76 -93
- package/src/components/viewer/EntityContextMenu.tsx +68 -10
- package/src/components/viewer/MainToolbar.tsx +33 -3
- package/src/components/viewer/ViewportContainer.tsx +70 -16
- package/src/components/viewer/ViewportOverlays.tsx +2 -98
- package/src/components/viewer/chat/ByokKeyModal.tsx +338 -0
- package/src/components/viewer/chat/ByokStreamingPill.tsx +62 -0
- package/src/components/viewer/chat/ByokTrustDiagram.tsx +192 -0
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +49 -52
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +55 -44
- package/src/components/viewer/selectionHandlers.ts +7 -1
- package/src/lib/geo/cesium-bridge.ts +86 -50
- package/src/lib/geo/cesium-placement.test.ts +244 -0
- package/src/lib/geo/cesium-placement.ts +231 -0
- package/src/lib/geo/effective-georef.test.ts +74 -1
- package/src/lib/geo/effective-georef.ts +40 -93
- package/src/lib/geo/geo-scale.ts +104 -0
- package/src/lib/geo/reproject.test.ts +130 -0
- package/src/lib/geo/reproject.ts +37 -12
- package/src/lib/geo/terrain-elevation.ts +198 -89
- package/src/lib/lens/adapter.ts +52 -6
- package/src/lib/llm/clipboard-detect.test.ts +150 -0
- package/src/lib/llm/clipboard-detect.ts +90 -0
- package/src/lib/llm/models.ts +28 -0
- package/src/lib/llm/stream-direct.ts +16 -4
- package/src/lib/llm/types.ts +8 -0
- package/src/services/playground-model.ts +55 -0
- package/src/store/index.ts +4 -5
- package/src/store/slices/cesiumSlice.ts +100 -19
- package/src/store.ts +3 -0
- package/dist/assets/arrow-CZ5kQ26f.js +0 -20
- package/dist/assets/bcf-4K724hw0.js +0 -281
- package/dist/assets/cesium-DUOzBlqv.js +0 -17817
- package/dist/assets/decode-worker-t2EGKAxO.js +0 -1708
- package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +0 -1
- package/dist/assets/geometry-controller.worker-NH8pZmrU.js +0 -7
- package/dist/assets/geometry.worker-Bp4rW_R1.js +0 -1
- package/dist/assets/ifc-lite-DfZHk36-.js +0 -7
- package/dist/assets/index-CSWgTe1s.css +0 -1
- package/dist/assets/index-XwKzDuw6.js +0 -22
- package/dist/assets/maplibre-gl-CGLcoNXc.js +0 -811
- package/dist/assets/sandbox-DPD1ROr0.js +0 -9700
- package/dist/assets/wasm-bridge-CErti6zX.js +0 -1
- package/dist/assets/zip-DBEtpeu6.js +0 -12
- package/src/components/viewer/CesiumSettingsDialog.tsx +0 -100
|
@@ -0,0 +1,1009 @@
|
|
|
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 { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
6
|
+
import { Check, ChevronDown, GripVertical, Move, RotateCcw, X } from 'lucide-react';
|
|
7
|
+
import type { CoordinateInfo } from '@ifc-lite/geometry';
|
|
8
|
+
import type { MapConversion, ProjectedCRS } from '@ifc-lite/parser';
|
|
9
|
+
|
|
10
|
+
import { toast } from '@/components/ui/toast';
|
|
11
|
+
import { getGlobalRenderer } from '@/hooks/useBCF';
|
|
12
|
+
import {
|
|
13
|
+
closestYOnVerticalLineFromRay,
|
|
14
|
+
getMapUnitScale,
|
|
15
|
+
intersectRayWithHorizontalPlane,
|
|
16
|
+
mapUnitsToMeters,
|
|
17
|
+
metersToMapUnits,
|
|
18
|
+
projectedDeltaToViewerDelta,
|
|
19
|
+
viewerDeltaToProjectedDelta,
|
|
20
|
+
} from '@/lib/geo/cesium-placement';
|
|
21
|
+
import { findClampAnchorY } from '@/lib/geo/clamp-anchor';
|
|
22
|
+
import { cn } from '@/lib/utils';
|
|
23
|
+
import { useViewerStore, type CesiumPlacementDraft } from '@/store';
|
|
24
|
+
|
|
25
|
+
interface CesiumPlacementEditorProps {
|
|
26
|
+
modelId: string;
|
|
27
|
+
mapConversion: MapConversion;
|
|
28
|
+
baseMapConversion: MapConversion;
|
|
29
|
+
projectedCRS?: ProjectedCRS;
|
|
30
|
+
coordinateInfo?: CoordinateInfo;
|
|
31
|
+
lengthUnitScale?: number;
|
|
32
|
+
storeyElevations?: Map<number, number>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type ScreenPoint = { x: number; y: number };
|
|
36
|
+
type WorldPoint = { x: number; y: number; z: number };
|
|
37
|
+
|
|
38
|
+
function getGizmoWorldSize(coordinateInfo: CoordinateInfo | undefined): number {
|
|
39
|
+
const bounds = coordinateInfo?.originalBounds;
|
|
40
|
+
if (!bounds) return 25;
|
|
41
|
+
const dx = bounds.max.x - bounds.min.x;
|
|
42
|
+
const dy = bounds.max.y - bounds.min.y;
|
|
43
|
+
const dz = bounds.max.z - bounds.min.z;
|
|
44
|
+
const size = Math.max(dx, dy, dz) * 0.45;
|
|
45
|
+
return Math.min(80, Math.max(15, size));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Drag math is anchored in WORLD SPACE (ray-plane / ray-line intersection)
|
|
49
|
+
// rather than projected screen-axis pixels. Screen-space linearisations alias
|
|
50
|
+
// badly when the gizmo plane is near-edge-on to the camera (det → 0), which
|
|
51
|
+
// previously produced "huge jumps" for XY drag at oblique tilts. Ray-based
|
|
52
|
+
// math stays exact at any camera angle short of full grazing.
|
|
53
|
+
type DragState =
|
|
54
|
+
| {
|
|
55
|
+
mode: 'height';
|
|
56
|
+
startDraft: CesiumPlacementDraft;
|
|
57
|
+
anchorX: number;
|
|
58
|
+
anchorZ: number;
|
|
59
|
+
startWorldY: number;
|
|
60
|
+
}
|
|
61
|
+
| {
|
|
62
|
+
mode: 'xy';
|
|
63
|
+
startDraft: CesiumPlacementDraft;
|
|
64
|
+
planeY: number;
|
|
65
|
+
startHit: WorldPoint;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function round2(value: number): number {
|
|
69
|
+
return Math.round(value * 100) / 100;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatSigned(value: number, suffix: string): string {
|
|
73
|
+
const rounded = Math.abs(value) < 0.005 ? 0 : round2(value);
|
|
74
|
+
return `${rounded >= 0 ? '+' : ''}${rounded.toFixed(2)} ${suffix}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeDegrees(value: number): number {
|
|
78
|
+
let normalized = value % 360;
|
|
79
|
+
if (normalized > 180) normalized -= 360;
|
|
80
|
+
if (normalized <= -180) normalized += 360;
|
|
81
|
+
return normalized;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function axisAngleDegrees(conversion: Pick<MapConversion, 'xAxisAbscissa' | 'xAxisOrdinate'>): number {
|
|
85
|
+
return normalizeDegrees(
|
|
86
|
+
Math.atan2(conversion.xAxisOrdinate ?? 0, conversion.xAxisAbscissa ?? 1) * 180 / Math.PI,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function axisFromAngleDegrees(angleDegrees: number): Pick<MapConversion, 'xAxisAbscissa' | 'xAxisOrdinate'> {
|
|
91
|
+
const radians = angleDegrees * Math.PI / 180;
|
|
92
|
+
return {
|
|
93
|
+
xAxisAbscissa: Math.round(Math.cos(radians) * 1_000_000) / 1_000_000,
|
|
94
|
+
xAxisOrdinate: Math.round(Math.sin(radians) * 1_000_000) / 1_000_000,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface PointerRay {
|
|
99
|
+
origin: { x: number; y: number; z: number };
|
|
100
|
+
direction: { x: number; y: number; z: number };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolve a CSS-pixel pointer event into a world-space ray via the renderer
|
|
105
|
+
* camera. Returns null when the renderer/canvas isn't mounted yet.
|
|
106
|
+
*
|
|
107
|
+
* Note: `unprojectToRay` consumes drawing-buffer pixels, so we rescale the
|
|
108
|
+
* CSS-pixel pointer coordinates by `canvas.width / rect.width` (and the same
|
|
109
|
+
* for height). Mixing CSS and drawing-buffer units silently scales the ray
|
|
110
|
+
* direction on high-DPI displays — the previous gizmo bug-pattern.
|
|
111
|
+
*/
|
|
112
|
+
function rayFromPointerEvent(clientX: number, clientY: number): PointerRay | null {
|
|
113
|
+
const renderer = getGlobalRenderer();
|
|
114
|
+
const camera = renderer?.getCamera();
|
|
115
|
+
const canvas = renderer?.getCanvas();
|
|
116
|
+
if (!camera || !canvas) return null;
|
|
117
|
+
const rect = canvas.getBoundingClientRect();
|
|
118
|
+
if (rect.width <= 0 || rect.height <= 0) return null;
|
|
119
|
+
const bufferX = ((clientX - rect.left) / rect.width) * canvas.width;
|
|
120
|
+
const bufferY = ((clientY - rect.top) / rect.height) * canvas.height;
|
|
121
|
+
const ray = camera.unprojectToRay(bufferX, bufferY, canvas.width, canvas.height);
|
|
122
|
+
if (!ray) return null;
|
|
123
|
+
return ray;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const PANEL_WIDTH = 320;
|
|
127
|
+
const PANEL_MARGIN = 16;
|
|
128
|
+
const PANEL_STORAGE_KEY = 'ifc-lite:cesium-placement-panel:v1';
|
|
129
|
+
|
|
130
|
+
interface PanelPreferences {
|
|
131
|
+
x?: number;
|
|
132
|
+
y?: number;
|
|
133
|
+
collapsed?: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readPanelPreferences(): PanelPreferences {
|
|
137
|
+
if (typeof window === 'undefined') return {};
|
|
138
|
+
try {
|
|
139
|
+
const raw = window.localStorage.getItem(PANEL_STORAGE_KEY);
|
|
140
|
+
if (!raw) return {};
|
|
141
|
+
const parsed = JSON.parse(raw) as PanelPreferences;
|
|
142
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
143
|
+
} catch (err) {
|
|
144
|
+
console.warn('[CesiumPlacementEditor] failed to read panel prefs', err);
|
|
145
|
+
return {};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function writePanelPreferences(prefs: PanelPreferences): void {
|
|
150
|
+
if (typeof window === 'undefined') return;
|
|
151
|
+
try {
|
|
152
|
+
window.localStorage.setItem(PANEL_STORAGE_KEY, JSON.stringify(prefs));
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.warn('[CesiumPlacementEditor] failed to persist panel prefs', err);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface ContainerBox {
|
|
159
|
+
width: number;
|
|
160
|
+
height: number;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function clampPanelPosition(
|
|
164
|
+
x: number,
|
|
165
|
+
y: number,
|
|
166
|
+
panelHeight: number,
|
|
167
|
+
container: ContainerBox,
|
|
168
|
+
): ScreenPoint {
|
|
169
|
+
// ViewportContainer is the offset parent — its width is the panel-group
|
|
170
|
+
// centre column, not the full window. Clamp inside its rect, leaving
|
|
171
|
+
// PANEL_MARGIN on every edge.
|
|
172
|
+
const maxX = Math.max(PANEL_MARGIN, container.width - PANEL_WIDTH - PANEL_MARGIN);
|
|
173
|
+
const maxY = Math.max(PANEL_MARGIN, container.height - panelHeight - PANEL_MARGIN);
|
|
174
|
+
return {
|
|
175
|
+
x: Math.min(Math.max(x, PANEL_MARGIN), maxX),
|
|
176
|
+
y: Math.min(Math.max(y, PANEL_MARGIN), maxY),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function defaultPanelPosition(panelHeight: number, container: ContainerBox): ScreenPoint {
|
|
181
|
+
return {
|
|
182
|
+
x: Math.max(PANEL_MARGIN, container.width - PANEL_WIDTH - PANEL_MARGIN),
|
|
183
|
+
y: Math.max(PANEL_MARGIN, container.height - panelHeight - PANEL_MARGIN),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function CesiumPlacementEditor({
|
|
188
|
+
modelId,
|
|
189
|
+
mapConversion,
|
|
190
|
+
baseMapConversion,
|
|
191
|
+
projectedCRS,
|
|
192
|
+
coordinateInfo,
|
|
193
|
+
lengthUnitScale = 1,
|
|
194
|
+
storeyElevations,
|
|
195
|
+
}: CesiumPlacementEditorProps) {
|
|
196
|
+
const editMode = useViewerStore((s) => s.cesiumPlacementEditMode);
|
|
197
|
+
const draftModelId = useViewerStore((s) => s.cesiumPlacementDraftModelId);
|
|
198
|
+
const draft = useViewerStore((s) => s.cesiumPlacementDraft);
|
|
199
|
+
const beginDraft = useViewerStore((s) => s.beginCesiumPlacementDraft);
|
|
200
|
+
const updateDraft = useViewerStore((s) => s.updateCesiumPlacementDraft);
|
|
201
|
+
const resetDraft = useViewerStore((s) => s.resetCesiumPlacementDraft);
|
|
202
|
+
const setEditMode = useViewerStore((s) => s.setCesiumPlacementEditMode);
|
|
203
|
+
const setActiveTool = useViewerStore((s) => s.setActiveTool);
|
|
204
|
+
const setGeorefFields = useViewerStore((s) => s.setGeorefFields);
|
|
205
|
+
const [projection, setProjection] = useState<{
|
|
206
|
+
center: ScreenPoint;
|
|
207
|
+
heightTip: ScreenPoint;
|
|
208
|
+
planeCorners: [ScreenPoint, ScreenPoint, ScreenPoint, ScreenPoint];
|
|
209
|
+
} | null>(null);
|
|
210
|
+
const dragStateRef = useRef<DragState | null>(null);
|
|
211
|
+
|
|
212
|
+
// Panel chrome state: position (draggable, persisted), collapse, and the
|
|
213
|
+
// header-drag offset captured on pointer-down. Position is lazy-initialised
|
|
214
|
+
// from localStorage so we don't flicker through a centred mount.
|
|
215
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
216
|
+
const [panelCollapsed, setPanelCollapsed] = useState<boolean>(
|
|
217
|
+
() => readPanelPreferences().collapsed ?? false,
|
|
218
|
+
);
|
|
219
|
+
const [panelPosition, setPanelPosition] = useState<ScreenPoint | null>(() => {
|
|
220
|
+
const prefs = readPanelPreferences();
|
|
221
|
+
if (typeof prefs.x === 'number' && typeof prefs.y === 'number') {
|
|
222
|
+
return { x: prefs.x, y: prefs.y };
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
});
|
|
226
|
+
const panelDragRef = useRef<{ offsetX: number; offsetY: number; pointerId: number } | null>(null);
|
|
227
|
+
|
|
228
|
+
// Resolve the panel's positioning container (its `offsetParent`, which is
|
|
229
|
+
// ViewportContainer's relative root). We measure it for default placement,
|
|
230
|
+
// clamping, and drag math so the panel stays inside the centre viewport
|
|
231
|
+
// column instead of being computed against the whole window.
|
|
232
|
+
const getContainerBox = useCallback((): ContainerBox | null => {
|
|
233
|
+
const parent = panelRef.current?.offsetParent as HTMLElement | null;
|
|
234
|
+
if (!parent) return null;
|
|
235
|
+
const rect = parent.getBoundingClientRect();
|
|
236
|
+
return { width: rect.width, height: rect.height };
|
|
237
|
+
}, []);
|
|
238
|
+
|
|
239
|
+
const getContainerOrigin = useCallback((): { left: number; top: number } | null => {
|
|
240
|
+
const parent = panelRef.current?.offsetParent as HTMLElement | null;
|
|
241
|
+
if (!parent) return null;
|
|
242
|
+
const rect = parent.getBoundingClientRect();
|
|
243
|
+
return { left: rect.left, top: rect.top };
|
|
244
|
+
}, []);
|
|
245
|
+
|
|
246
|
+
// When a saved position exists, clamp it on mount and after resizes so
|
|
247
|
+
// it stays inside the viewport container. When no saved position exists
|
|
248
|
+
// we leave panelPosition === null and rely on CSS right/bottom anchoring
|
|
249
|
+
// for the default bottom-right placement, which avoids a measure-first
|
|
250
|
+
// flicker and works even when the offsetParent isn't measurable yet.
|
|
251
|
+
useLayoutEffect(() => {
|
|
252
|
+
const apply = () => {
|
|
253
|
+
const container = getContainerBox();
|
|
254
|
+
if (!container) return;
|
|
255
|
+
const height = panelRef.current?.offsetHeight ?? 280;
|
|
256
|
+
setPanelPosition((prev) => {
|
|
257
|
+
if (prev === null) return null;
|
|
258
|
+
return clampPanelPosition(prev.x, prev.y, height, container);
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
apply();
|
|
262
|
+
if (typeof window === 'undefined') return;
|
|
263
|
+
window.addEventListener('resize', apply);
|
|
264
|
+
return () => window.removeEventListener('resize', apply);
|
|
265
|
+
}, [panelCollapsed, getContainerBox]);
|
|
266
|
+
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
if (!panelPosition) return;
|
|
269
|
+
writePanelPreferences({ x: panelPosition.x, y: panelPosition.y, collapsed: panelCollapsed });
|
|
270
|
+
}, [panelPosition, panelCollapsed]);
|
|
271
|
+
|
|
272
|
+
const handlePanelHeaderPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
273
|
+
// Ignore drags that originate inside the header's action buttons —
|
|
274
|
+
// those have their own click handlers.
|
|
275
|
+
if ((e.target as HTMLElement).closest('[data-panel-action]')) return;
|
|
276
|
+
if (!panelRef.current) return;
|
|
277
|
+
const rect = panelRef.current.getBoundingClientRect();
|
|
278
|
+
e.preventDefault();
|
|
279
|
+
e.stopPropagation();
|
|
280
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
281
|
+
panelDragRef.current = {
|
|
282
|
+
offsetX: e.clientX - rect.left,
|
|
283
|
+
offsetY: e.clientY - rect.top,
|
|
284
|
+
pointerId: e.pointerId,
|
|
285
|
+
};
|
|
286
|
+
}, []);
|
|
287
|
+
|
|
288
|
+
const handlePanelHeaderPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
289
|
+
const drag = panelDragRef.current;
|
|
290
|
+
if (!drag || drag.pointerId !== e.pointerId) return;
|
|
291
|
+
e.preventDefault();
|
|
292
|
+
e.stopPropagation();
|
|
293
|
+
const container = getContainerBox();
|
|
294
|
+
const origin = getContainerOrigin();
|
|
295
|
+
if (!container || !origin) return;
|
|
296
|
+
const height = panelRef.current?.offsetHeight ?? 280;
|
|
297
|
+
// Translate pointer-client coords into container-local coords, then clamp.
|
|
298
|
+
const localX = e.clientX - drag.offsetX - origin.left;
|
|
299
|
+
const localY = e.clientY - drag.offsetY - origin.top;
|
|
300
|
+
setPanelPosition(clampPanelPosition(localX, localY, height, container));
|
|
301
|
+
}, [getContainerBox, getContainerOrigin]);
|
|
302
|
+
|
|
303
|
+
const handlePanelHeaderPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
304
|
+
const drag = panelDragRef.current;
|
|
305
|
+
if (!drag || drag.pointerId !== e.pointerId) return;
|
|
306
|
+
panelDragRef.current = null;
|
|
307
|
+
try {
|
|
308
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
309
|
+
} catch (_err) {
|
|
310
|
+
/* cleanup — safe to ignore: pointer already released by browser */
|
|
311
|
+
}
|
|
312
|
+
}, []);
|
|
313
|
+
|
|
314
|
+
const togglePanelCollapsed = useCallback(() => {
|
|
315
|
+
setPanelCollapsed((prev) => !prev);
|
|
316
|
+
}, []);
|
|
317
|
+
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
if (!editMode) return;
|
|
320
|
+
if (draftModelId !== modelId || !draft) {
|
|
321
|
+
beginDraft(modelId, baseMapConversion);
|
|
322
|
+
}
|
|
323
|
+
}, [baseMapConversion, beginDraft, draft, draftModelId, editMode, modelId]);
|
|
324
|
+
|
|
325
|
+
const activeDraft: CesiumPlacementDraft = draftModelId === modelId && draft
|
|
326
|
+
? draft
|
|
327
|
+
: {
|
|
328
|
+
eastings: mapConversion.eastings,
|
|
329
|
+
northings: mapConversion.northings,
|
|
330
|
+
orthogonalHeight: mapConversion.orthogonalHeight,
|
|
331
|
+
// MapConversion's cos/sin pair is optional; identity = no rotation.
|
|
332
|
+
xAxisAbscissa: mapConversion.xAxisAbscissa ?? 1,
|
|
333
|
+
xAxisOrdinate: mapConversion.xAxisOrdinate ?? 0,
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const mapUnitScale = getMapUnitScale(projectedCRS, lengthUnitScale);
|
|
337
|
+
const mapUnitSuffix = mapUnitScale === 1 ? 'm' : 'map units';
|
|
338
|
+
const baseAngle = axisAngleDegrees(baseMapConversion);
|
|
339
|
+
const activeAngle = axisAngleDegrees(activeDraft);
|
|
340
|
+
const deltaE = activeDraft.eastings - baseMapConversion.eastings;
|
|
341
|
+
const deltaN = activeDraft.northings - baseMapConversion.northings;
|
|
342
|
+
const deltaH = activeDraft.orthogonalHeight - baseMapConversion.orthogonalHeight;
|
|
343
|
+
const deltaAngle = normalizeDegrees(activeAngle - baseAngle);
|
|
344
|
+
const deltaHeightMeters = mapUnitsToMeters(deltaH, projectedCRS, lengthUnitScale);
|
|
345
|
+
const dirty = Math.abs(deltaE) > 1e-6 || Math.abs(deltaN) > 1e-6 || Math.abs(deltaH) > 1e-6 || Math.abs(deltaAngle) > 1e-6;
|
|
346
|
+
const nudgeStep = round2(metersToMapUnits(1, projectedCRS, lengthUnitScale));
|
|
347
|
+
|
|
348
|
+
const anchorWorld = useMemo((): WorldPoint => {
|
|
349
|
+
const bounds = coordinateInfo?.originalBounds;
|
|
350
|
+
const centerX = bounds ? (bounds.min.x + bounds.max.x) / 2 : 0;
|
|
351
|
+
const centerZ = bounds ? (bounds.min.z + bounds.max.z) / 2 : 0;
|
|
352
|
+
const anchorY = findClampAnchorY(bounds, storeyElevations);
|
|
353
|
+
const xyOffset = projectedDeltaToViewerDelta(
|
|
354
|
+
deltaE,
|
|
355
|
+
deltaN,
|
|
356
|
+
baseMapConversion,
|
|
357
|
+
projectedCRS,
|
|
358
|
+
lengthUnitScale,
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
x: centerX + xyOffset.x,
|
|
363
|
+
y: anchorY + deltaHeightMeters,
|
|
364
|
+
z: centerZ + xyOffset.z,
|
|
365
|
+
};
|
|
366
|
+
}, [
|
|
367
|
+
baseMapConversion,
|
|
368
|
+
coordinateInfo?.originalBounds,
|
|
369
|
+
deltaE,
|
|
370
|
+
deltaN,
|
|
371
|
+
deltaHeightMeters,
|
|
372
|
+
lengthUnitScale,
|
|
373
|
+
projectedCRS,
|
|
374
|
+
storeyElevations,
|
|
375
|
+
]);
|
|
376
|
+
const gizmoHalfWorldSize = useMemo(
|
|
377
|
+
() => getGizmoWorldSize(coordinateInfo),
|
|
378
|
+
[coordinateInfo],
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
if (!editMode) return;
|
|
383
|
+
let raf = 0;
|
|
384
|
+
const project = () => {
|
|
385
|
+
const renderer = getGlobalRenderer();
|
|
386
|
+
const camera = renderer?.getCamera();
|
|
387
|
+
const canvas = renderer?.getCanvas();
|
|
388
|
+
if (camera && canvas) {
|
|
389
|
+
const w = canvas.clientWidth;
|
|
390
|
+
const h = canvas.clientHeight;
|
|
391
|
+
const center = camera.projectToScreen(anchorWorld, w, h);
|
|
392
|
+
const heightAxisMeters = gizmoHalfWorldSize * 1.25;
|
|
393
|
+
const heightTip = camera.projectToScreen(
|
|
394
|
+
{ ...anchorWorld, y: anchorWorld.y + heightAxisMeters },
|
|
395
|
+
w,
|
|
396
|
+
h,
|
|
397
|
+
);
|
|
398
|
+
const rotationRadians = deltaAngle * Math.PI / 180;
|
|
399
|
+
const ux = { x: Math.cos(rotationRadians), z: -Math.sin(rotationRadians) };
|
|
400
|
+
const uz = { x: Math.sin(rotationRadians), z: Math.cos(rotationRadians) };
|
|
401
|
+
const corner = (sx: number, sz: number) => camera.projectToScreen(
|
|
402
|
+
{
|
|
403
|
+
x: anchorWorld.x + ux.x * sx + uz.x * sz,
|
|
404
|
+
y: anchorWorld.y,
|
|
405
|
+
z: anchorWorld.z + ux.z * sx + uz.z * sz,
|
|
406
|
+
},
|
|
407
|
+
w,
|
|
408
|
+
h,
|
|
409
|
+
);
|
|
410
|
+
const c0 = corner(-gizmoHalfWorldSize, -gizmoHalfWorldSize);
|
|
411
|
+
const c1 = corner(gizmoHalfWorldSize, -gizmoHalfWorldSize);
|
|
412
|
+
const c2 = corner(gizmoHalfWorldSize, gizmoHalfWorldSize);
|
|
413
|
+
const c3 = corner(-gizmoHalfWorldSize, gizmoHalfWorldSize);
|
|
414
|
+
if (center && heightTip && c0 && c1 && c2 && c3) {
|
|
415
|
+
setProjection({
|
|
416
|
+
center,
|
|
417
|
+
heightTip,
|
|
418
|
+
planeCorners: [c0, c1, c2, c3],
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
raf = requestAnimationFrame(project);
|
|
423
|
+
};
|
|
424
|
+
project();
|
|
425
|
+
return () => cancelAnimationFrame(raf);
|
|
426
|
+
}, [anchorWorld, deltaAngle, editMode, gizmoHalfWorldSize]);
|
|
427
|
+
|
|
428
|
+
const handleHeightPointerDown = useCallback((e: React.PointerEvent<HTMLElement>) => {
|
|
429
|
+
if (!projection) return;
|
|
430
|
+
const ray = rayFromPointerEvent(e.clientX, e.clientY);
|
|
431
|
+
if (!ray) return;
|
|
432
|
+
const startWorldY = closestYOnVerticalLineFromRay(ray, anchorWorld.x, anchorWorld.z);
|
|
433
|
+
if (startWorldY === null) return;
|
|
434
|
+
e.preventDefault();
|
|
435
|
+
e.stopPropagation();
|
|
436
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
437
|
+
dragStateRef.current = {
|
|
438
|
+
mode: 'height',
|
|
439
|
+
startDraft: activeDraft,
|
|
440
|
+
anchorX: anchorWorld.x,
|
|
441
|
+
anchorZ: anchorWorld.z,
|
|
442
|
+
startWorldY,
|
|
443
|
+
};
|
|
444
|
+
}, [activeDraft, anchorWorld.x, anchorWorld.z, projection]);
|
|
445
|
+
|
|
446
|
+
const handlePlanePointerDown = useCallback((e: React.PointerEvent<HTMLElement>) => {
|
|
447
|
+
if (!projection) return;
|
|
448
|
+
const ray = rayFromPointerEvent(e.clientX, e.clientY);
|
|
449
|
+
if (!ray) return;
|
|
450
|
+
const startHit = intersectRayWithHorizontalPlane(ray, anchorWorld.y);
|
|
451
|
+
if (!startHit) return;
|
|
452
|
+
e.preventDefault();
|
|
453
|
+
e.stopPropagation();
|
|
454
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
455
|
+
dragStateRef.current = {
|
|
456
|
+
mode: 'xy',
|
|
457
|
+
startDraft: activeDraft,
|
|
458
|
+
planeY: anchorWorld.y,
|
|
459
|
+
startHit,
|
|
460
|
+
};
|
|
461
|
+
}, [activeDraft, anchorWorld.y, projection]);
|
|
462
|
+
|
|
463
|
+
const handlePointerMove = useCallback((e: React.PointerEvent<Element>) => {
|
|
464
|
+
const dragState = dragStateRef.current;
|
|
465
|
+
if (!dragState) return;
|
|
466
|
+
e.preventDefault();
|
|
467
|
+
e.stopPropagation();
|
|
468
|
+
|
|
469
|
+
const ray = rayFromPointerEvent(e.clientX, e.clientY);
|
|
470
|
+
if (!ray) return;
|
|
471
|
+
|
|
472
|
+
if (dragState.mode === 'height') {
|
|
473
|
+
const worldY = closestYOnVerticalLineFromRay(ray, dragState.anchorX, dragState.anchorZ);
|
|
474
|
+
if (worldY === null) return;
|
|
475
|
+
const deltaMeters = worldY - dragState.startWorldY;
|
|
476
|
+
updateDraft({
|
|
477
|
+
orthogonalHeight: round2(
|
|
478
|
+
dragState.startDraft.orthogonalHeight
|
|
479
|
+
+ metersToMapUnits(deltaMeters, projectedCRS, lengthUnitScale),
|
|
480
|
+
),
|
|
481
|
+
});
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const hit = intersectRayWithHorizontalPlane(ray, dragState.planeY);
|
|
486
|
+
if (!hit) return;
|
|
487
|
+
const deltaX = hit.x - dragState.startHit.x;
|
|
488
|
+
const deltaZ = hit.z - dragState.startHit.z;
|
|
489
|
+
// The horizontal-plane hit gives the world-space displacement directly;
|
|
490
|
+
// viewerDeltaToProjectedDelta then applies the file's xAxis rotation and
|
|
491
|
+
// unit scale to express it in Eastings/Northings.
|
|
492
|
+
const projectedDelta = viewerDeltaToProjectedDelta(
|
|
493
|
+
deltaX,
|
|
494
|
+
deltaZ,
|
|
495
|
+
dragState.startDraft,
|
|
496
|
+
projectedCRS,
|
|
497
|
+
lengthUnitScale,
|
|
498
|
+
);
|
|
499
|
+
updateDraft({
|
|
500
|
+
eastings: round2(dragState.startDraft.eastings + projectedDelta.eastings),
|
|
501
|
+
northings: round2(dragState.startDraft.northings + projectedDelta.northings),
|
|
502
|
+
});
|
|
503
|
+
}, [lengthUnitScale, projectedCRS, updateDraft]);
|
|
504
|
+
|
|
505
|
+
const handlePointerUp = useCallback((e: React.PointerEvent<Element>) => {
|
|
506
|
+
if (!dragStateRef.current) return;
|
|
507
|
+
dragStateRef.current = null;
|
|
508
|
+
try {
|
|
509
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
510
|
+
} catch (_err) {
|
|
511
|
+
/* cleanup — safe to ignore: pointer already released by browser */
|
|
512
|
+
}
|
|
513
|
+
}, []);
|
|
514
|
+
|
|
515
|
+
const handleReset = useCallback(() => {
|
|
516
|
+
beginDraft(modelId, baseMapConversion);
|
|
517
|
+
}, [baseMapConversion, beginDraft, modelId]);
|
|
518
|
+
|
|
519
|
+
const handleApply = useCallback(() => {
|
|
520
|
+
if (!dirty) return;
|
|
521
|
+
setGeorefFields(modelId, 'mapConversion', [
|
|
522
|
+
{ field: 'eastings', value: activeDraft.eastings, oldValue: baseMapConversion.eastings },
|
|
523
|
+
{ field: 'northings', value: activeDraft.northings, oldValue: baseMapConversion.northings },
|
|
524
|
+
{ field: 'orthogonalHeight', value: activeDraft.orthogonalHeight, oldValue: baseMapConversion.orthogonalHeight },
|
|
525
|
+
// MapConversion's cos/sin pair is optional in the IFC schema; fall back
|
|
526
|
+
// to the identity (1, 0) so the diff against an un-rotated source picks
|
|
527
|
+
// up the new explicit rotation rather than skipping the field entirely.
|
|
528
|
+
{ field: 'xAxisAbscissa', value: activeDraft.xAxisAbscissa, oldValue: baseMapConversion.xAxisAbscissa ?? 1 },
|
|
529
|
+
{ field: 'xAxisOrdinate', value: activeDraft.xAxisOrdinate, oldValue: baseMapConversion.xAxisOrdinate ?? 0 },
|
|
530
|
+
]);
|
|
531
|
+
resetDraft();
|
|
532
|
+
toast.success('Georeference placement updated');
|
|
533
|
+
}, [activeDraft, baseMapConversion, dirty, modelId, resetDraft, setGeorefFields]);
|
|
534
|
+
|
|
535
|
+
const nudge = useCallback((eastDelta: number, northDelta: number) => {
|
|
536
|
+
updateDraft({
|
|
537
|
+
eastings: round2(activeDraft.eastings + eastDelta),
|
|
538
|
+
northings: round2(activeDraft.northings + northDelta),
|
|
539
|
+
});
|
|
540
|
+
}, [activeDraft.eastings, activeDraft.northings, updateDraft]);
|
|
541
|
+
|
|
542
|
+
const nudgeHeight = useCallback((heightDelta: number) => {
|
|
543
|
+
updateDraft({
|
|
544
|
+
orthogonalHeight: round2(activeDraft.orthogonalHeight + heightDelta),
|
|
545
|
+
});
|
|
546
|
+
}, [activeDraft.orthogonalHeight, updateDraft]);
|
|
547
|
+
|
|
548
|
+
const nudgeRotation = useCallback((angleDelta: number) => {
|
|
549
|
+
updateDraft(axisFromAngleDegrees(activeAngle + angleDelta));
|
|
550
|
+
}, [activeAngle, updateDraft]);
|
|
551
|
+
|
|
552
|
+
const handleClose = useCallback(() => {
|
|
553
|
+
setEditMode(false);
|
|
554
|
+
setActiveTool('select');
|
|
555
|
+
resetDraft();
|
|
556
|
+
}, [resetDraft, setActiveTool, setEditMode]);
|
|
557
|
+
|
|
558
|
+
if (!editMode) return null;
|
|
559
|
+
|
|
560
|
+
// The gizmo overlay (SVG axes + drag pads) renders only once we have a
|
|
561
|
+
// valid projection of the anchor; the side panel renders unconditionally
|
|
562
|
+
// so the user can still nudge, apply, or close even if the gizmo is
|
|
563
|
+
// off-screen or behind the camera.
|
|
564
|
+
const gizmoVisuals = projection
|
|
565
|
+
? (() => {
|
|
566
|
+
const planePoints = projection.planeCorners
|
|
567
|
+
.map((point) => `${point.x},${point.y}`)
|
|
568
|
+
.join(' ');
|
|
569
|
+
const minPlaneX = Math.min(...projection.planeCorners.map((p) => p.x));
|
|
570
|
+
const maxPlaneX = Math.max(...projection.planeCorners.map((p) => p.x));
|
|
571
|
+
const minPlaneY = Math.min(...projection.planeCorners.map((p) => p.y));
|
|
572
|
+
const maxPlaneY = Math.max(...projection.planeCorners.map((p) => p.y));
|
|
573
|
+
const hitPadding = 16;
|
|
574
|
+
const [c0, c1, c2, c3] = projection.planeCorners;
|
|
575
|
+
const xAxisStart = { x: (c0.x + c3.x) / 2, y: (c0.y + c3.y) / 2 };
|
|
576
|
+
const xAxisEnd = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 };
|
|
577
|
+
const zAxisStart = { x: (c0.x + c1.x) / 2, y: (c0.y + c1.y) / 2 };
|
|
578
|
+
const zAxisEnd = { x: (c2.x + c3.x) / 2, y: (c2.y + c3.y) / 2 };
|
|
579
|
+
return {
|
|
580
|
+
planePoints,
|
|
581
|
+
minPlaneX,
|
|
582
|
+
maxPlaneX,
|
|
583
|
+
minPlaneY,
|
|
584
|
+
maxPlaneY,
|
|
585
|
+
hitPadding,
|
|
586
|
+
xAxisStart,
|
|
587
|
+
xAxisEnd,
|
|
588
|
+
zAxisStart,
|
|
589
|
+
zAxisEnd,
|
|
590
|
+
};
|
|
591
|
+
})()
|
|
592
|
+
: null;
|
|
593
|
+
|
|
594
|
+
const renderGizmo = () => {
|
|
595
|
+
if (!gizmoVisuals || !projection) return null;
|
|
596
|
+
const {
|
|
597
|
+
planePoints,
|
|
598
|
+
minPlaneX,
|
|
599
|
+
maxPlaneX,
|
|
600
|
+
minPlaneY,
|
|
601
|
+
maxPlaneY,
|
|
602
|
+
hitPadding,
|
|
603
|
+
xAxisStart,
|
|
604
|
+
xAxisEnd,
|
|
605
|
+
zAxisStart,
|
|
606
|
+
zAxisEnd,
|
|
607
|
+
} = gizmoVisuals;
|
|
608
|
+
return (
|
|
609
|
+
<>
|
|
610
|
+
<svg className="absolute inset-0 z-20 h-full w-full pointer-events-none">
|
|
611
|
+
<defs>
|
|
612
|
+
<pattern id="cesium-placement-grid" width="12" height="12" patternUnits="userSpaceOnUse">
|
|
613
|
+
<path d="M 12 0 L 0 0 0 12" fill="none" stroke="rgb(45 212 191)" strokeWidth="0.8" opacity="0.45" />
|
|
614
|
+
</pattern>
|
|
615
|
+
</defs>
|
|
616
|
+
<g style={{ pointerEvents: 'auto' }}>
|
|
617
|
+
<polygon
|
|
618
|
+
points={planePoints}
|
|
619
|
+
fill="url(#cesium-placement-grid)"
|
|
620
|
+
stroke="rgb(45 212 191)"
|
|
621
|
+
strokeWidth="3"
|
|
622
|
+
opacity="0.92"
|
|
623
|
+
pointerEvents="none"
|
|
624
|
+
>
|
|
625
|
+
<title>Drag to move Eastings/Northings</title>
|
|
626
|
+
</polygon>
|
|
627
|
+
<line
|
|
628
|
+
x1={xAxisStart.x}
|
|
629
|
+
y1={xAxisStart.y}
|
|
630
|
+
x2={xAxisEnd.x}
|
|
631
|
+
y2={xAxisEnd.y}
|
|
632
|
+
stroke="white"
|
|
633
|
+
strokeWidth="2"
|
|
634
|
+
opacity="0.8"
|
|
635
|
+
pointerEvents="none"
|
|
636
|
+
/>
|
|
637
|
+
<line
|
|
638
|
+
x1={zAxisStart.x}
|
|
639
|
+
y1={zAxisStart.y}
|
|
640
|
+
x2={zAxisEnd.x}
|
|
641
|
+
y2={zAxisEnd.y}
|
|
642
|
+
stroke="white"
|
|
643
|
+
strokeWidth="2"
|
|
644
|
+
opacity="0.8"
|
|
645
|
+
pointerEvents="none"
|
|
646
|
+
/>
|
|
647
|
+
<text
|
|
648
|
+
x={projection.center.x}
|
|
649
|
+
y={maxPlaneY + 22}
|
|
650
|
+
textAnchor="middle"
|
|
651
|
+
fill="rgb(153 246 228)"
|
|
652
|
+
fontSize="11"
|
|
653
|
+
fontFamily="monospace"
|
|
654
|
+
fontWeight="700"
|
|
655
|
+
pointerEvents="none"
|
|
656
|
+
>
|
|
657
|
+
DRAG XY
|
|
658
|
+
</text>
|
|
659
|
+
<line
|
|
660
|
+
x1={projection.center.x}
|
|
661
|
+
y1={projection.center.y}
|
|
662
|
+
x2={projection.heightTip.x}
|
|
663
|
+
y2={projection.heightTip.y}
|
|
664
|
+
stroke="rgb(251 191 36)"
|
|
665
|
+
strokeWidth="3"
|
|
666
|
+
strokeLinecap="round"
|
|
667
|
+
opacity="0.95"
|
|
668
|
+
/>
|
|
669
|
+
<circle
|
|
670
|
+
cx={projection.heightTip.x}
|
|
671
|
+
cy={projection.heightTip.y}
|
|
672
|
+
r="10"
|
|
673
|
+
fill="rgb(251 191 36)"
|
|
674
|
+
stroke="white"
|
|
675
|
+
strokeWidth="2"
|
|
676
|
+
cursor="grab"
|
|
677
|
+
pointerEvents="none"
|
|
678
|
+
>
|
|
679
|
+
<title>Drag to change OrthogonalHeight</title>
|
|
680
|
+
</circle>
|
|
681
|
+
<circle
|
|
682
|
+
cx={projection.center.x}
|
|
683
|
+
cy={projection.center.y}
|
|
684
|
+
r="4"
|
|
685
|
+
fill="white"
|
|
686
|
+
stroke="rgb(45 212 191)"
|
|
687
|
+
strokeWidth="2"
|
|
688
|
+
/>
|
|
689
|
+
</g>
|
|
690
|
+
</svg>
|
|
691
|
+
|
|
692
|
+
<button
|
|
693
|
+
type="button"
|
|
694
|
+
aria-label="Drag Eastings and Northings"
|
|
695
|
+
className="absolute z-[21] cursor-grab bg-transparent active:cursor-grabbing"
|
|
696
|
+
style={{
|
|
697
|
+
left: minPlaneX - hitPadding,
|
|
698
|
+
top: minPlaneY - hitPadding,
|
|
699
|
+
width: Math.max(56, maxPlaneX - minPlaneX + hitPadding * 2),
|
|
700
|
+
height: Math.max(56, maxPlaneY - minPlaneY + hitPadding * 2),
|
|
701
|
+
}}
|
|
702
|
+
onPointerDown={handlePlanePointerDown}
|
|
703
|
+
onPointerMove={handlePointerMove}
|
|
704
|
+
onPointerUp={handlePointerUp}
|
|
705
|
+
onPointerCancel={handlePointerUp}
|
|
706
|
+
/>
|
|
707
|
+
<button
|
|
708
|
+
type="button"
|
|
709
|
+
aria-label="Drag OrthogonalHeight"
|
|
710
|
+
className="absolute z-[22] cursor-grab rounded-full bg-transparent active:cursor-grabbing"
|
|
711
|
+
style={{
|
|
712
|
+
left: projection.heightTip.x - 18,
|
|
713
|
+
top: projection.heightTip.y - 18,
|
|
714
|
+
width: 36,
|
|
715
|
+
height: 36,
|
|
716
|
+
}}
|
|
717
|
+
onPointerDown={handleHeightPointerDown}
|
|
718
|
+
onPointerMove={handlePointerMove}
|
|
719
|
+
onPointerUp={handlePointerUp}
|
|
720
|
+
onPointerCancel={handlePointerUp}
|
|
721
|
+
/>
|
|
722
|
+
</>
|
|
723
|
+
);
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
return (
|
|
727
|
+
<>
|
|
728
|
+
{renderGizmo()}
|
|
729
|
+
|
|
730
|
+
<div
|
|
731
|
+
ref={panelRef}
|
|
732
|
+
className={cn(
|
|
733
|
+
'absolute z-30 select-none border-2 border-zinc-900 dark:border-zinc-100',
|
|
734
|
+
'bg-white text-zinc-900 dark:bg-black dark:text-zinc-100 font-mono',
|
|
735
|
+
)}
|
|
736
|
+
style={
|
|
737
|
+
// First render (no saved position yet): anchor bottom-right via
|
|
738
|
+
// CSS right/bottom so the panel appears immediately, no
|
|
739
|
+
// measure-first flicker, no offsetParent dependency. Once the
|
|
740
|
+
// user drags (or a saved position is restored), switch to
|
|
741
|
+
// absolute left/top in container-local coordinates.
|
|
742
|
+
panelPosition
|
|
743
|
+
? {
|
|
744
|
+
left: panelPosition.x,
|
|
745
|
+
top: panelPosition.y,
|
|
746
|
+
width: PANEL_WIDTH,
|
|
747
|
+
boxShadow: '4px 4px 0px 0px rgba(0,0,0,0.35)',
|
|
748
|
+
}
|
|
749
|
+
: {
|
|
750
|
+
right: PANEL_MARGIN,
|
|
751
|
+
bottom: PANEL_MARGIN,
|
|
752
|
+
width: PANEL_WIDTH,
|
|
753
|
+
boxShadow: '4px 4px 0px 0px rgba(0,0,0,0.35)',
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
>
|
|
757
|
+
{/* Header — drag handle, title, dirty indicator, collapse/close */}
|
|
758
|
+
<div
|
|
759
|
+
className={cn(
|
|
760
|
+
'flex items-center gap-2 px-2.5 py-2 border-b-2 border-zinc-900 dark:border-zinc-100',
|
|
761
|
+
'bg-zinc-50 dark:bg-zinc-950 cursor-grab active:cursor-grabbing touch-none',
|
|
762
|
+
)}
|
|
763
|
+
onPointerDown={handlePanelHeaderPointerDown}
|
|
764
|
+
onPointerMove={handlePanelHeaderPointerMove}
|
|
765
|
+
onPointerUp={handlePanelHeaderPointerUp}
|
|
766
|
+
onPointerCancel={handlePanelHeaderPointerUp}
|
|
767
|
+
role="toolbar"
|
|
768
|
+
aria-label="Move georeference panel header"
|
|
769
|
+
>
|
|
770
|
+
<GripVertical className="h-3.5 w-3.5 text-zinc-400 dark:text-zinc-600" aria-hidden />
|
|
771
|
+
<Move className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" aria-hidden />
|
|
772
|
+
<span className="text-[11px] font-bold uppercase tracking-[0.18em]">
|
|
773
|
+
Move Georef
|
|
774
|
+
</span>
|
|
775
|
+
{dirty && (
|
|
776
|
+
<span
|
|
777
|
+
className="h-1.5 w-1.5 bg-amber-500"
|
|
778
|
+
title="Unsaved changes"
|
|
779
|
+
aria-label="Unsaved changes"
|
|
780
|
+
/>
|
|
781
|
+
)}
|
|
782
|
+
<div className="ml-auto flex items-center gap-0.5">
|
|
783
|
+
<button
|
|
784
|
+
type="button"
|
|
785
|
+
data-panel-action
|
|
786
|
+
onClick={togglePanelCollapsed}
|
|
787
|
+
className={cn(
|
|
788
|
+
'inline-flex h-6 w-6 items-center justify-center border border-transparent',
|
|
789
|
+
'hover:bg-zinc-200 dark:hover:bg-zinc-800',
|
|
790
|
+
)}
|
|
791
|
+
aria-label={panelCollapsed ? 'Expand panel' : 'Collapse panel'}
|
|
792
|
+
aria-expanded={!panelCollapsed}
|
|
793
|
+
>
|
|
794
|
+
<ChevronDown
|
|
795
|
+
className={cn(
|
|
796
|
+
'h-3.5 w-3.5 transition-transform',
|
|
797
|
+
panelCollapsed && '-rotate-90',
|
|
798
|
+
)}
|
|
799
|
+
/>
|
|
800
|
+
</button>
|
|
801
|
+
<button
|
|
802
|
+
type="button"
|
|
803
|
+
data-panel-action
|
|
804
|
+
onClick={handleClose}
|
|
805
|
+
className={cn(
|
|
806
|
+
'inline-flex h-6 w-6 items-center justify-center border border-transparent',
|
|
807
|
+
'hover:bg-zinc-200 dark:hover:bg-zinc-800',
|
|
808
|
+
)}
|
|
809
|
+
aria-label="Close georeference move mode"
|
|
810
|
+
>
|
|
811
|
+
<X className="h-3.5 w-3.5" />
|
|
812
|
+
</button>
|
|
813
|
+
</div>
|
|
814
|
+
</div>
|
|
815
|
+
|
|
816
|
+
{!panelCollapsed && (
|
|
817
|
+
<div className="p-3 text-[10px]">
|
|
818
|
+
<div className="grid grid-cols-4 gap-1 border-b border-zinc-200 dark:border-zinc-800 pb-2">
|
|
819
|
+
<Metric
|
|
820
|
+
label="Delta E"
|
|
821
|
+
value={formatSigned(deltaE, mapUnitSuffix)}
|
|
822
|
+
accent="text-emerald-700 dark:text-emerald-300"
|
|
823
|
+
/>
|
|
824
|
+
<Metric
|
|
825
|
+
label="Delta N"
|
|
826
|
+
value={formatSigned(deltaN, mapUnitSuffix)}
|
|
827
|
+
accent="text-emerald-700 dark:text-emerald-300"
|
|
828
|
+
/>
|
|
829
|
+
<Metric
|
|
830
|
+
label="Delta Z"
|
|
831
|
+
value={formatSigned(deltaH, mapUnitSuffix)}
|
|
832
|
+
accent="text-amber-700 dark:text-amber-300"
|
|
833
|
+
/>
|
|
834
|
+
<Metric
|
|
835
|
+
label="Delta R"
|
|
836
|
+
value={formatSigned(deltaAngle, 'deg')}
|
|
837
|
+
accent="text-fuchsia-700 dark:text-fuchsia-300"
|
|
838
|
+
/>
|
|
839
|
+
</div>
|
|
840
|
+
|
|
841
|
+
<div className="mt-2 space-y-1">
|
|
842
|
+
<div className="pb-1 text-[9px] leading-snug text-zinc-500 dark:text-zinc-400">
|
|
843
|
+
Drag the pad on the model to move Eastings/Northings. Drag the
|
|
844
|
+
knob to change height.
|
|
845
|
+
</div>
|
|
846
|
+
<PreviewRow
|
|
847
|
+
label="Eastings"
|
|
848
|
+
value={`${activeDraft.eastings.toFixed(2)} ${mapUnitSuffix}`}
|
|
849
|
+
/>
|
|
850
|
+
<PreviewRow
|
|
851
|
+
label="Northings"
|
|
852
|
+
value={`${activeDraft.northings.toFixed(2)} ${mapUnitSuffix}`}
|
|
853
|
+
/>
|
|
854
|
+
<PreviewRow
|
|
855
|
+
label="OrthogonalHeight"
|
|
856
|
+
value={`${activeDraft.orthogonalHeight.toFixed(2)} ${mapUnitSuffix}`}
|
|
857
|
+
/>
|
|
858
|
+
<PreviewRow label="XAxis angle" value={`${activeAngle.toFixed(2)} deg`} />
|
|
859
|
+
</div>
|
|
860
|
+
|
|
861
|
+
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-1 text-[9px]">
|
|
862
|
+
<span className="text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
|
|
863
|
+
Nudge 1 m
|
|
864
|
+
</span>
|
|
865
|
+
<NudgeButton onClick={() => nudge(0, nudgeStep)} aria-label="Nudge north">
|
|
866
|
+
N+
|
|
867
|
+
</NudgeButton>
|
|
868
|
+
<span />
|
|
869
|
+
<NudgeButton onClick={() => nudge(-nudgeStep, 0)} aria-label="Nudge west">
|
|
870
|
+
E-
|
|
871
|
+
</NudgeButton>
|
|
872
|
+
<NudgeButton onClick={() => nudge(nudgeStep, 0)} aria-label="Nudge east">
|
|
873
|
+
E+
|
|
874
|
+
</NudgeButton>
|
|
875
|
+
<NudgeButton onClick={() => nudge(0, -nudgeStep)} aria-label="Nudge south">
|
|
876
|
+
N-
|
|
877
|
+
</NudgeButton>
|
|
878
|
+
</div>
|
|
879
|
+
|
|
880
|
+
<div className="mt-2 flex items-center gap-1 text-[9px]">
|
|
881
|
+
<span className="mr-auto text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
|
|
882
|
+
Height
|
|
883
|
+
</span>
|
|
884
|
+
<NudgeButton
|
|
885
|
+
onClick={() => nudgeHeight(-nudgeStep)}
|
|
886
|
+
tone="amber"
|
|
887
|
+
aria-label="Nudge height down"
|
|
888
|
+
>
|
|
889
|
+
Z-
|
|
890
|
+
</NudgeButton>
|
|
891
|
+
<NudgeButton
|
|
892
|
+
onClick={() => nudgeHeight(nudgeStep)}
|
|
893
|
+
tone="amber"
|
|
894
|
+
aria-label="Nudge height up"
|
|
895
|
+
>
|
|
896
|
+
Z+
|
|
897
|
+
</NudgeButton>
|
|
898
|
+
</div>
|
|
899
|
+
|
|
900
|
+
<div className="mt-2 flex items-center gap-1 text-[9px]">
|
|
901
|
+
<span className="mr-auto text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
|
|
902
|
+
Rotate
|
|
903
|
+
</span>
|
|
904
|
+
<NudgeButton
|
|
905
|
+
onClick={() => nudgeRotation(-1)}
|
|
906
|
+
tone="fuchsia"
|
|
907
|
+
aria-label="Rotate negative one degree"
|
|
908
|
+
>
|
|
909
|
+
R-
|
|
910
|
+
</NudgeButton>
|
|
911
|
+
<NudgeButton
|
|
912
|
+
onClick={() => nudgeRotation(1)}
|
|
913
|
+
tone="fuchsia"
|
|
914
|
+
aria-label="Rotate positive one degree"
|
|
915
|
+
>
|
|
916
|
+
R+
|
|
917
|
+
</NudgeButton>
|
|
918
|
+
</div>
|
|
919
|
+
|
|
920
|
+
<div className="mt-3 flex items-center gap-2">
|
|
921
|
+
<button
|
|
922
|
+
type="button"
|
|
923
|
+
onClick={handleApply}
|
|
924
|
+
disabled={!dirty}
|
|
925
|
+
className={cn(
|
|
926
|
+
'inline-flex flex-1 items-center justify-center gap-1.5 border-2 px-2 py-1.5 text-[10px] font-bold uppercase tracking-wide transition-colors',
|
|
927
|
+
dirty
|
|
928
|
+
? 'border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 hover:border-emerald-700 dark:border-emerald-500 dark:bg-emerald-500 dark:text-zinc-950 dark:hover:bg-emerald-400 dark:hover:border-emerald-400'
|
|
929
|
+
: 'border-zinc-300 bg-zinc-100 text-zinc-400 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-600 cursor-not-allowed',
|
|
930
|
+
)}
|
|
931
|
+
>
|
|
932
|
+
<Check className="h-3 w-3" />
|
|
933
|
+
Set as georeference
|
|
934
|
+
</button>
|
|
935
|
+
<button
|
|
936
|
+
type="button"
|
|
937
|
+
onClick={handleReset}
|
|
938
|
+
disabled={!dirty}
|
|
939
|
+
className={cn(
|
|
940
|
+
'inline-flex items-center justify-center gap-1 border-2 px-2 py-1.5 text-[10px] uppercase tracking-wide transition-colors',
|
|
941
|
+
dirty
|
|
942
|
+
? 'border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800'
|
|
943
|
+
: 'border-zinc-200 bg-zinc-50 text-zinc-400 dark:border-zinc-900 dark:bg-zinc-950 dark:text-zinc-600 cursor-not-allowed',
|
|
944
|
+
)}
|
|
945
|
+
>
|
|
946
|
+
<RotateCcw className="h-3 w-3" />
|
|
947
|
+
Reset
|
|
948
|
+
</button>
|
|
949
|
+
</div>
|
|
950
|
+
</div>
|
|
951
|
+
)}
|
|
952
|
+
</div>
|
|
953
|
+
</>
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function Metric({ label, value, accent }: { label: string; value: string; accent: string }) {
|
|
958
|
+
return (
|
|
959
|
+
<div>
|
|
960
|
+
<div className="text-[8px] uppercase tracking-[0.16em] text-zinc-500 dark:text-zinc-400">
|
|
961
|
+
{label}
|
|
962
|
+
</div>
|
|
963
|
+
<div className={cn('mt-0.5 whitespace-nowrap text-[10px] font-semibold', accent)}>
|
|
964
|
+
{value}
|
|
965
|
+
</div>
|
|
966
|
+
</div>
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function PreviewRow({ label, value }: { label: string; value: string }) {
|
|
971
|
+
return (
|
|
972
|
+
<div className="flex items-center justify-between gap-3">
|
|
973
|
+
<span className="text-zinc-500 dark:text-zinc-400">{label}</span>
|
|
974
|
+
<span className="text-zinc-900 dark:text-zinc-100">{value}</span>
|
|
975
|
+
</div>
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
type NudgeButtonProps = {
|
|
980
|
+
children: React.ReactNode;
|
|
981
|
+
onClick: () => void;
|
|
982
|
+
tone?: 'emerald' | 'amber' | 'fuchsia';
|
|
983
|
+
} & Pick<React.ButtonHTMLAttributes<HTMLButtonElement>, 'aria-label'>;
|
|
984
|
+
|
|
985
|
+
function NudgeButton({ children, onClick, tone = 'emerald', ...rest }: NudgeButtonProps) {
|
|
986
|
+
const toneClass = {
|
|
987
|
+
emerald:
|
|
988
|
+
'text-emerald-700 hover:bg-emerald-50 hover:border-emerald-600 dark:text-emerald-300 dark:hover:bg-emerald-950 dark:hover:border-emerald-400',
|
|
989
|
+
amber:
|
|
990
|
+
'text-amber-700 hover:bg-amber-50 hover:border-amber-600 dark:text-amber-300 dark:hover:bg-amber-950 dark:hover:border-amber-400',
|
|
991
|
+
fuchsia:
|
|
992
|
+
'text-fuchsia-700 hover:bg-fuchsia-50 hover:border-fuchsia-600 dark:text-fuchsia-300 dark:hover:bg-fuchsia-950 dark:hover:border-fuchsia-400',
|
|
993
|
+
}[tone];
|
|
994
|
+
|
|
995
|
+
return (
|
|
996
|
+
<button
|
|
997
|
+
type="button"
|
|
998
|
+
onClick={onClick}
|
|
999
|
+
className={cn(
|
|
1000
|
+
'border-2 border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900',
|
|
1001
|
+
'px-2 py-1 font-bold tracking-wider transition-colors',
|
|
1002
|
+
toneClass,
|
|
1003
|
+
)}
|
|
1004
|
+
{...rest}
|
|
1005
|
+
>
|
|
1006
|
+
{children}
|
|
1007
|
+
</button>
|
|
1008
|
+
);
|
|
1009
|
+
}
|