@arronqzy/view-scene3d 0.1.2

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.
@@ -0,0 +1,15 @@
1
+ import type { ReactNode } from "react";
2
+ import type { Scene3dPurpose } from "../types";
3
+
4
+ type Scene3dPhysicsProps = {
5
+ purpose: Scene3dPurpose;
6
+ children: ReactNode;
7
+ };
8
+
9
+ /**
10
+ * 物理世界包装必须结构稳定。切换 sandbox 时再挂载/卸载 Physics,
11
+ * 会把整棵场景卸掉,模型看起来就像空白。目前没有刚体节点,先直通。
12
+ */
13
+ export function Scene3dPhysics({ children }: Scene3dPhysicsProps) {
14
+ return <>{children}</>;
15
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * React Three Fiber 生态统一出口。
3
+ * 与 @react-three/fiber@8 / React 18 对齐;升级 fiber 9 时可同步 bump 各 @react-three/* 主版本。
4
+ *
5
+ * 未纳入:create-r3f-app(CLI 脚手架)、composer-suite(非 npm 标准包)、
6
+ * framer-motion-3d(已废弃,与 framer-motion@12+ 不兼容;3D 动画请用 @react-spring/three)。
7
+ */
8
+ export * as Fiber from "@react-three/fiber";
9
+ export * as Drei from "@react-three/drei";
10
+ export * as Postprocessing from "@react-three/postprocessing";
11
+ export * as A11y from "@react-three/a11y";
12
+ export * as XR from "@react-three/xr";
13
+ export * as Flex from "@react-three/flex";
14
+ export * as CSG from "@react-three/csg";
15
+ export * as Cannon from "@react-three/cannon";
16
+ export * as Rapier from "@react-three/rapier";
17
+ export * as P2 from "@react-three/p2";
18
+ export * as GpuPathtracer from "@react-three/gpu-pathtracer";
19
+ export * as Spring from "@react-spring/three";
20
+ export * as Gesture from "@use-gesture/react";
21
+ export * as Leva from "leva";
22
+ export * as Maath from "maath";
23
+ export * as Valtio from "valtio";
24
+ export * as Miniplex from "miniplex";
25
+ export * as Lamina from "lamina";
26
+
27
+ export * as PostprocessingCore from "postprocessing";
28
+ export * as ThreeStdlib from "three-stdlib";
@@ -0,0 +1,8 @@
1
+ export {
2
+ Scene3dCanvas,
3
+ Scene3dNodeContent,
4
+ type Scene3dNodeContentProps,
5
+ } from "./Scene3dNodeContent";
6
+ export { Scene3dA11y } from "./Scene3dA11y";
7
+ export { Scene3dEffects } from "./Scene3dEffects";
8
+ export { Scene3dPhysics } from "./Scene3dPhysics";
package/src/types.ts ADDED
@@ -0,0 +1,124 @@
1
+ export type Scene3dPivot =
2
+ | "center"
3
+ | "left"
4
+ | "right"
5
+ | "front"
6
+ | "back"
7
+ | "top"
8
+ | "bottom";
9
+
10
+ export type Scene3dAxis = "x" | "y" | "z";
11
+
12
+ export type Scene3dRotationDirection = "clockwise" | "counterclockwise";
13
+
14
+ export type Scene3dModelFormat = "glb" | "gltf" | "obj" | "fbx" | "unknown";
15
+
16
+ export type Scene3dModelAsset = {
17
+ id: string;
18
+ /** 配置面板显示名 */
19
+ label: string;
20
+ url: string;
21
+ fileName?: string;
22
+ format?: Scene3dModelFormat;
23
+ /** 从模型解析出的节点 name,按模型缓存 */
24
+ objectNames?: string[];
25
+ position?: [number, number, number];
26
+ rotation?: [number, number, number];
27
+ scale?: [number, number, number];
28
+ };
29
+
30
+ export type Scene3dObjectTag = {
31
+ id: string;
32
+ modelId: string;
33
+ objectName: string;
34
+ html: string;
35
+ /** 气泡相对节点包围盒的方向 */
36
+ anchor: Scene3dPivot;
37
+ offset?: [number, number, number];
38
+ };
39
+
40
+ export type Scene3dCameraState = {
41
+ position: [number, number, number];
42
+ target: [number, number, number];
43
+ fov: number;
44
+ near?: number;
45
+ far?: number;
46
+ };
47
+
48
+ export type Scene3dOrbitSettings = {
49
+ /** 预览模式下是否允许鼠标操控 */
50
+ enableMouseControl: boolean;
51
+ autoRotate: boolean;
52
+ autoRotateSpeed: number;
53
+ enableDamping: boolean;
54
+ };
55
+
56
+ export type Scene3dLighting = {
57
+ ambientIntensity: number;
58
+ directionalIntensity: number;
59
+ backgroundColor: string;
60
+ };
61
+
62
+ /** 产品展示 / 数字孪生 / 交互沙盘 */
63
+ export type Scene3dPurpose = "product" | "twin" | "sandbox";
64
+
65
+ export type Scene3dEnvironmentPreset =
66
+ | "none"
67
+ | "studio"
68
+ | "warehouse"
69
+ | "city"
70
+ | "sunset"
71
+ | "apartment"
72
+ | "forest";
73
+
74
+ export type Scene3dPresentation = {
75
+ purpose: Scene3dPurpose;
76
+ environmentPreset: Scene3dEnvironmentPreset;
77
+ showContactShadows: boolean;
78
+ showGrid: boolean;
79
+ clickToFocus: boolean;
80
+ highlightOnHover: boolean;
81
+ };
82
+
83
+ export type Scene3dModelAnimationRule = {
84
+ id: string;
85
+ modelId: string;
86
+ objectName: string;
87
+ axis: Scene3dAxis;
88
+ direction: Scene3dRotationDirection;
89
+ pivot: Scene3dPivot;
90
+ angleDeg: number;
91
+ durationSec: number;
92
+ loop: boolean;
93
+ delaySec?: number;
94
+ };
95
+
96
+ export type Scene3dCameraAnimationRule = {
97
+ id: string;
98
+ label?: string;
99
+ mode?: "lerp" | "orbit";
100
+ orbitSpeedDegPerSec?: number;
101
+ from: Scene3dCameraState;
102
+ to: Scene3dCameraState;
103
+ durationSec: number;
104
+ loop: boolean;
105
+ pingPong?: boolean;
106
+ };
107
+
108
+ export type Scene3dConfig = {
109
+ models: Scene3dModelAsset[];
110
+ camera: Scene3dCameraState;
111
+ orbit: Scene3dOrbitSettings;
112
+ lighting: Scene3dLighting;
113
+ presentation: Scene3dPresentation;
114
+ modelAnimations: Scene3dModelAnimationRule[];
115
+ cameraAnimations: Scene3dCameraAnimationRule[];
116
+ enableShadows?: boolean;
117
+ /** `${modelId}|||${objectName}` */
118
+ hiddenObjectKeys?: string[];
119
+ /** 单独显示某个节点;为 null 时按 hiddenObjectKeys */
120
+ soloObjectKey?: string | null;
121
+ /** 编辑态当前选中的节点,用于高亮 */
122
+ selectedObjectKey?: string | null;
123
+ objectTags?: Scene3dObjectTag[];
124
+ };
@@ -0,0 +1,494 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, onUnmounted, ref, watch } from "vue";
3
+ import { useI18n } from "@arronqzy/i18n/vue";
4
+ import * as THREE from "three";
5
+ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
6
+ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
7
+ import {
8
+ collectObjectNames,
9
+ easeInOutCubic,
10
+ getPivotWorldPoint,
11
+ isScene3dObjectVisible,
12
+ lerpCamera,
13
+ mergeScene3dConfig,
14
+ rotationSign,
15
+ type Scene3dCameraState,
16
+ type Scene3dConfig,
17
+ } from "../index";
18
+
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ config?: Scene3dConfig | null;
22
+ previewMode?: boolean;
23
+ selected?: boolean;
24
+ }>(),
25
+ { previewMode: false, selected: false }
26
+ );
27
+
28
+ const emit = defineEmits<{
29
+ updateConfig: [patch: Partial<Scene3dConfig>];
30
+ }>();
31
+
32
+ const { t } = useI18n();
33
+ const rootRef = ref<HTMLDivElement | null>(null);
34
+ const cKeyHeld = ref(false);
35
+ const merged = computed(() => mergeScene3dConfig(props.config));
36
+ const cameraEditMode = computed(() => !props.previewMode && cKeyHeld.value);
37
+ const orbitEnabled = computed(() =>
38
+ props.previewMode ? merged.value.orbit.enableMouseControl : cameraEditMode.value
39
+ );
40
+ const playbackActive = computed(() => props.previewMode || !cameraEditMode.value);
41
+ const cameraAnimating = computed(
42
+ () => merged.value.cameraAnimations.length > 0 && playbackActive.value
43
+ );
44
+ const autoRotateActive = computed(
45
+ () => !cameraAnimating.value && merged.value.orbit.autoRotate && playbackActive.value
46
+ );
47
+
48
+ let renderer: THREE.WebGLRenderer | null = null;
49
+ let scene: THREE.Scene | null = null;
50
+ let camera: THREE.PerspectiveCamera | null = null;
51
+ let controls: OrbitControls | null = null;
52
+ let raf = 0;
53
+ let clock = new THREE.Clock();
54
+ let resizeObserver: ResizeObserver | null = null;
55
+ let onKeyDownHandler: ((e: KeyboardEvent) => void) | null = null;
56
+ let onKeyUpHandler: ((e: KeyboardEvent) => void) | null = null;
57
+ let orbitInteracting = false;
58
+ let resumeCameraAnimTimer: ReturnType<typeof setTimeout> | null = null;
59
+ const tagOverlays = ref<Array<{ id: string; html: string; x: number; y: number }>>([]);
60
+ const highlightOriginals = new Map<THREE.Mesh, THREE.Material | THREE.Material[]>();
61
+ const modelRoots = new Map<string, THREE.Object3D>();
62
+ const loader = new GLTFLoader();
63
+
64
+ function syncCameraFromConfig(cfg: Scene3dConfig) {
65
+ if (!camera || !controls) return;
66
+ camera.position.set(...cfg.camera.position);
67
+ camera.fov = cfg.camera.fov;
68
+ camera.near = cfg.camera.near ?? 0.1;
69
+ camera.far = cfg.camera.far ?? 200;
70
+ camera.updateProjectionMatrix();
71
+ controls.target.set(...cfg.camera.target);
72
+ controls.update();
73
+ }
74
+
75
+ function emitCamera() {
76
+ if (!camera || !controls || props.previewMode) return;
77
+ const next: Scene3dCameraState = {
78
+ position: [camera.position.x, camera.position.y, camera.position.z],
79
+ target: [controls.target.x, controls.target.y, controls.target.z],
80
+ fov: camera.fov,
81
+ near: camera.near,
82
+ far: camera.far,
83
+ };
84
+ emit("updateConfig", { camera: next });
85
+ }
86
+
87
+ function disposeModel(id: string) {
88
+ const root = modelRoots.get(id);
89
+ if (!root) return;
90
+ root.traverse((obj) => {
91
+ if (obj instanceof THREE.Mesh) {
92
+ obj.geometry?.dispose();
93
+ const mat = obj.material;
94
+ if (Array.isArray(mat)) mat.forEach((m) => m.dispose());
95
+ else mat?.dispose();
96
+ }
97
+ });
98
+ scene?.remove(root);
99
+ modelRoots.delete(id);
100
+ }
101
+
102
+ async function loadModels(cfg: Scene3dConfig) {
103
+ if (!scene) return;
104
+ const ids = new Set(cfg.models.map((m) => m.id));
105
+ for (const id of [...modelRoots.keys()]) {
106
+ if (!ids.has(id)) disposeModel(id);
107
+ }
108
+ for (const asset of cfg.models) {
109
+ if (modelRoots.has(asset.id)) continue;
110
+ try {
111
+ const gltf = await loader.loadAsync(asset.url);
112
+ const root = gltf.scene.clone(true);
113
+ const box = new THREE.Box3().setFromObject(root);
114
+ const center = box.getCenter(new THREE.Vector3());
115
+ const basePos = asset.position ?? [0, 0, 0];
116
+ root.position.set(basePos[0] - center.x, basePos[1] - box.min.y, basePos[2] - center.z);
117
+ root.rotation.set(...(asset.rotation ?? [0, 0, 0]));
118
+ root.scale.set(...(asset.scale ?? [1, 1, 1]));
119
+ scene.add(root);
120
+ modelRoots.set(asset.id, root);
121
+ applyObjectState(cfg);
122
+ const names = collectObjectNames(root);
123
+ if (!props.previewMode) {
124
+ emit("updateConfig", {
125
+ models: cfg.models.map((m) =>
126
+ m.id === asset.id ? { ...m, objectNames: names } : m
127
+ ),
128
+ });
129
+ }
130
+ } catch {
131
+ /* ignore broken model url */
132
+ }
133
+ }
134
+ }
135
+
136
+ function restoreHighlight() {
137
+ for (const [mesh, material] of highlightOriginals) mesh.material = material;
138
+ highlightOriginals.clear();
139
+ }
140
+
141
+ function applyObjectState(cfg: Scene3dConfig) {
142
+ for (const asset of cfg.models) {
143
+ const root = modelRoots.get(asset.id);
144
+ if (!root) continue;
145
+ const named = new Set(asset.objectNames ?? collectObjectNames(root));
146
+ root.traverse((obj) => {
147
+ if (!obj.name || !named.has(obj.name)) return;
148
+ obj.visible = isScene3dObjectVisible({
149
+ modelId: asset.id,
150
+ objectName: obj.name,
151
+ hiddenObjectKeys: cfg.hiddenObjectKeys,
152
+ soloObjectKey: cfg.soloObjectKey,
153
+ });
154
+ });
155
+ }
156
+ restoreHighlight();
157
+ if (props.previewMode || !cfg.selectedObjectKey) return;
158
+ const sep = "|||";
159
+ const splitAt = cfg.selectedObjectKey.indexOf(sep);
160
+ if (splitAt < 0) return;
161
+ const modelId = cfg.selectedObjectKey.slice(0, splitAt);
162
+ const objectName = cfg.selectedObjectKey.slice(splitAt + sep.length);
163
+ const root = modelRoots.get(modelId);
164
+ const target = root?.getObjectByName(objectName);
165
+ target?.traverse((obj) => {
166
+ if (!(obj instanceof THREE.Mesh) || !obj.material) return;
167
+ highlightOriginals.set(obj, obj.material);
168
+ const cloned = Array.isArray(obj.material)
169
+ ? obj.material.map((m) => m.clone())
170
+ : obj.material.clone();
171
+ const list = Array.isArray(cloned) ? cloned : [cloned];
172
+ for (const mat of list) {
173
+ if ("emissive" in mat) {
174
+ (mat as THREE.MeshStandardMaterial).emissive.set("#38bdf8");
175
+ (mat as THREE.MeshStandardMaterial).emissiveIntensity = 0.9;
176
+ }
177
+ }
178
+ obj.material = cloned;
179
+ });
180
+ }
181
+
182
+ function updateTagOverlays(cfg: Scene3dConfig) {
183
+ const el = rootRef.value;
184
+ if (!el || !camera) {
185
+ tagOverlays.value = [];
186
+ return;
187
+ }
188
+ const w = Math.max(1, el.clientWidth);
189
+ const h = Math.max(1, el.clientHeight);
190
+ const next: Array<{ id: string; html: string; x: number; y: number }> = [];
191
+ for (const tag of cfg.objectTags ?? []) {
192
+ if (!tag.html.trim()) continue;
193
+ const root = modelRoots.get(tag.modelId);
194
+ const target = root?.getObjectByName(tag.objectName);
195
+ if (!target?.visible) continue;
196
+ target.updateMatrixWorld(true);
197
+ const box = new THREE.Box3().setFromObject(target);
198
+ if (box.isEmpty()) continue;
199
+ const world = getPivotWorldPoint(box, tag.anchor);
200
+ if (tag.offset) world.add(new THREE.Vector3(...tag.offset));
201
+ const ndc = world.project(camera);
202
+ if (ndc.z < -1 || ndc.z > 1) continue;
203
+ next.push({
204
+ id: tag.id,
205
+ html: tag.html,
206
+ x: (ndc.x * 0.5 + 0.5) * w,
207
+ y: (-ndc.y * 0.5 + 0.5) * h,
208
+ });
209
+ }
210
+ tagOverlays.value = next;
211
+ }
212
+
213
+ function applyModelAnimations(cfg: Scene3dConfig, elapsed: number) {
214
+ if (!playbackActive.value) return;
215
+ for (const rule of cfg.modelAnimations) {
216
+ const root = modelRoots.get(rule.modelId);
217
+ if (!root) continue;
218
+ const target = root.getObjectByName(rule.objectName);
219
+ if (!target) continue;
220
+ const delay = rule.delaySec ?? 0;
221
+ if (elapsed < delay) continue;
222
+ const duration = Math.max(0.05, rule.durationSec);
223
+ let progress = (elapsed - delay) / duration;
224
+ progress = rule.loop ? progress % 1 : Math.min(1, progress);
225
+ const eased = easeInOutCubic(progress);
226
+ const angle =
227
+ THREE.MathUtils.degToRad(rule.angleDeg) * rotationSign(rule.direction) * eased;
228
+ target.rotation.set(0, 0, 0);
229
+ target.updateMatrixWorld(true);
230
+ const box = new THREE.Box3().setFromObject(target);
231
+ const pivot = getPivotWorldPoint(box, rule.pivot);
232
+ const axis =
233
+ rule.axis === "x"
234
+ ? new THREE.Vector3(1, 0, 0)
235
+ : rule.axis === "y"
236
+ ? new THREE.Vector3(0, 1, 0)
237
+ : new THREE.Vector3(0, 0, 1);
238
+ const q = new THREE.Quaternion().setFromAxisAngle(axis, angle);
239
+ target.position.sub(pivot);
240
+ target.position.applyQuaternion(q);
241
+ target.position.add(pivot);
242
+ target.quaternion.premultiply(q);
243
+ }
244
+ }
245
+
246
+ function applyCameraAnimation(cfg: Scene3dConfig, elapsed: number) {
247
+ const rule = cfg.cameraAnimations[cfg.cameraAnimations.length - 1];
248
+ if (!playbackActive.value || !rule || !camera || !controls) return;
249
+ if (props.previewMode && orbitEnabled.value && orbitInteracting) return;
250
+ const duration = Math.max(0.05, rule.durationSec);
251
+ let tVal = elapsed / duration;
252
+ if (rule.loop) tVal = rule.pingPong ? Math.abs((tVal % 2) - 1) : tVal % 1;
253
+ else tVal = Math.min(1, tVal);
254
+ if (rule.mode === "orbit") {
255
+ const [tx, ty, tz] = rule.from.target;
256
+ const ox = rule.from.position[0] - tx;
257
+ const oy = rule.from.position[1] - ty;
258
+ const oz = rule.from.position[2] - tz;
259
+ const radius = Math.max(0.001, Math.hypot(ox, oz));
260
+ const baseTheta = Math.atan2(oz, ox);
261
+ const speedRad = THREE.MathUtils.degToRad(rule.orbitSpeedDegPerSec ?? 24);
262
+ const angle = rule.loop ? elapsed * speedRad : Math.min(elapsed, duration) * speedRad;
263
+ camera.position.set(tx + Math.cos(baseTheta + angle) * radius, ty + oy, tz + Math.sin(baseTheta + angle) * radius);
264
+ camera.fov = rule.from.fov;
265
+ camera.updateProjectionMatrix();
266
+ controls.target.set(...rule.from.target);
267
+ controls.update();
268
+ return;
269
+ }
270
+ const next = lerpCamera(rule.from, rule.to, easeInOutCubic(tVal));
271
+ camera.position.set(...next.position);
272
+ camera.fov = next.fov;
273
+ camera.updateProjectionMatrix();
274
+ controls.target.set(...next.target);
275
+ controls.update();
276
+ }
277
+
278
+ function resize() {
279
+ const el = rootRef.value;
280
+ if (!el || !renderer || !camera) return;
281
+ const w = Math.max(1, el.clientWidth);
282
+ const h = Math.max(1, el.clientHeight);
283
+ renderer.setSize(w, h, false);
284
+ camera.aspect = w / h;
285
+ camera.updateProjectionMatrix();
286
+ }
287
+
288
+ function animate() {
289
+ raf = requestAnimationFrame(animate);
290
+ const cfg = merged.value;
291
+ const elapsed = clock.getElapsedTime();
292
+ applyModelAnimations(cfg, elapsed);
293
+ if (cfg.cameraAnimations.length === 0 && !props.previewMode) {
294
+ syncCameraFromConfig(cfg);
295
+ } else if (cfg.cameraAnimations.length > 0) {
296
+ applyCameraAnimation(cfg, elapsed);
297
+ }
298
+ controls?.update();
299
+ updateTagOverlays(cfg);
300
+ if (renderer && scene && camera) renderer.render(scene, camera);
301
+ }
302
+
303
+ function onControlsChange() {
304
+ emitCamera();
305
+ }
306
+
307
+ function pausePreviewCameraAnimation() {
308
+ if (!props.previewMode || !orbitEnabled.value) return;
309
+ orbitInteracting = true;
310
+ if (resumeCameraAnimTimer) clearTimeout(resumeCameraAnimTimer);
311
+ resumeCameraAnimTimer = setTimeout(() => {
312
+ orbitInteracting = false;
313
+ resumeCameraAnimTimer = null;
314
+ }, 1500);
315
+ }
316
+
317
+ function onControlsInteraction() {
318
+ onControlsChange();
319
+ pausePreviewCameraAnimation();
320
+ }
321
+
322
+ onMounted(() => {
323
+ const el = rootRef.value;
324
+ if (!el) return;
325
+ scene = new THREE.Scene();
326
+ scene.background = new THREE.Color(merged.value.lighting.backgroundColor);
327
+ camera = new THREE.PerspectiveCamera(
328
+ merged.value.camera.fov,
329
+ Math.max(1, el.clientWidth) / Math.max(1, el.clientHeight),
330
+ merged.value.camera.near ?? 0.1,
331
+ merged.value.camera.far ?? 200
332
+ );
333
+ renderer = new THREE.WebGLRenderer({
334
+ antialias: true,
335
+ alpha: false,
336
+ powerPreference: "high-performance",
337
+ });
338
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
339
+ renderer.setSize(Math.max(1, el.clientWidth), Math.max(1, el.clientHeight), false);
340
+ el.appendChild(renderer.domElement);
341
+
342
+ const ambient = new THREE.AmbientLight(0xffffff, merged.value.lighting.ambientIntensity);
343
+ const dir = new THREE.DirectionalLight(0xffffff, merged.value.lighting.directionalIntensity);
344
+ dir.position.set(6, 10, 4);
345
+ scene.add(ambient, dir);
346
+
347
+ controls = new OrbitControls(camera, renderer.domElement);
348
+ controls.enableDamping = merged.value.orbit.enableDamping;
349
+ controls.autoRotate = autoRotateActive.value;
350
+ controls.autoRotateSpeed = merged.value.orbit.autoRotateSpeed;
351
+ controls.enabled = orbitEnabled.value || autoRotateActive.value;
352
+ controls.enableRotate = orbitEnabled.value;
353
+ controls.enablePan = orbitEnabled.value;
354
+ controls.enableZoom = orbitEnabled.value;
355
+ controls.addEventListener("change", onControlsInteraction);
356
+ controls.addEventListener("start", () => {
357
+ if (resumeCameraAnimTimer) clearTimeout(resumeCameraAnimTimer);
358
+ orbitInteracting = true;
359
+ });
360
+ controls.addEventListener("end", pausePreviewCameraAnimation);
361
+ syncCameraFromConfig(merged.value);
362
+ void loadModels(merged.value);
363
+ resize();
364
+ animate();
365
+
366
+ resizeObserver = new ResizeObserver(resize);
367
+ resizeObserver.observe(el);
368
+ onKeyDownHandler = (e: KeyboardEvent) => {
369
+ if (e.key === "c" || e.key === "C") cKeyHeld.value = true;
370
+ };
371
+ onKeyUpHandler = (e: KeyboardEvent) => {
372
+ if (e.key === "c" || e.key === "C") {
373
+ emitCamera();
374
+ cKeyHeld.value = false;
375
+ }
376
+ };
377
+ if (!props.previewMode) {
378
+ window.addEventListener("keydown", onKeyDownHandler);
379
+ window.addEventListener("keyup", onKeyUpHandler);
380
+ }
381
+ });
382
+
383
+ onUnmounted(() => {
384
+ cancelAnimationFrame(raf);
385
+ restoreHighlight();
386
+ tagOverlays.value = [];
387
+ if (resumeCameraAnimTimer) clearTimeout(resumeCameraAnimTimer);
388
+ resumeCameraAnimTimer = null;
389
+ orbitInteracting = false;
390
+ resizeObserver?.disconnect();
391
+ if (onKeyDownHandler) window.removeEventListener("keydown", onKeyDownHandler);
392
+ if (onKeyUpHandler) window.removeEventListener("keyup", onKeyUpHandler);
393
+ controls?.removeEventListener("change", onControlsChange);
394
+ controls?.dispose();
395
+ for (const id of [...modelRoots.keys()]) disposeModel(id);
396
+ renderer?.dispose();
397
+ renderer?.domElement.remove();
398
+ renderer = null;
399
+ scene = null;
400
+ camera = null;
401
+ controls = null;
402
+ });
403
+
404
+ watch(orbitEnabled, (enabled) => {
405
+ if (!controls) return;
406
+ controls.enabled = enabled || autoRotateActive.value;
407
+ controls.enableRotate = enabled;
408
+ controls.enablePan = enabled;
409
+ controls.enableZoom = enabled;
410
+ });
411
+
412
+ watch(autoRotateActive, (active) => {
413
+ if (!controls) return;
414
+ controls.autoRotate = active;
415
+ controls.enabled = orbitEnabled.value || active;
416
+ });
417
+
418
+ watch(
419
+ () => merged.value.models,
420
+ (models) => {
421
+ void loadModels({ ...merged.value, models });
422
+ },
423
+ { deep: true }
424
+ );
425
+
426
+ watch(
427
+ () => merged.value.lighting.backgroundColor,
428
+ (color) => {
429
+ if (scene) scene.background = new THREE.Color(color);
430
+ }
431
+ );
432
+
433
+ watch(
434
+ () => [
435
+ merged.value.hiddenObjectKeys,
436
+ merged.value.soloObjectKey,
437
+ merged.value.selectedObjectKey,
438
+ ],
439
+ () => applyObjectState(merged.value),
440
+ { deep: true }
441
+ );
442
+
443
+ watch(
444
+ () => [
445
+ merged.value.lighting.ambientIntensity,
446
+ merged.value.lighting.directionalIntensity,
447
+ ],
448
+ ([ambientIntensity, directionalIntensity]) => {
449
+ scene?.traverse((obj) => {
450
+ if (obj instanceof THREE.AmbientLight) obj.intensity = Number(ambientIntensity) || 0;
451
+ if (obj instanceof THREE.DirectionalLight) obj.intensity = Number(directionalIntensity) || 0;
452
+ });
453
+ }
454
+ );
455
+
456
+ function stopPointer(e: Event) {
457
+ if (!props.previewMode && orbitEnabled.value) e.stopPropagation();
458
+ }
459
+ </script>
460
+
461
+ <template>
462
+ <div v-if="merged.models.length === 0" class="flex h-full w-full items-center justify-center rounded border border-dashed border-gray-300 bg-gray-50 px-2 text-center text-[11px] text-gray-500">
463
+ {{ t("panel.config.scene3dPlaceholder") }}
464
+ </div>
465
+ <div v-else class="relative h-full w-full overflow-hidden rounded">
466
+ <div
467
+ v-if="cameraEditMode"
468
+ class="pointer-events-none absolute left-1 top-1 z-10 rounded bg-black/65 px-1.5 py-0.5 text-[10px] text-white"
469
+ >
470
+ {{ t("panel.config.scene3dCameraEditHint") }}
471
+ </div>
472
+ <div
473
+ v-else-if="!previewMode && selected"
474
+ class="pointer-events-none absolute bottom-1 left-1 z-10 rounded bg-black/45 px-1.5 py-0.5 text-[10px] text-white/90"
475
+ >
476
+ {{ t("panel.config.scene3dHoldCHint") }}
477
+ </div>
478
+ <div
479
+ ref="rootRef"
480
+ class="h-full w-full"
481
+ @pointerdown="stopPointer"
482
+ @mousedown="stopPointer"
483
+ />
484
+ <div class="pointer-events-none absolute inset-0 overflow-hidden">
485
+ <div
486
+ v-for="tag in tagOverlays"
487
+ :key="tag.id"
488
+ class="absolute -translate-x-1/2 -translate-y-full rounded-lg bg-black/80 px-2 py-1 text-[12px] leading-snug text-white shadow-lg"
489
+ :style="{ left: tag.x + 'px', top: tag.y + 'px' }"
490
+ v-html="tag.html"
491
+ />
492
+ </div>
493
+ </div>
494
+ </template>