@thatopen/services 0.3.10 → 0.3.12
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/dist/cli/commands/revit.d.ts +9 -0
- package/dist/cli/lib/revit-addin.d.ts +20 -0
- package/dist/cli.js +101 -0
- package/dist/cloud/LasToPotree.d.ts +59 -0
- package/dist/cloud/SplatToFq.d.ts +38 -0
- package/dist/cloud/index.d.ts +2 -0
- package/docs/builtin/paths.json +86 -86
- package/docs/cloud/GaussianSplatConverter.md +43 -0
- package/docs/cloud/PointCloudConverter.md +53 -0
- package/docs/revit-collab-quickstart.md +151 -0
- package/package.json +95 -95
- package/src/built-in/ClashesManager/example.ts +283 -283
- package/src/built-in/UIManager/src/clashes-panel/example.ts +160 -160
- package/src/built-in/UIManager/src/models-list/example.ts +122 -118
- package/src/cli/templates/app/README.md +217 -241
- package/src/cli/templates/app/package.json +33 -33
- package/src/cli/templates/app/src/setups/camera-tools.ts +0 -63
- package/src/cli/templates/app/src/setups/camera-views.ts +0 -201
- package/src/cli/templates/app/src/setups/card-header.ts +0 -35
- package/src/cli/templates/app/src/setups/clipper-tool.ts +0 -773
- package/src/cli/templates/app/src/setups/clipper.ts +0 -75
- package/src/cli/templates/app/src/setups/cloud-runner.ts +0 -13
- package/src/cli/templates/app/src/setups/exploded-view.ts +0 -246
- package/src/cli/templates/app/src/setups/helper-panel.ts +0 -88
- package/src/cli/templates/app/src/setups/hider.ts +0 -98
- package/src/cli/templates/app/src/setups/index.ts +0 -23
- package/src/cli/templates/app/src/setups/inspection.ts +0 -136
- package/src/cli/templates/app/src/setups/measurement-panel.ts +0 -188
- package/src/cli/templates/app/src/setups/measurement-tool.ts +0 -588
- package/src/cli/templates/app/src/setups/measurements.ts +0 -133
- package/src/cli/templates/app/src/setups/navigation-gizmo.ts +0 -316
- package/src/cli/templates/app/src/setups/plans-panel.ts +0 -619
- package/src/cli/templates/app/src/setups/right-sidebar.ts +0 -178
- package/src/cli/templates/app/src/setups/styles-panel.ts +0 -110
- package/src/cli/templates/app/src/setups/styles.ts +0 -375
- package/src/cli/templates/app/src/setups/tool-mode-manager.ts +0 -156
- package/src/cli/templates/app/src/setups/tool-mode.ts +0 -23
- package/src/cli/templates/app/src/setups/toolbar.ts +0 -229
- package/src/cli/templates/app/src/setups/ui-manager.ts +0 -27
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import * as THREE from "three";
|
|
2
|
-
import * as OBC from "@thatopen/components";
|
|
3
|
-
import * as OBF from "@thatopen/components-front";
|
|
4
|
-
import type { ModeTool } from "./tool-mode";
|
|
5
|
-
|
|
6
|
-
// Brand purple, matching the selection outline so measurements read as "ours".
|
|
7
|
-
const MEASURE_COLOR = 0x6528d7;
|
|
8
|
-
|
|
9
|
-
const canvasOf = (world: OBC.World): HTMLCanvasElement => {
|
|
10
|
-
const canvas = (world.renderer as OBF.PostproductionRenderer | null)?.three
|
|
11
|
-
.domElement;
|
|
12
|
-
if (!canvas) {
|
|
13
|
-
throw new Error("measurements setup: world has no renderer with a canvas");
|
|
14
|
-
}
|
|
15
|
-
return canvas;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Length-measurement tool. Wraps `OBF.LengthMeasurement` as a toolbar mode tool:
|
|
20
|
-
*
|
|
21
|
-
* - double-click places measurement points (snapping to vertices/edges); a
|
|
22
|
-
* dimension line + label is drawn between them,
|
|
23
|
-
* - Escape cancels the in-progress measurement (handled by the component),
|
|
24
|
-
* - Delete / Backspace → delete the measurement under the cursor,
|
|
25
|
-
* - Shift + Delete / Shift + Backspace → clear ALL measurements.
|
|
26
|
-
*
|
|
27
|
-
* Finished measurements persist when the mode is exited.
|
|
28
|
-
*/
|
|
29
|
-
export const lengthMeasurement = (
|
|
30
|
-
components: OBC.Components,
|
|
31
|
-
world: OBC.World,
|
|
32
|
-
): ModeTool => {
|
|
33
|
-
const measurer = components.get(OBF.LengthMeasurement);
|
|
34
|
-
measurer.world = world;
|
|
35
|
-
measurer.color = new THREE.Color(MEASURE_COLOR);
|
|
36
|
-
const canvas = canvasOf(world);
|
|
37
|
-
|
|
38
|
-
const onDblClick = () => measurer.create();
|
|
39
|
-
|
|
40
|
-
const onKeyDown = (event: KeyboardEvent) => {
|
|
41
|
-
if (event.code !== "Delete" && event.code !== "Backspace") return;
|
|
42
|
-
if (event.shiftKey) {
|
|
43
|
-
measurer.list.clear();
|
|
44
|
-
} else {
|
|
45
|
-
void measurer.delete();
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
let active = false;
|
|
50
|
-
|
|
51
|
-
return {
|
|
52
|
-
label: "Measure length",
|
|
53
|
-
icon: "mdi:ruler",
|
|
54
|
-
activate() {
|
|
55
|
-
if (active) return;
|
|
56
|
-
active = true;
|
|
57
|
-
measurer.enabled = true;
|
|
58
|
-
canvas.addEventListener("dblclick", onDblClick);
|
|
59
|
-
window.addEventListener("keydown", onKeyDown);
|
|
60
|
-
canvas.style.cursor = "crosshair";
|
|
61
|
-
},
|
|
62
|
-
deactivate() {
|
|
63
|
-
if (!active) return;
|
|
64
|
-
active = false;
|
|
65
|
-
measurer.cancelCreation(); // drop any half-finished measurement
|
|
66
|
-
measurer.enabled = false;
|
|
67
|
-
canvas.removeEventListener("dblclick", onDblClick);
|
|
68
|
-
window.removeEventListener("keydown", onKeyDown);
|
|
69
|
-
canvas.style.cursor = "";
|
|
70
|
-
},
|
|
71
|
-
};
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Area-measurement tool. Wraps `OBF.AreaMeasurement` as a toolbar mode tool:
|
|
76
|
-
*
|
|
77
|
-
* - double-click adds polygon points; Enter closes/finalizes the polygon and
|
|
78
|
-
* shows the filled area + value,
|
|
79
|
-
* - Escape cancels the in-progress polygon (handled by the component),
|
|
80
|
-
* - Delete / Backspace → delete the area under the cursor,
|
|
81
|
-
* - Shift + Delete / Shift + Backspace → clear ALL areas.
|
|
82
|
-
*
|
|
83
|
-
* NOTE: the click-to-add-point vs. double-click-to-create flow is worth a live
|
|
84
|
-
* check; tuned here to mirror the length tool's double-click entry.
|
|
85
|
-
*/
|
|
86
|
-
export const areaMeasurement = (
|
|
87
|
-
components: OBC.Components,
|
|
88
|
-
world: OBC.World,
|
|
89
|
-
): ModeTool => {
|
|
90
|
-
const measurer = components.get(OBF.AreaMeasurement);
|
|
91
|
-
measurer.world = world;
|
|
92
|
-
measurer.color = new THREE.Color(MEASURE_COLOR);
|
|
93
|
-
const canvas = canvasOf(world);
|
|
94
|
-
|
|
95
|
-
const onDblClick = () => measurer.create();
|
|
96
|
-
|
|
97
|
-
const onKeyDown = (event: KeyboardEvent) => {
|
|
98
|
-
if (event.code === "Enter" || event.code === "NumpadEnter") {
|
|
99
|
-
measurer.endCreation();
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
if (event.code !== "Delete" && event.code !== "Backspace") return;
|
|
103
|
-
if (event.shiftKey) {
|
|
104
|
-
measurer.list.clear();
|
|
105
|
-
} else {
|
|
106
|
-
void measurer.delete();
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
let active = false;
|
|
111
|
-
|
|
112
|
-
return {
|
|
113
|
-
label: "Measure area",
|
|
114
|
-
icon: "mdi:vector-square",
|
|
115
|
-
activate() {
|
|
116
|
-
if (active) return;
|
|
117
|
-
active = true;
|
|
118
|
-
measurer.enabled = true;
|
|
119
|
-
canvas.addEventListener("dblclick", onDblClick);
|
|
120
|
-
window.addEventListener("keydown", onKeyDown);
|
|
121
|
-
canvas.style.cursor = "crosshair";
|
|
122
|
-
},
|
|
123
|
-
deactivate() {
|
|
124
|
-
if (!active) return;
|
|
125
|
-
active = false;
|
|
126
|
-
measurer.cancelCreation();
|
|
127
|
-
measurer.enabled = false;
|
|
128
|
-
canvas.removeEventListener("dblclick", onDblClick);
|
|
129
|
-
window.removeEventListener("keydown", onKeyDown);
|
|
130
|
-
canvas.style.cursor = "";
|
|
131
|
-
},
|
|
132
|
-
};
|
|
133
|
-
};
|
|
@@ -1,316 +0,0 @@
|
|
|
1
|
-
import * as THREE from "three";
|
|
2
|
-
import * as OBC from "@thatopen/components";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* NAVIGATION GIZMO — a Blender/Spline-style axis-ball orientation widget pinned
|
|
6
|
-
* TOP-RIGHT of the viewport. Replaces the old camera-views overlay.
|
|
7
|
-
*
|
|
8
|
-
* A small rounded dark DISC contains a soft axis triad that reflects the LIVE
|
|
9
|
-
* camera orientation (it rotates with the camera): a neutral hub and three muted
|
|
10
|
-
* axes (soft red/green/blue), each a ball on a SHORT, THICK rounded bar. No
|
|
11
|
-
* labels, no outer ring. Colour is FIXED BY AXIS SIGN — +X/+Y/+Z are coloured
|
|
12
|
-
* (ball + arm); −X/−Y/−Z are small flat-gray dots (no arm), regardless of facing.
|
|
13
|
-
* Click a ball → orient + frame the model's union bbox in a SINGLE animated move
|
|
14
|
-
* (one `setLookAt` whose distance is computed to frame the sphere — no separate
|
|
15
|
-
* fitToSphere). Hovering an end gently brightens it. No home/fit button (the
|
|
16
|
-
* Focus command zoom-to-fits the whole model when nothing is selected) and no
|
|
17
|
-
* ortho/persp toggle (the bottom toolbar owns it).
|
|
18
|
-
*
|
|
19
|
-
* Same orient-on-click technique as a view-cube; this is the axis-ball variant.
|
|
20
|
-
* The widget renders in its OWN tiny WebGL renderer (a fixed ~60px disc overlay,
|
|
21
|
-
* tight around the triad) fully decoupled from the main viewport — a constant
|
|
22
|
-
* on-screen size. The gizmo camera is ALWAYS ORTHOGRAPHIC (axonometric — the arms
|
|
23
|
-
* never foreshorten), posed each frame to mirror the main camera's orientation,
|
|
24
|
-
* so the end facing you is the axis the camera is looking down. Picking is a
|
|
25
|
-
* single raycast against the six ball meshes → the nearest ball's axis direction.
|
|
26
|
-
*
|
|
27
|
-
* Self-mounts into the viewport overlay (like the old camera-views). Wire from
|
|
28
|
-
* main.ts with one line, after the viewport exists:
|
|
29
|
-
*
|
|
30
|
-
* navigationGizmo(components, viewerElement);
|
|
31
|
-
*
|
|
32
|
-
* @param components engine components
|
|
33
|
-
* @param container optional viewport element to overlay; defaults to the first
|
|
34
|
-
* world's renderer container.
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
const SIZE = 60; // px, fixed on-screen widget size (~50% smaller)
|
|
38
|
-
const DIST = 5; // gizmo camera distance from the triad
|
|
39
|
-
const FRUSTUM = 1.08; // ortho half-extent — tight around the triad (little padding)
|
|
40
|
-
const R = 0.82; // ball distance from the hub
|
|
41
|
-
|
|
42
|
-
// Colour is FIXED BY AXIS SIGN: +X/+Y/+Z are coloured (ball + arm), the negatives
|
|
43
|
-
// are always flat gray — independent of facing.
|
|
44
|
-
const AXES: { dir: [number, number, number]; color: number; positive: boolean }[] = [
|
|
45
|
-
{ dir: [1, 0, 0], color: 0xcf6f6f, positive: true }, // +X soft red
|
|
46
|
-
{ dir: [-1, 0, 0], color: 0xcf6f6f, positive: false },
|
|
47
|
-
{ dir: [0, 1, 0], color: 0x86b572, positive: true }, // +Y soft green
|
|
48
|
-
{ dir: [0, -1, 0], color: 0x86b572, positive: false },
|
|
49
|
-
{ dir: [0, 0, 1], color: 0x7095c9, positive: true }, // +Z soft blue
|
|
50
|
-
{ dir: [0, 0, -1], color: 0x7095c9, positive: false },
|
|
51
|
-
];
|
|
52
|
-
const GRAY = new THREE.Color(0x6e7177); // negatives are always flat gray
|
|
53
|
-
|
|
54
|
-
interface Axis {
|
|
55
|
-
ball: THREE.Mesh<THREE.SphereGeometry, THREE.MeshBasicMaterial>;
|
|
56
|
-
arm: THREE.Mesh<THREE.CapsuleGeometry, THREE.MeshBasicMaterial>;
|
|
57
|
-
baseColor: THREE.Color; // fixed appearance colour (axis colour, or gray for −axes)
|
|
58
|
-
baseScale: number;
|
|
59
|
-
dir: THREE.Vector3;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export const navigationGizmo = (
|
|
63
|
-
components: OBC.Components,
|
|
64
|
-
container?: HTMLElement,
|
|
65
|
-
) => {
|
|
66
|
-
const fragments = components.get(OBC.FragmentsManager);
|
|
67
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
68
|
-
const firstWorld = (): any => [...components.get(OBC.Worlds).list.values()][0];
|
|
69
|
-
|
|
70
|
-
// ── Overlay DOM (pinned top-right, click-through except on the widget) ──
|
|
71
|
-
const root = document.createElement("div");
|
|
72
|
-
root.className = "nav-gizmo";
|
|
73
|
-
root.style.cssText = [
|
|
74
|
-
"position: absolute",
|
|
75
|
-
"top: 1rem",
|
|
76
|
-
"right: 1rem",
|
|
77
|
-
"z-index: 20",
|
|
78
|
-
"display: flex",
|
|
79
|
-
"flex-direction: column",
|
|
80
|
-
"align-items: center",
|
|
81
|
-
"gap: 0.25rem",
|
|
82
|
-
"pointer-events: none",
|
|
83
|
-
].join(";");
|
|
84
|
-
|
|
85
|
-
// The subtle dark DISC is the canvas itself (the alpha renderer clears
|
|
86
|
-
// transparent, so this soft CSS background + round clip shows through). No bold
|
|
87
|
-
// outer ring — just the disc + a faint shadow for separation.
|
|
88
|
-
const canvas = document.createElement("canvas");
|
|
89
|
-
canvas.style.cssText = [
|
|
90
|
-
`width:${SIZE}px`,
|
|
91
|
-
`height:${SIZE}px`,
|
|
92
|
-
"pointer-events:auto",
|
|
93
|
-
"cursor:pointer",
|
|
94
|
-
"border-radius:50%",
|
|
95
|
-
// Opaque circular background matching the panels (BUI theme base colour).
|
|
96
|
-
"background:var(--bim-ui_bg-base, var(--bim-ui_bg-contrast-20, #1b1b1f))",
|
|
97
|
-
"box-shadow:0 1px 6px rgba(0,0,0,0.28)",
|
|
98
|
-
].join(";");
|
|
99
|
-
root.appendChild(canvas);
|
|
100
|
-
|
|
101
|
-
// ── Gizmo scene (own renderer, fully decoupled from the main viewport) ──
|
|
102
|
-
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
|
|
103
|
-
renderer.setPixelRatio(window.devicePixelRatio || 1);
|
|
104
|
-
renderer.setSize(SIZE, SIZE, false);
|
|
105
|
-
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
106
|
-
|
|
107
|
-
const scene = new THREE.Scene();
|
|
108
|
-
const gizmoCam = new THREE.OrthographicCamera(
|
|
109
|
-
-FRUSTUM, FRUSTUM, FRUSTUM, -FRUSTUM, 0.1, 100,
|
|
110
|
-
);
|
|
111
|
-
|
|
112
|
-
// Small neutral center hub.
|
|
113
|
-
scene.add(
|
|
114
|
-
new THREE.Mesh(
|
|
115
|
-
new THREE.SphereGeometry(0.06, 16, 12),
|
|
116
|
-
new THREE.MeshBasicMaterial({ color: 0x8a8f96 }),
|
|
117
|
-
),
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
// Six axis ends: a ball + a SHORT, THICK rounded bar (capsule) from the hub.
|
|
121
|
-
// Appearance is FIXED BY SIGN: +axes coloured (ball + arm), −axes flat gray
|
|
122
|
-
// (small ball, no arm).
|
|
123
|
-
const BALL_R = 0.12; // ball radius
|
|
124
|
-
const ARM_R = 0.05; // arm (capsule) radius — fat
|
|
125
|
-
const ARM_INNER = 0.08; // bar starts just outside the hub
|
|
126
|
-
const ARM_OUTER = R - BALL_R; // bar ends just inside the ball
|
|
127
|
-
const ARM_LEN = ARM_OUTER - ARM_INNER - 2 * ARM_R; // cylinder length (caps add 2·R)
|
|
128
|
-
const ARM_MID = (ARM_INNER + ARM_OUTER) / 2; // centre offset along the axis
|
|
129
|
-
const _yAxis = new THREE.Vector3(0, 1, 0);
|
|
130
|
-
const axes: Axis[] = [];
|
|
131
|
-
for (const a of AXES) {
|
|
132
|
-
const dir = new THREE.Vector3(...a.dir);
|
|
133
|
-
const baseColor = a.positive ? new THREE.Color(a.color) : GRAY.clone();
|
|
134
|
-
const baseScale = 1; // negatives are full-size too — just gray + no arm
|
|
135
|
-
|
|
136
|
-
const ball = new THREE.Mesh(
|
|
137
|
-
new THREE.SphereGeometry(BALL_R, 20, 16),
|
|
138
|
-
new THREE.MeshBasicMaterial({ color: baseColor.clone(), transparent: true, opacity: a.positive ? 1 : 0.7 }),
|
|
139
|
-
);
|
|
140
|
-
ball.position.copy(dir).multiplyScalar(R);
|
|
141
|
-
ball.scale.setScalar(baseScale);
|
|
142
|
-
ball.renderOrder = 2;
|
|
143
|
-
scene.add(ball);
|
|
144
|
-
|
|
145
|
-
// Capsule points along +Y by default → rotate to the axis, centre it on the
|
|
146
|
-
// hub→ball midpoint. Only the coloured (+) axes show an arm.
|
|
147
|
-
const arm = new THREE.Mesh(
|
|
148
|
-
new THREE.CapsuleGeometry(ARM_R, ARM_LEN, 6, 12),
|
|
149
|
-
new THREE.MeshBasicMaterial({ color: baseColor.clone(), transparent: true }),
|
|
150
|
-
);
|
|
151
|
-
arm.quaternion.setFromUnitVectors(_yAxis, dir);
|
|
152
|
-
arm.position.copy(dir).multiplyScalar(ARM_MID);
|
|
153
|
-
arm.renderOrder = 1;
|
|
154
|
-
arm.visible = a.positive;
|
|
155
|
-
scene.add(arm);
|
|
156
|
-
|
|
157
|
-
axes.push({ ball, arm, dir, baseColor, baseScale });
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// ── Pose the gizmo camera to mirror the main camera each frame ──
|
|
161
|
-
const _fwd = new THREE.Vector3();
|
|
162
|
-
const _up = new THREE.Vector3();
|
|
163
|
-
const _lastQuat = new THREE.Quaternion(0, 0, 0, 0);
|
|
164
|
-
const poseGizmo = (): boolean => {
|
|
165
|
-
const main = firstWorld()?.camera?.three as THREE.Camera | undefined;
|
|
166
|
-
if (!main) return false;
|
|
167
|
-
main.getWorldDirection(_fwd); // direction the main camera looks (into scene)
|
|
168
|
-
gizmoCam.position.copy(_fwd).multiplyScalar(-DIST); // mirror that viewpoint
|
|
169
|
-
_up.set(0, 1, 0).applyQuaternion(main.quaternion); // honor camera roll
|
|
170
|
-
gizmoCam.up.copy(_up);
|
|
171
|
-
gizmoCam.lookAt(0, 0, 0);
|
|
172
|
-
return true;
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
const WHITE = new THREE.Color(0xffffff);
|
|
176
|
-
let hovered: Axis | null = null;
|
|
177
|
-
|
|
178
|
-
let hoverDirty = false;
|
|
179
|
-
const render = () => {
|
|
180
|
-
if (!poseGizmo()) return;
|
|
181
|
-
renderer.render(scene, gizmoCam);
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
// Render only when the orientation changed (or a hover changed) — cheap.
|
|
185
|
-
let raf = 0;
|
|
186
|
-
const loop = () => {
|
|
187
|
-
raf = requestAnimationFrame(loop);
|
|
188
|
-
void raf;
|
|
189
|
-
const main = firstWorld()?.camera?.three as THREE.Camera | undefined;
|
|
190
|
-
if (!main) return;
|
|
191
|
-
if (hoverDirty || !main.quaternion.equals(_lastQuat)) {
|
|
192
|
-
_lastQuat.copy(main.quaternion);
|
|
193
|
-
hoverDirty = false;
|
|
194
|
-
render();
|
|
195
|
-
}
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
// ── Picking: nearest ball under the cursor ─────────────────────
|
|
199
|
-
const raycaster = new THREE.Raycaster();
|
|
200
|
-
const ndc = new THREE.Vector2();
|
|
201
|
-
const ballMeshes = axes.map((ax) => ax.ball);
|
|
202
|
-
const pickBall = (ev: PointerEvent | MouseEvent): Axis | null => {
|
|
203
|
-
const rect = canvas.getBoundingClientRect();
|
|
204
|
-
ndc.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
|
|
205
|
-
ndc.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
|
|
206
|
-
raycaster.setFromCamera(ndc, gizmoCam);
|
|
207
|
-
const hit = raycaster.intersectObjects(ballMeshes, false)[0];
|
|
208
|
-
if (!hit) return null;
|
|
209
|
-
return axes.find((ax) => ax.ball === hit.object) ?? null;
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
const setHover = (ax: Axis | null) => {
|
|
213
|
-
if (hovered === ax) return;
|
|
214
|
-
if (hovered) {
|
|
215
|
-
hovered.ball.scale.setScalar(hovered.baseScale);
|
|
216
|
-
hovered.ball.material.color.copy(hovered.baseColor);
|
|
217
|
-
}
|
|
218
|
-
hovered = ax;
|
|
219
|
-
if (ax) {
|
|
220
|
-
ax.ball.scale.setScalar(ax.baseScale * 1.18); // subtle grow
|
|
221
|
-
ax.ball.material.color.copy(ax.baseColor).lerp(WHITE, 0.22); // gentle brighten
|
|
222
|
-
}
|
|
223
|
-
canvas.style.cursor = ax ? "pointer" : "default";
|
|
224
|
-
hoverDirty = true;
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
// ── Orient + frame in ONE animated move ────────────────────────
|
|
228
|
-
// Compute the framing distance ourselves and issue a SINGLE setLookAt (which
|
|
229
|
-
// tweens position + target together) — no separate fitToSphere, so a gizmo
|
|
230
|
-
// click is one continuous orient-and-frame motion, not two.
|
|
231
|
-
const unionBox = () => {
|
|
232
|
-
const box = new THREE.Box3();
|
|
233
|
-
for (const model of fragments.list.values()) {
|
|
234
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
235
|
-
const b = (model as any).box as THREE.Box3 | undefined;
|
|
236
|
-
if (b && !b.isEmpty()) box.union(b);
|
|
237
|
-
}
|
|
238
|
-
return box;
|
|
239
|
-
};
|
|
240
|
-
|
|
241
|
-
const orientTo = (dir: THREE.Vector3) => {
|
|
242
|
-
const world = firstWorld();
|
|
243
|
-
const controls = world?.camera?.controls;
|
|
244
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
245
|
-
const main = world?.camera?.three as any;
|
|
246
|
-
if (!controls?.setLookAt || !main) return;
|
|
247
|
-
try {
|
|
248
|
-
const box = unionBox();
|
|
249
|
-
if (box.isEmpty()) return; // nothing loaded → no-op gracefully
|
|
250
|
-
// Frame the actual MODEL AABB (not its bounding sphere — the sphere over-
|
|
251
|
-
// estimates ~1.7× for a box, landing the camera too far). For an axis view
|
|
252
|
-
// the distance is driven by the box's two PERPENDICULAR extents (fit the
|
|
253
|
-
// larger against the narrower fov half-angle) plus half the depth along the
|
|
254
|
-
// axis so the camera sits just outside the box. ONE animated setLookAt.
|
|
255
|
-
const v = dir.clone().normalize();
|
|
256
|
-
const center = box.getCenter(new THREE.Vector3());
|
|
257
|
-
const size = box.getSize(new THREE.Vector3());
|
|
258
|
-
const dims = [size.x, size.y, size.z];
|
|
259
|
-
const ax = Math.abs(v.x) > 0.5 ? 0 : Math.abs(v.y) > 0.5 ? 1 : 2; // dominant axis
|
|
260
|
-
const depth = dims[ax] || 1;
|
|
261
|
-
const perp = dims.filter((_, i) => i !== ax);
|
|
262
|
-
const maxPerp = Math.max(perp[0] || 0, perp[1] || 0, 1e-3);
|
|
263
|
-
if (main.isPerspectiveCamera) {
|
|
264
|
-
const vHalf = THREE.MathUtils.degToRad(main.fov || 50) / 2;
|
|
265
|
-
const aspect = main.aspect || 1;
|
|
266
|
-
const hHalf = Math.atan(Math.tan(vHalf) * aspect);
|
|
267
|
-
const fit = Math.max(0.001, Math.min(vHalf, hHalf));
|
|
268
|
-
const d = (maxPerp / 2 / Math.tan(fit) + depth / 2) * 1.12;
|
|
269
|
-
const eye = v.multiplyScalar(d).add(center);
|
|
270
|
-
void controls.setLookAt(eye.x, eye.y, eye.z, center.x, center.y, center.z, true);
|
|
271
|
-
} else {
|
|
272
|
-
// Orthographic: distance is cosmetic; place outside the box and tween the
|
|
273
|
-
// ortho zoom CONCURRENTLY (same frame → one smooth motion) to fit it.
|
|
274
|
-
const d = depth / 2 + maxPerp * 2;
|
|
275
|
-
const eye = v.multiplyScalar(d).add(center);
|
|
276
|
-
void controls.setLookAt(eye.x, eye.y, eye.z, center.x, center.y, center.z, true);
|
|
277
|
-
const fw = main.right - main.left;
|
|
278
|
-
const fh = main.top - main.bottom;
|
|
279
|
-
const zoom = Math.min(fw, fh) / (maxPerp * 1.12);
|
|
280
|
-
if (typeof controls.zoomTo === "function" && Number.isFinite(zoom) && zoom > 0) {
|
|
281
|
-
void controls.zoomTo(zoom, true);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
} catch (error) {
|
|
285
|
-
console.warn("[navigation-gizmo] orient failed", error);
|
|
286
|
-
}
|
|
287
|
-
};
|
|
288
|
-
|
|
289
|
-
// ── Interaction ────────────────────────────────────────────────
|
|
290
|
-
canvas.addEventListener("pointermove", (ev) => setHover(pickBall(ev)));
|
|
291
|
-
canvas.addEventListener("pointerleave", () => setHover(null));
|
|
292
|
-
canvas.addEventListener("click", (ev) => {
|
|
293
|
-
const b = pickBall(ev);
|
|
294
|
-
if (b) void orientTo(b.dir);
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
// ── Mount + start ──────────────────────────────────────────────
|
|
298
|
-
const resolveContainer = (): HTMLElement | undefined => {
|
|
299
|
-
if (container) return container;
|
|
300
|
-
const world = firstWorld();
|
|
301
|
-
const canvasEl = world?.renderer?.three?.domElement as HTMLElement | undefined;
|
|
302
|
-
return (canvasEl?.parentElement as HTMLElement | undefined) ?? undefined;
|
|
303
|
-
};
|
|
304
|
-
const host = resolveContainer();
|
|
305
|
-
if (host) {
|
|
306
|
-
if (getComputedStyle(host).position === "static") host.style.position = "relative";
|
|
307
|
-
host.appendChild(root);
|
|
308
|
-
} else {
|
|
309
|
-
console.warn("[navigation-gizmo] no viewport found to overlay; append the returned element manually");
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
render(); // initial paint
|
|
313
|
-
loop();
|
|
314
|
-
|
|
315
|
-
return root;
|
|
316
|
-
};
|