@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,1116 @@
1
+ import React, {
2
+ Suspense,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from "react";
9
+ import { Canvas, useFrame, useThree, type ThreeEvent } from "@react-three/fiber";
10
+ import {
11
+ ContactShadows,
12
+ Environment,
13
+ Grid,
14
+ Html,
15
+ OrbitControls,
16
+ useGLTF,
17
+ } from "@react-three/drei";
18
+ import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
19
+ import {
20
+ Box3,
21
+ Color,
22
+ Euler,
23
+ Group,
24
+ MathUtils,
25
+ Mesh,
26
+ MOUSE,
27
+ Object3D,
28
+ PerspectiveCamera,
29
+ Quaternion,
30
+ Vector3,
31
+ type MeshStandardMaterial,
32
+ } from "three";
33
+ import { useI18nOptional } from "@arronqzy/i18n/react";
34
+ import { Scene3dA11y, Scene3dA11yAnnouncer } from "./Scene3dA11y";
35
+ import { Scene3dEffects } from "./Scene3dEffects";
36
+ import { Scene3dPhysics } from "./Scene3dPhysics";
37
+ import {
38
+ collectObjectNames,
39
+ createDefaultScene3dConfig,
40
+ easeInOutCubic,
41
+ getPivotWorldPoint,
42
+ lerpCamera,
43
+ mergeScene3dConfig,
44
+ rotationSign,
45
+ type Scene3dCameraState,
46
+ type Scene3dConfig,
47
+ type Scene3dEnvironmentPreset,
48
+ type Scene3dModelAnimationRule,
49
+ type Scene3dModelAsset,
50
+ type Scene3dObjectTag,
51
+ } from "../index";
52
+ import { isScene3dObjectVisible } from "../object-state";
53
+
54
+ type Scene3dCanvasProps = {
55
+ config: Scene3dConfig;
56
+ orbitEnabled: boolean;
57
+ previewMode: boolean;
58
+ onCameraChange?: (camera: Scene3dCameraState) => void;
59
+ onAutoFitCamera?: (camera: Scene3dCameraState) => void;
60
+ onObjectNamesDiscovered?: (modelId: string, names: string[]) => void;
61
+ onHoverName?: (name: string | null) => void;
62
+ onControlsReady?: (controls: OrbitControlsImpl | null) => void;
63
+ onCanvasDomReady?: (canvas: HTMLCanvasElement | null) => void;
64
+ };
65
+
66
+ const ENV_PRESETS: Exclude<Scene3dEnvironmentPreset, "none">[] = [
67
+ "studio",
68
+ "warehouse",
69
+ "city",
70
+ "sunset",
71
+ "apartment",
72
+ "forest",
73
+ ];
74
+
75
+ function isEnvPreset(
76
+ value: Scene3dEnvironmentPreset
77
+ ): value is Exclude<Scene3dEnvironmentPreset, "none"> {
78
+ return ENV_PRESETS.includes(value as Exclude<Scene3dEnvironmentPreset, "none">);
79
+ }
80
+
81
+ function CameraRig({
82
+ config,
83
+ orbitEnabled,
84
+ previewMode,
85
+ onCameraChange,
86
+ cameraAnimating,
87
+ focusPoint,
88
+ onControlsReady,
89
+ onOrbitInteractionChange,
90
+ }: {
91
+ config: Scene3dConfig;
92
+ orbitEnabled: boolean;
93
+ previewMode: boolean;
94
+ onCameraChange?: (camera: Scene3dCameraState) => void;
95
+ cameraAnimating: boolean;
96
+ focusPoint: Vector3 | null;
97
+ onControlsReady?: (controls: OrbitControlsImpl | null) => void;
98
+ onOrbitInteractionChange?: (active: boolean) => void;
99
+ }) {
100
+ const controlsRef = useRef<OrbitControlsImpl | null>(null);
101
+ const { camera, invalidate } = useThree();
102
+ const lastEmitRef = useRef(0);
103
+ const resumeCameraAnimRef = useRef<ReturnType<typeof setTimeout> | null>(null);
104
+ const pausePreviewCameraAnimation = useCallback(() => {
105
+ if (!previewMode || !onOrbitInteractionChange) return;
106
+ onOrbitInteractionChange(true);
107
+ if (resumeCameraAnimRef.current) clearTimeout(resumeCameraAnimRef.current);
108
+ resumeCameraAnimRef.current = setTimeout(() => {
109
+ onOrbitInteractionChange(false);
110
+ resumeCameraAnimRef.current = null;
111
+ }, 1500);
112
+ }, [onOrbitInteractionChange, previewMode]);
113
+ const setControlsRef = useCallback(
114
+ (controls: OrbitControlsImpl | null) => {
115
+ controlsRef.current = controls;
116
+ onControlsReady?.(controls);
117
+ },
118
+ [onControlsReady]
119
+ );
120
+ useEffect(
121
+ () => () => {
122
+ if (resumeCameraAnimRef.current) clearTimeout(resumeCameraAnimRef.current);
123
+ },
124
+ []
125
+ );
126
+ const autoRotateActive = !cameraAnimating && config.orbit.autoRotate && (previewMode || !orbitEnabled) && !focusPoint;
127
+ const controlsEnabled = orbitEnabled || autoRotateActive;
128
+
129
+ useEffect(() => {
130
+ if (cameraAnimating || controlsEnabled) return;
131
+ if (!(camera instanceof PerspectiveCamera)) return;
132
+ camera.position.set(...config.camera.position);
133
+ camera.fov = config.camera.fov;
134
+ camera.near = config.camera.near ?? 0.1;
135
+ camera.far = config.camera.far ?? 200;
136
+ camera.updateProjectionMatrix();
137
+ if (controlsRef.current) {
138
+ controlsRef.current.target.set(...config.camera.target);
139
+ controlsRef.current.update();
140
+ }
141
+ invalidate();
142
+ }, [
143
+ camera,
144
+ cameraAnimating,
145
+ config.camera.far,
146
+ config.camera.fov,
147
+ config.camera.near,
148
+ config.camera.position,
149
+ config.camera.target,
150
+ invalidate,
151
+ controlsEnabled,
152
+ ]);
153
+
154
+ useFrame(() => {
155
+ if (cameraAnimating && camera instanceof PerspectiveCamera) {
156
+ camera.position.set(...config.camera.position);
157
+ camera.fov = config.camera.fov;
158
+ camera.near = config.camera.near ?? 0.1;
159
+ camera.far = config.camera.far ?? 200;
160
+ camera.updateProjectionMatrix();
161
+ if (controlsRef.current) {
162
+ controlsRef.current.target.set(...config.camera.target);
163
+ controlsRef.current.update();
164
+ }
165
+ invalidate();
166
+ return;
167
+ }
168
+ if (focusPoint && controlsRef.current) {
169
+ controlsRef.current.target.lerp(focusPoint, 0.12);
170
+ controlsRef.current.update();
171
+ invalidate();
172
+ }
173
+ if (!orbitEnabled || !onCameraChange || !controlsRef.current) return;
174
+ const now = performance.now();
175
+ if (now - lastEmitRef.current < 120) return;
176
+ lastEmitRef.current = now;
177
+ onCameraChange({
178
+ position: [camera.position.x, camera.position.y, camera.position.z],
179
+ target: [
180
+ controlsRef.current.target.x,
181
+ controlsRef.current.target.y,
182
+ controlsRef.current.target.z,
183
+ ],
184
+ fov: config.camera.fov,
185
+ near: config.camera.near,
186
+ far: config.camera.far,
187
+ });
188
+ });
189
+
190
+ return (
191
+ <OrbitControls
192
+ ref={setControlsRef}
193
+ enabled={controlsEnabled}
194
+ enablePan={orbitEnabled}
195
+ enableZoom={orbitEnabled}
196
+ enableRotate={orbitEnabled}
197
+ screenSpacePanning
198
+ enableDamping={config.orbit.enableDamping}
199
+ autoRotate={autoRotateActive}
200
+ autoRotateSpeed={config.orbit.autoRotateSpeed}
201
+ mouseButtons={{
202
+ LEFT: MOUSE.ROTATE,
203
+ MIDDLE: MOUSE.DOLLY,
204
+ RIGHT: MOUSE.PAN,
205
+ }}
206
+ makeDefault
207
+ onChange={() => {
208
+ invalidate();
209
+ pausePreviewCameraAnimation();
210
+ }}
211
+ onStart={() => {
212
+ if (resumeCameraAnimRef.current) clearTimeout(resumeCameraAnimRef.current);
213
+ onOrbitInteractionChange?.(true);
214
+ }}
215
+ onEnd={() => {
216
+ pausePreviewCameraAnimation();
217
+ }}
218
+ />
219
+ );
220
+ }
221
+
222
+ function AnimatedModelPart({
223
+ root,
224
+ objectName,
225
+ rule,
226
+ playing,
227
+ }: {
228
+ root: Object3D;
229
+ objectName: string;
230
+ rule: Scene3dModelAnimationRule;
231
+ playing: boolean;
232
+ }) {
233
+ const targetRef = useRef<Object3D | null>(null);
234
+ const baseRotationRef = useRef<Euler | null>(null);
235
+ const basePositionRef = useRef<Vector3 | null>(null);
236
+ const startedAtRef = useRef<number | null>(null);
237
+
238
+ useEffect(() => {
239
+ targetRef.current = root.getObjectByName(objectName) ?? null;
240
+ if (targetRef.current) {
241
+ baseRotationRef.current = targetRef.current.rotation.clone();
242
+ basePositionRef.current = targetRef.current.position.clone();
243
+ }
244
+ startedAtRef.current = null;
245
+ }, [objectName, root, rule.id]);
246
+
247
+ useFrame(({ clock }) => {
248
+ const target = targetRef.current;
249
+ const baseRot = baseRotationRef.current;
250
+ const basePos = basePositionRef.current;
251
+ if (!target || !baseRot || !basePos || !playing) return;
252
+ if (startedAtRef.current === null) {
253
+ startedAtRef.current = clock.elapsedTime + (rule.delaySec ?? 0);
254
+ }
255
+ const elapsed = clock.elapsedTime - startedAtRef.current;
256
+ if (elapsed < 0) return;
257
+ const duration = Math.max(0.05, rule.durationSec);
258
+ let progress = elapsed / duration;
259
+ progress = rule.loop ? progress % 1 : Math.min(1, progress);
260
+ const eased = easeInOutCubic(progress);
261
+ const angleRad =
262
+ MathUtils.degToRad(rule.angleDeg) * rotationSign(rule.direction) * eased;
263
+
264
+ target.rotation.copy(baseRot);
265
+ target.position.copy(basePos);
266
+ target.updateMatrixWorld(true);
267
+
268
+ const box = new Box3().setFromObject(target);
269
+ const pivot = getPivotWorldPoint(box, rule.pivot);
270
+ const axis =
271
+ rule.axis === "x"
272
+ ? new Vector3(1, 0, 0)
273
+ : rule.axis === "y"
274
+ ? new Vector3(0, 1, 0)
275
+ : new Vector3(0, 0, 1);
276
+ const q = new Quaternion().setFromAxisAngle(axis, angleRad);
277
+ target.position.sub(pivot);
278
+ target.position.applyQuaternion(q);
279
+ target.position.add(pivot);
280
+ target.quaternion.premultiply(q);
281
+ });
282
+
283
+ return null;
284
+ }
285
+
286
+ function ObjectHtmlTag({
287
+ root,
288
+ tag,
289
+ }: {
290
+ root: Object3D;
291
+ tag: Scene3dObjectTag;
292
+ }) {
293
+ const groupRef = useRef<Group>(null);
294
+
295
+ useFrame(() => {
296
+ const group = groupRef.current;
297
+ if (!group || !tag.html.trim()) return;
298
+ const target = root.getObjectByName(tag.objectName);
299
+ if (!target) {
300
+ group.visible = false;
301
+ return;
302
+ }
303
+ target.updateMatrixWorld(true);
304
+ const box = new Box3().setFromObject(target);
305
+ if (box.isEmpty()) {
306
+ group.visible = false;
307
+ return;
308
+ }
309
+ const world = getPivotWorldPoint(box, tag.anchor);
310
+ if (tag.offset) world.add(new Vector3(...tag.offset));
311
+ const parent = group.parent;
312
+ if (parent) parent.worldToLocal(world);
313
+ group.position.copy(world);
314
+ group.visible = target.visible;
315
+ });
316
+
317
+ if (!tag.html.trim()) return null;
318
+
319
+ return (
320
+ <group ref={groupRef}>
321
+ <Html center zIndexRange={[40, 0]} style={{ pointerEvents: "none" }}>
322
+ <div
323
+ className="scene3d-object-tag"
324
+ style={{
325
+ maxWidth: 240,
326
+ padding: "6px 8px",
327
+ borderRadius: 8,
328
+ background: "rgba(15, 17, 21, 0.82)",
329
+ color: "#fff",
330
+ fontSize: 12,
331
+ lineHeight: 1.45,
332
+ boxShadow: "0 6px 18px rgba(0,0,0,.28)",
333
+ pointerEvents: "none",
334
+ whiteSpace: "normal",
335
+ }}
336
+ dangerouslySetInnerHTML={{ __html: tag.html }}
337
+ />
338
+ </Html>
339
+ </group>
340
+ );
341
+ }
342
+
343
+ function GltfModel({
344
+ asset,
345
+ onObjectNamesDiscovered,
346
+ animations,
347
+ playing,
348
+ interactive,
349
+ highlightOnHover,
350
+ clickToFocus,
351
+ onFocus,
352
+ onHoverName,
353
+ onLoaded,
354
+ hiddenObjectKeys,
355
+ soloObjectKey,
356
+ selectedObjectKey,
357
+ objectTags,
358
+ highlightSelection,
359
+ }: {
360
+ asset: Scene3dModelAsset;
361
+ onObjectNamesDiscovered?: (modelId: string, names: string[]) => void;
362
+ animations: Scene3dModelAnimationRule[];
363
+ playing: boolean;
364
+ interactive: boolean;
365
+ highlightOnHover: boolean;
366
+ clickToFocus: boolean;
367
+ onFocus?: (point: Vector3) => void;
368
+ onHoverName?: (name: string | null) => void;
369
+ onLoaded?: () => void;
370
+ hiddenObjectKeys?: string[];
371
+ soloObjectKey?: string | null;
372
+ selectedObjectKey?: string | null;
373
+ objectTags: Scene3dObjectTag[];
374
+ highlightSelection: boolean;
375
+ }) {
376
+ const gltf = useGLTF(asset.url);
377
+ const scene = useMemo(() => gltf.scene.clone(true), [gltf.scene]);
378
+ const hoveredRef = useRef<Mesh | null>(null);
379
+ const hoveredColorRef = useRef<Color | null>(null);
380
+
381
+ useEffect(() => {
382
+ const box = new Box3().setFromObject(scene);
383
+ const center = box.getCenter(new Vector3());
384
+ scene.position.x -= center.x;
385
+ scene.position.z -= center.z;
386
+ scene.position.y -= box.min.y;
387
+
388
+ const names = collectObjectNames(scene);
389
+ onObjectNamesDiscovered?.(asset.id, names);
390
+ onLoaded?.();
391
+ return () => {
392
+ scene.traverse((obj) => {
393
+ if (!(obj instanceof Mesh)) return;
394
+ obj.geometry?.dispose();
395
+ });
396
+ };
397
+ }, [asset.id, onLoaded, onObjectNamesDiscovered, scene]);
398
+
399
+ useEffect(() => {
400
+ const named = new Set(asset.objectNames ?? collectObjectNames(scene));
401
+ scene.traverse((obj) => {
402
+ if (!obj.name || !named.has(obj.name)) return;
403
+ obj.visible = isScene3dObjectVisible({
404
+ modelId: asset.id,
405
+ objectName: obj.name,
406
+ hiddenObjectKeys,
407
+ soloObjectKey,
408
+ });
409
+ });
410
+ }, [asset.id, asset.objectNames, hiddenObjectKeys, scene, soloObjectKey]);
411
+
412
+ useEffect(() => {
413
+ if (!highlightSelection) return;
414
+ const parsed = selectedObjectKey?.startsWith(`${asset.id}|||`)
415
+ ? selectedObjectKey.slice(asset.id.length + 3)
416
+ : null;
417
+ const target = parsed ? scene.getObjectByName(parsed) : null;
418
+ const originals: Array<{ mesh: Mesh; material: Mesh["material"] }> = [];
419
+ target?.traverse((obj) => {
420
+ if (!(obj instanceof Mesh) || !obj.material) return;
421
+ originals.push({ mesh: obj, material: obj.material });
422
+ const cloned = Array.isArray(obj.material)
423
+ ? obj.material.map((m) => m.clone())
424
+ : obj.material.clone();
425
+ const list = Array.isArray(cloned) ? cloned : [cloned];
426
+ for (const mat of list) {
427
+ if ("emissive" in mat) {
428
+ (mat as MeshStandardMaterial).emissive.set("#38bdf8");
429
+ (mat as MeshStandardMaterial).emissiveIntensity = 0.9;
430
+ }
431
+ }
432
+ obj.material = cloned;
433
+ });
434
+ return () => {
435
+ for (const item of originals) item.mesh.material = item.material;
436
+ };
437
+ }, [asset.id, highlightSelection, scene, selectedObjectKey]);
438
+
439
+ const clearHover = useCallback(() => {
440
+ const mesh = hoveredRef.current;
441
+ const color = hoveredColorRef.current;
442
+ if (mesh && color && mesh.material && "emissive" in mesh.material) {
443
+ (mesh.material as MeshStandardMaterial).emissive.copy(color);
444
+ }
445
+ hoveredRef.current = null;
446
+ hoveredColorRef.current = null;
447
+ onHoverName?.(null);
448
+ }, [onHoverName]);
449
+
450
+ const onPointerOver = useCallback(
451
+ (event: ThreeEvent<PointerEvent>) => {
452
+ if (!interactive || !highlightOnHover) return;
453
+ event.stopPropagation();
454
+ const mesh = event.object;
455
+ if (!(mesh instanceof Mesh) || !mesh.material || !("emissive" in mesh.material)) {
456
+ return;
457
+ }
458
+ clearHover();
459
+ const mat = mesh.material as MeshStandardMaterial;
460
+ hoveredRef.current = mesh;
461
+ hoveredColorRef.current = mat.emissive.clone();
462
+ mat.emissive.set("#3b82f6");
463
+ onHoverName?.(mesh.name || asset.label);
464
+ },
465
+ [asset.label, clearHover, highlightOnHover, interactive, onHoverName]
466
+ );
467
+
468
+ const onPointerOut = useCallback(() => {
469
+ if (!interactive) return;
470
+ clearHover();
471
+ }, [clearHover, interactive]);
472
+
473
+ const onClick = useCallback(
474
+ (event: ThreeEvent<MouseEvent>) => {
475
+ if (!interactive || !clickToFocus) return;
476
+ event.stopPropagation();
477
+ const box = new Box3().setFromObject(event.object);
478
+ onFocus?.(box.getCenter(new Vector3()));
479
+ },
480
+ [clickToFocus, interactive, onFocus]
481
+ );
482
+
483
+ const pos = asset.position ?? [0, 0, 0];
484
+ const rot = asset.rotation ?? [0, 0, 0];
485
+ const scale = asset.scale ?? [1, 1, 1];
486
+
487
+ return (
488
+ <group position={pos} rotation={rot} scale={scale}>
489
+ <primitive
490
+ object={scene}
491
+ onPointerOver={onPointerOver}
492
+ onPointerOut={onPointerOut}
493
+ onClick={onClick}
494
+ />
495
+ {animations.map((rule) => (
496
+ <AnimatedModelPart
497
+ key={rule.id}
498
+ root={scene}
499
+ objectName={rule.objectName}
500
+ rule={rule}
501
+ playing={playing}
502
+ />
503
+ ))}
504
+ {objectTags
505
+ .filter((tag) => tag.modelId === asset.id && tag.html.trim())
506
+ .map((tag) => (
507
+ <ObjectHtmlTag key={tag.id} root={scene} tag={tag} />
508
+ ))}
509
+ </group>
510
+ );
511
+ }
512
+
513
+ function CameraAnimationDriver({
514
+ config,
515
+ playing,
516
+ onCameraUpdate,
517
+ }: {
518
+ config: Scene3dConfig;
519
+ playing: boolean;
520
+ onCameraUpdate: (camera: Scene3dCameraState) => void;
521
+ }) {
522
+ const startedAtRef = useRef<number | null>(null);
523
+ const rule = config.cameraAnimations[config.cameraAnimations.length - 1];
524
+
525
+ useEffect(() => {
526
+ startedAtRef.current = null;
527
+ }, [rule?.id]);
528
+
529
+ useFrame(({ clock }) => {
530
+ if (!playing || !rule) return;
531
+ if (startedAtRef.current === null) startedAtRef.current = clock.elapsedTime;
532
+ const elapsed = clock.elapsedTime - startedAtRef.current;
533
+ const duration = Math.max(0.05, rule.durationSec);
534
+ let t = elapsed / duration;
535
+ if (rule.loop) {
536
+ t = rule.pingPong ? Math.abs((t % 2) - 1) : t % 1;
537
+ } else {
538
+ t = Math.min(1, t);
539
+ }
540
+ if (rule.mode === "orbit") {
541
+ const [tx, ty, tz] = rule.from.target;
542
+ const ox = rule.from.position[0] - tx;
543
+ const oy = rule.from.position[1] - ty;
544
+ const oz = rule.from.position[2] - tz;
545
+ const radius = Math.max(0.001, Math.hypot(ox, oz));
546
+ const baseTheta = Math.atan2(oz, ox);
547
+ const speedRad = MathUtils.degToRad(rule.orbitSpeedDegPerSec ?? 24);
548
+ const angle = rule.loop ? elapsed * speedRad : Math.min(elapsed, duration) * speedRad;
549
+ const theta = baseTheta + angle;
550
+ onCameraUpdate({
551
+ ...rule.from,
552
+ position: [tx + Math.cos(theta) * radius, ty + oy, tz + Math.sin(theta) * radius],
553
+ target: rule.from.target,
554
+ });
555
+ return;
556
+ }
557
+ onCameraUpdate(lerpCamera(rule.from, rule.to, easeInOutCubic(t)));
558
+ });
559
+
560
+ return null;
561
+ }
562
+
563
+ function InvalidateOnChange({ revision }: { revision: string }) {
564
+ const invalidate = useThree((state) => state.invalidate);
565
+ useEffect(() => {
566
+ invalidate();
567
+ }, [invalidate, revision]);
568
+ return null;
569
+ }
570
+
571
+ function Scene3dScene({
572
+ config,
573
+ orbitEnabled,
574
+ onCameraChange,
575
+ onAutoFitCamera,
576
+ onObjectNamesDiscovered,
577
+ previewMode,
578
+ onHoverName,
579
+ onControlsReady,
580
+ }: Scene3dCanvasProps) {
581
+ const [runtimeCamera, setRuntimeCamera] = useState(config.camera);
582
+ const [focusPoint, setFocusPoint] = useState<Vector3 | null>(null);
583
+ const [orbitInteracting, setOrbitInteracting] = useState(false);
584
+ const [loadedEpoch, setLoadedEpoch] = useState(0);
585
+ const contentRef = useRef<Object3D | null>(null);
586
+ const { size } = useThree();
587
+ const playbackActive = previewMode || !orbitEnabled;
588
+ const manualOrbitEnabled = previewMode
589
+ ? orbitEnabled
590
+ : orbitEnabled && !(config.cameraAnimations.length > 0 && playbackActive);
591
+ const cameraAnimating =
592
+ config.cameraAnimations.length > 0 &&
593
+ playbackActive &&
594
+ !(previewMode && manualOrbitEnabled && orbitInteracting);
595
+ const presentation = config.presentation;
596
+ const env = presentation.environmentPreset;
597
+ const defaultCamera = useMemo(() => createDefaultScene3dConfig().camera, []);
598
+ const purpose = presentation.purpose;
599
+
600
+ useEffect(() => {
601
+ setRuntimeCamera(config.camera);
602
+ setFocusPoint(null);
603
+ }, [config.camera]);
604
+
605
+ useEffect(() => {
606
+ if (previewMode) return;
607
+ const sameCamera =
608
+ config.camera.fov === defaultCamera.fov &&
609
+ config.camera.near === defaultCamera.near &&
610
+ config.camera.far === defaultCamera.far &&
611
+ config.camera.position.every((v, i) => Math.abs(v - defaultCamera.position[i]) < 0.0001) &&
612
+ config.camera.target.every((v, i) => Math.abs(v - defaultCamera.target[i]) < 0.0001);
613
+ if (!sameCamera) return;
614
+ if (cameraAnimating || orbitEnabled) return;
615
+ if (!contentRef.current) return;
616
+
617
+ const box = new Box3().setFromObject(contentRef.current);
618
+ if (box.isEmpty()) return;
619
+ const center = box.getCenter(new Vector3());
620
+ const size3 = box.getSize(new Vector3());
621
+ const aspect = Math.max(0.0001, size.width / Math.max(1, size.height));
622
+ const fovRad = MathUtils.degToRad(config.camera.fov);
623
+ const fitHeight = size3.y / (2 * Math.tan(fovRad / 2));
624
+ const fitWidth = size3.x / (2 * Math.tan(fovRad / 2)) / aspect;
625
+ const distance = Math.max(fitHeight, fitWidth, size3.z * 0.7) * 1.35;
626
+ const dir = new Vector3()
627
+ .fromArray(defaultCamera.position)
628
+ .sub(new Vector3().fromArray(defaultCamera.target))
629
+ .normalize();
630
+ const nextPos = center.clone().add(dir.multiplyScalar(distance));
631
+
632
+ const fittedCamera: Scene3dCameraState = {
633
+ ...config.camera,
634
+ position: [nextPos.x, nextPos.y, nextPos.z],
635
+ target: [center.x, center.y, center.z],
636
+ };
637
+ setRuntimeCamera(fittedCamera);
638
+ onAutoFitCamera?.(fittedCamera);
639
+ }, [cameraAnimating, config.camera, defaultCamera, loadedEpoch, onAutoFitCamera, orbitEnabled, previewMode, size.height, size.width]);
640
+
641
+ const interactive = !previewMode;
642
+
643
+ const sceneBody = (
644
+ <>
645
+ <InvalidateOnChange
646
+ revision={`${purpose}|${env}|${config.lighting.backgroundColor}|${config.lighting.ambientIntensity}|${config.lighting.directionalIntensity}|${String(config.enableShadows)}`}
647
+ />
648
+ <color attach="background" args={[config.lighting.backgroundColor]} />
649
+ <Suspense fallback={null}>
650
+ {isEnvPreset(env) ? <Environment preset={env} /> : null}
651
+ </Suspense>
652
+ <hemisphereLight intensity={0.22} groundColor="#1b1b1b" />
653
+ <ambientLight intensity={config.lighting.ambientIntensity} />
654
+ <directionalLight
655
+ position={[6, 10, 4]}
656
+ intensity={config.lighting.directionalIntensity}
657
+ castShadow={config.enableShadows}
658
+ />
659
+ {presentation.showGrid ? (
660
+ <Grid
661
+ args={[24, 24]}
662
+ cellSize={0.5}
663
+ cellThickness={0.6}
664
+ sectionSize={2}
665
+ sectionThickness={1.1}
666
+ fadeDistance={28}
667
+ fadeStrength={1.2}
668
+ infiniteGrid
669
+ />
670
+ ) : null}
671
+ {presentation.showContactShadows ? (
672
+ <ContactShadows
673
+ position={[0, 0, 0]}
674
+ opacity={0.42}
675
+ scale={16}
676
+ blur={1.4}
677
+ far={6}
678
+ />
679
+ ) : null}
680
+ <CameraRig
681
+ config={{ ...config, camera: runtimeCamera }}
682
+ orbitEnabled={manualOrbitEnabled}
683
+ previewMode={previewMode}
684
+ onCameraChange={previewMode ? undefined : onCameraChange}
685
+ cameraAnimating={cameraAnimating}
686
+ focusPoint={focusPoint}
687
+ onControlsReady={onControlsReady}
688
+ onOrbitInteractionChange={previewMode ? setOrbitInteracting : undefined}
689
+ />
690
+ {cameraAnimating ? (
691
+ <CameraAnimationDriver
692
+ config={config}
693
+ playing={playbackActive}
694
+ onCameraUpdate={setRuntimeCamera}
695
+ />
696
+ ) : null}
697
+ <group ref={contentRef}>
698
+ {config.models.map((asset) => (
699
+ <Suspense key={asset.id} fallback={null}>
700
+ <GltfModel
701
+ asset={asset}
702
+ onObjectNamesDiscovered={
703
+ previewMode ? undefined : onObjectNamesDiscovered
704
+ }
705
+ animations={config.modelAnimations.filter((a) => a.modelId === asset.id)}
706
+ playing={playbackActive}
707
+ interactive={interactive}
708
+ highlightOnHover={!previewMode && presentation.highlightOnHover}
709
+ clickToFocus={!previewMode && presentation.clickToFocus}
710
+ onFocus={!previewMode ? setFocusPoint : undefined}
711
+ onHoverName={previewMode ? undefined : onHoverName}
712
+ onLoaded={previewMode ? undefined : () => setLoadedEpoch((v) => v + 1)}
713
+ hiddenObjectKeys={config.hiddenObjectKeys}
714
+ soloObjectKey={config.soloObjectKey}
715
+ selectedObjectKey={config.selectedObjectKey}
716
+ objectTags={config.objectTags ?? []}
717
+ highlightSelection={!previewMode}
718
+ />
719
+ </Suspense>
720
+ ))}
721
+ </group>
722
+ <Scene3dEffects purpose={purpose} enabled />
723
+ </>
724
+ );
725
+
726
+ if (previewMode) return sceneBody;
727
+
728
+ return (
729
+ <Scene3dA11y description="3D scene">
730
+ <Scene3dPhysics purpose={purpose}>{sceneBody}</Scene3dPhysics>
731
+ </Scene3dA11y>
732
+ );
733
+ }
734
+
735
+ export function Scene3dCanvas({
736
+ config,
737
+ orbitEnabled,
738
+ previewMode,
739
+ onCameraChange,
740
+ onAutoFitCamera,
741
+ onObjectNamesDiscovered,
742
+ onHoverName,
743
+ onControlsReady,
744
+ onCanvasDomReady,
745
+ }: Scene3dCanvasProps) {
746
+ const merged = useMemo(() => mergeScene3dConfig(config), [config]);
747
+ const playbackActive = previewMode || !orbitEnabled;
748
+ const hasAnimation =
749
+ playbackActive &&
750
+ (merged.modelAnimations.length > 0 ||
751
+ merged.cameraAnimations.length > 0 ||
752
+ merged.orbit.autoRotate);
753
+ const live = previewMode
754
+ ? orbitEnabled || hasAnimation
755
+ : hasAnimation || orbitEnabled;
756
+
757
+ return (
758
+ <>
759
+ {!previewMode ? <Scene3dA11yAnnouncer /> : null}
760
+ <Canvas
761
+ onCreated={({ gl }) => {
762
+ onCanvasDomReady?.(gl.domElement);
763
+ }}
764
+ shadows
765
+ dpr={[1, 2]}
766
+ frameloop={live ? "always" : "demand"}
767
+ resize={{ scroll: false, debounce: 0, offsetSize: true }}
768
+ camera={{
769
+ position: merged.camera.position,
770
+ fov: merged.camera.fov,
771
+ near: merged.camera.near ?? 0.1,
772
+ far: merged.camera.far ?? 200,
773
+ }}
774
+ gl={{
775
+ antialias: true,
776
+ alpha: false,
777
+ powerPreference: "high-performance",
778
+ stencil: false,
779
+ }}
780
+ style={{ width: "100%", height: "100%", display: "block", touchAction: "none" }}
781
+ >
782
+ <Scene3dScene
783
+ config={merged}
784
+ orbitEnabled={orbitEnabled}
785
+ previewMode={previewMode}
786
+ onCameraChange={onCameraChange}
787
+ onAutoFitCamera={onAutoFitCamera}
788
+ onObjectNamesDiscovered={onObjectNamesDiscovered}
789
+ onHoverName={onHoverName}
790
+ onControlsReady={onControlsReady}
791
+ />
792
+ </Canvas>
793
+ </>
794
+ );
795
+ }
796
+
797
+ export type Scene3dNodeContentProps = {
798
+ config?: Scene3dConfig | null;
799
+ previewMode?: boolean;
800
+ selected?: boolean;
801
+ updateConfig?: (patch: Partial<Scene3dConfig>) => void;
802
+ onObjectNamesDiscovered?: (modelId: string, names: string[]) => void;
803
+ };
804
+
805
+ export function Scene3dNodeContent({
806
+ config,
807
+ previewMode = false,
808
+ selected = false,
809
+ updateConfig,
810
+ onObjectNamesDiscovered,
811
+ }: Scene3dNodeContentProps) {
812
+ const { t } = useI18nOptional();
813
+ const merged = useMemo(() => mergeScene3dConfig(config), [config]);
814
+ const [cKeyHeld, setCKeyHeld] = useState(false);
815
+ const rootRef = useRef<HTMLDivElement | null>(null);
816
+ const controlsRef = useRef<OrbitControlsImpl | null>(null);
817
+ const canvasDomRef = useRef<HTMLCanvasElement | null>(null);
818
+ const commitCurrentCamera = useCallback(() => {
819
+ const controls = controlsRef.current as
820
+ | (OrbitControlsImpl & { object?: PerspectiveCamera; target: Vector3 })
821
+ | null;
822
+ const camera = controls?.object;
823
+ if (!controls || !camera) return;
824
+ updateConfig?.({
825
+ camera: {
826
+ position: [camera.position.x, camera.position.y, camera.position.z],
827
+ target: [controls.target.x, controls.target.y, controls.target.z],
828
+ fov: camera.fov,
829
+ near: camera.near,
830
+ far: camera.far,
831
+ },
832
+ });
833
+ }, [updateConfig]);
834
+
835
+ useEffect(() => {
836
+ if (previewMode) return;
837
+ const onKeyDown = (e: KeyboardEvent) => {
838
+ if (e.key === "c" || e.key === "C") setCKeyHeld(true);
839
+ };
840
+ const onKeyUp = (e: KeyboardEvent) => {
841
+ if (e.key === "c" || e.key === "C") {
842
+ commitCurrentCamera();
843
+ setCKeyHeld(false);
844
+ }
845
+ };
846
+ window.addEventListener("keydown", onKeyDown);
847
+ window.addEventListener("keyup", onKeyUp);
848
+ return () => {
849
+ window.removeEventListener("keydown", onKeyDown);
850
+ window.removeEventListener("keyup", onKeyUp);
851
+ };
852
+ }, [commitCurrentCamera, previewMode]);
853
+
854
+ const cameraEditMode = !previewMode && cKeyHeld;
855
+ const orbitEnabled = previewMode
856
+ ? merged.orbit.enableMouseControl
857
+ : cameraEditMode;
858
+
859
+ const handleCameraChange = useCallback(
860
+ (camera: Scene3dCameraState) => {
861
+ if (!cameraEditMode) return;
862
+ updateConfig?.({ camera });
863
+ },
864
+ [cameraEditMode, updateConfig]
865
+ );
866
+
867
+ const handleDiscover = useCallback(
868
+ (modelId: string, names: string[]) => {
869
+ if (previewMode) return;
870
+ onObjectNamesDiscovered?.(modelId, names);
871
+ const current = merged.models.find((m) => m.id === modelId);
872
+ if (
873
+ current?.objectNames &&
874
+ current.objectNames.length === names.length &&
875
+ current.objectNames.every((n, i) => n === names[i])
876
+ ) {
877
+ return;
878
+ }
879
+ const models = merged.models.map((m) =>
880
+ m.id === modelId ? { ...m, objectNames: names } : m
881
+ );
882
+ updateConfig?.({ models });
883
+ },
884
+ [merged.models, onObjectNamesDiscovered, previewMode, updateConfig]
885
+ );
886
+
887
+ useEffect(() => {
888
+ if (previewMode || !cameraEditMode) return;
889
+
890
+ let mode: "rotate" | "pan" | null = null;
891
+ let activePointerId: number | null = null;
892
+ let lastX = 0;
893
+ let lastY = 0;
894
+
895
+ type ExtControls = OrbitControlsImpl & {
896
+ object: PerspectiveCamera;
897
+ target: Vector3;
898
+ getAzimuthalAngle: () => number;
899
+ getPolarAngle: () => number;
900
+ setAzimuthalAngle: (angle: number) => void;
901
+ setPolarAngle: (angle: number) => void;
902
+ dollyIn: (scale: number) => void;
903
+ dollyOut: (scale: number) => void;
904
+ };
905
+ const getControls = () => controlsRef.current as ExtControls | null;
906
+ const isInsideScene3d = (clientX: number, clientY: number) => {
907
+ const root = rootRef.current;
908
+ if (!root) return false;
909
+ const rect = root.getBoundingClientRect();
910
+ return (
911
+ clientX >= rect.left &&
912
+ clientX <= rect.right &&
913
+ clientY >= rect.top &&
914
+ clientY <= rect.bottom
915
+ );
916
+ };
917
+
918
+ const kill = (e: Event) => {
919
+ e.preventDefault();
920
+ e.stopPropagation();
921
+ e.stopImmediatePropagation();
922
+ };
923
+ const panCamera = (controls: ExtControls, dx: number, dy: number) => {
924
+ const element = canvasDomRef.current;
925
+ if (!element) return;
926
+ const camera = controls.object;
927
+ const offset = new Vector3().subVectors(camera.position, controls.target);
928
+ const targetDistance =
929
+ offset.length() * Math.tan(((camera.fov || 45) / 2) * (Math.PI / 180));
930
+ const panX = (2 * dx * targetDistance) / Math.max(1, element.clientHeight);
931
+ const panY = (2 * dy * targetDistance) / Math.max(1, element.clientHeight);
932
+ const xAxis = new Vector3().setFromMatrixColumn(camera.matrix, 0).multiplyScalar(-panX);
933
+ const yAxis = new Vector3()
934
+ .setFromMatrixColumn(camera.matrix, 1)
935
+ .multiplyScalar(panY);
936
+ const delta = xAxis.add(yAxis);
937
+ controls.target.add(delta);
938
+ camera.position.add(delta);
939
+ };
940
+
941
+ const onPointerDown = (e: PointerEvent) => {
942
+ if (e.pointerType !== "mouse") return;
943
+ if (!isInsideScene3d(e.clientX, e.clientY)) return;
944
+ const controls = getControls();
945
+ if (!controls) return;
946
+ if (e.button === 0) mode = "rotate";
947
+ else if (e.button === 2) mode = "pan";
948
+ else return;
949
+ activePointerId = e.pointerId;
950
+ lastX = e.clientX;
951
+ lastY = e.clientY;
952
+ kill(e);
953
+ };
954
+
955
+ const onPointerMove = (e: PointerEvent) => {
956
+ if (e.pointerType !== "mouse") return;
957
+ if (activePointerId !== null && e.pointerId !== activePointerId) return;
958
+ if (!mode) {
959
+ if (!isInsideScene3d(e.clientX, e.clientY)) return;
960
+ if (e.buttons & 1) mode = "rotate";
961
+ else if (e.buttons & 2) mode = "pan";
962
+ else return;
963
+ activePointerId = e.pointerId;
964
+ lastX = e.clientX;
965
+ lastY = e.clientY;
966
+ kill(e);
967
+ return;
968
+ }
969
+ const controls = getControls();
970
+ if (!controls) return;
971
+ const dx = e.clientX - lastX;
972
+ const dy = e.clientY - lastY;
973
+ lastX = e.clientX;
974
+ lastY = e.clientY;
975
+ if (mode === "rotate") {
976
+ controls.setAzimuthalAngle(controls.getAzimuthalAngle() - dx * 0.01);
977
+ controls.setPolarAngle(
978
+ MathUtils.clamp(
979
+ controls.getPolarAngle() - dy * 0.01,
980
+ 0.01,
981
+ Math.PI - 0.01
982
+ )
983
+ );
984
+ } else {
985
+ panCamera(controls, dx, dy);
986
+ }
987
+ controls.update();
988
+ kill(e);
989
+ };
990
+
991
+ const onPointerUp = (e: PointerEvent) => {
992
+ if (e.pointerType !== "mouse") return;
993
+ if (activePointerId !== null && e.pointerId !== activePointerId) return;
994
+ if (!mode) return;
995
+ mode = null;
996
+ activePointerId = null;
997
+ kill(e);
998
+ };
999
+
1000
+ const onWheel = (e: WheelEvent) => {
1001
+ if (!isInsideScene3d(e.clientX, e.clientY)) return;
1002
+ const controls = getControls();
1003
+ if (!controls) return;
1004
+ if (e.deltaY > 0) controls.dollyOut(1.08);
1005
+ else controls.dollyIn(1.08);
1006
+ controls.update();
1007
+ kill(e);
1008
+ };
1009
+
1010
+ const onContextMenu = (e: MouseEvent) => {
1011
+ if (!isInsideScene3d(e.clientX, e.clientY)) return;
1012
+ kill(e);
1013
+ };
1014
+
1015
+ window.addEventListener("pointerdown", onPointerDown, true);
1016
+ window.addEventListener("pointermove", onPointerMove, true);
1017
+ window.addEventListener("pointerup", onPointerUp, true);
1018
+ window.addEventListener("wheel", onWheel, { passive: false, capture: true });
1019
+ window.addEventListener("contextmenu", onContextMenu, true);
1020
+
1021
+ return () => {
1022
+ window.removeEventListener("pointerdown", onPointerDown, true);
1023
+ window.removeEventListener("pointermove", onPointerMove, true);
1024
+ window.removeEventListener("pointerup", onPointerUp, true);
1025
+ window.removeEventListener("wheel", onWheel, true);
1026
+ window.removeEventListener("contextmenu", onContextMenu, true);
1027
+ mode = null;
1028
+ activePointerId = null;
1029
+ };
1030
+ }, [cameraEditMode, previewMode]);
1031
+
1032
+ if (merged.models.length === 0) {
1033
+ return (
1034
+ <div className="flex h-full w-full items-center justify-center rounded border border-dashed border-border/70 bg-muted/15 px-2 text-center text-[11px] text-muted-foreground">
1035
+ {t("panel.config.scene3dPlaceholder")}
1036
+ </div>
1037
+ );
1038
+ }
1039
+
1040
+ return (
1041
+ <div ref={rootRef} className="relative h-full w-full overflow-hidden rounded">
1042
+ {cameraEditMode ? (
1043
+ <div className="pointer-events-none absolute left-1 top-1 z-10 rounded bg-black/65 px-1.5 py-0.5 text-[10px] text-white">
1044
+ {t("panel.config.scene3dCameraEditHint")}
1045
+ </div>
1046
+ ) : null}
1047
+ {!previewMode && selected && !cameraEditMode ? (
1048
+ <div className="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">
1049
+ {t("panel.config.scene3dHoldCHint")}
1050
+ </div>
1051
+ ) : null}
1052
+ <div
1053
+ className="h-full w-full"
1054
+ data-scene3d-orbit-active={
1055
+ !previewMode && orbitEnabled ? "true" : undefined
1056
+ }
1057
+ onPointerDown={(e) => {
1058
+ if (!previewMode && orbitEnabled) e.stopPropagation();
1059
+ }}
1060
+ onPointerUp={(e) => {
1061
+ if (!previewMode && orbitEnabled) e.stopPropagation();
1062
+ }}
1063
+ onPointerMove={(e) => {
1064
+ if (!previewMode && orbitEnabled) e.stopPropagation();
1065
+ }}
1066
+ onMouseDown={(e) => {
1067
+ if (!previewMode && orbitEnabled) e.stopPropagation();
1068
+ }}
1069
+ onClick={(e) => {
1070
+ if (!previewMode && orbitEnabled) e.stopPropagation();
1071
+ }}
1072
+ onDoubleClick={(e) => {
1073
+ if (!previewMode && orbitEnabled) e.stopPropagation();
1074
+ }}
1075
+ onWheel={(e) => {
1076
+ if (!previewMode && orbitEnabled) e.stopPropagation();
1077
+ }}
1078
+ onContextMenu={(e) => {
1079
+ if (!previewMode && orbitEnabled) {
1080
+ e.preventDefault();
1081
+ e.stopPropagation();
1082
+ }
1083
+ }}
1084
+ >
1085
+ <Suspense
1086
+ fallback={
1087
+ <div className="flex h-full w-full items-center justify-center text-[11px] text-muted-foreground">
1088
+ {t("common.loading")}
1089
+ </div>
1090
+ }
1091
+ >
1092
+ <Scene3dCanvas
1093
+ config={merged}
1094
+ orbitEnabled={orbitEnabled}
1095
+ previewMode={previewMode}
1096
+ onCameraChange={handleCameraChange}
1097
+ onAutoFitCamera={
1098
+ previewMode
1099
+ ? undefined
1100
+ : (camera) => {
1101
+ updateConfig?.({ camera });
1102
+ }
1103
+ }
1104
+ onObjectNamesDiscovered={handleDiscover}
1105
+ onControlsReady={(controls) => {
1106
+ controlsRef.current = controls;
1107
+ }}
1108
+ onCanvasDomReady={(canvas) => {
1109
+ canvasDomRef.current = canvas;
1110
+ }}
1111
+ />
1112
+ </Suspense>
1113
+ </div>
1114
+ </div>
1115
+ );
1116
+ }