cozyclay 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -0
- package/README.md +17 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/bin/agent/agent-routes.mjs +219 -38
- package/bin/agent/agent-runner.mjs +98 -4
- package/bin/agent/codex-client.mjs +2 -1
- package/bin/agent/fal-contract.mjs +0 -17
- package/bin/agent/h3-preservation.mjs +6 -8
- package/bin/agent/provider-keys.mjs +1 -1
- package/bin/agent/providers.mjs +124 -3
- package/bin/agent/script-tool.mjs +74 -0
- package/bin/agent/session-store.mjs +5 -5
- package/bin/agent/studio-prompt.mjs +2 -1
- package/bin/agent/studio-rebase.mjs +314 -0
- package/bin/agent/studio-tools.mjs +21 -14
- package/bin/agent/supervisor.mjs +311 -0
- package/bin/agent/video-adapters.mjs +0 -7
- package/bin/codex-auth.mjs +6 -5
- package/bin/cozyclay.mjs +12 -3
- package/bin/live/cli.mjs +2 -3
- package/bin/live/client.mjs +1 -1
- package/bin/live/studio.mjs +5 -2
- package/bin/mcp-runtime.mjs +12 -2
- package/bin/telemetry-state.mjs +3 -3
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-B41FmPYI.js +1 -0
- package/dist/assets/app-Do-Og5Pu.css +1 -0
- package/dist/assets/app-DwP-tl2x.js +4920 -0
- package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
- package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
- package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
- package/dist/assets/scene-asset-sync-BCWDJB1d.js +19 -0
- package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
- package/dist/assets/{ticket-BcmsnHn5.js → ticket-jqMhqL88.js} +1 -1
- package/dist/assets/workflow-Br-K7Srs.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +328 -222
- package/dist/sitemap.xml +5 -5
- package/dist/workflow/index.html +4 -4
- package/mcp/LIVE-PROTOCOL.md +23 -2
- package/mcp/README.md +37 -2
- package/mcp/ardy-prompts.mjs +1 -1
- package/mcp/image-file.mjs +132 -0
- package/mcp/live-hub.mjs +2 -2
- package/mcp/package.json +1 -0
- package/mcp/runtime/package-lock.json +7 -0
- package/mcp/runtime/package.json +1 -0
- package/mcp/server.mjs +0 -6
- package/mcp/tool-handlers.mjs +289 -87
- package/package.json +11 -2
- package/src/App.jsx +801 -259
- package/src/agent-image-client.js +94 -0
- package/src/analytics.js +47 -3
- package/src/app-stage.jsx +129 -2745
- package/src/ardy/auto-physics.js +6 -16
- package/src/ardy/collision-blockers.js +3 -7
- package/src/ardy/fix-collisions.js +5 -35
- package/src/ardy/ik-drag.js +86 -5
- package/src/ardy/ik-key-json.js +3 -1
- package/src/ardy/ik.js +15 -13
- package/src/ardy/motion-calibration.js +1 -1
- package/src/ardy/npz.js +0 -8
- package/src/ardy/physics-panel.jsx +3 -2
- package/src/ardy/physics-review.js +3 -2
- package/src/ardy/physics-support.js +1 -1
- package/src/ardy/physics-surface.js +1 -1
- package/src/ardy/platform-fit-panel.jsx +2 -1
- package/src/ardy/playback.js +1 -5
- package/src/ardy/range-pin.js +2 -2
- package/src/ardy/root-drop.js +0 -1
- package/src/ardy/timeline.css +196 -3
- package/src/ardy/timeline.jsx +117 -29
- package/src/ardy/to-cskel27.js +0 -2
- package/src/ardy/waypoints.js +1 -4
- package/src/ardy-bridge-contract.js +281 -0
- package/src/asset-pane.jsx +19 -9
- package/src/auto-color.js +1 -1
- package/src/burn-in.js +1 -1
- package/src/camera-block.js +1 -1
- package/src/camera-follow.js +75 -32
- package/src/camera-move.js +0 -6
- package/src/camera-tutorial.jsx +43 -24
- package/src/character-kind.js +79 -0
- package/src/command-bus.js +76 -17
- package/src/commands/cast.js +31 -7
- package/src/commands/elements/object.js +1 -1
- package/src/commands/elements/shot.js +1 -1
- package/src/commands/elements.js +1 -1
- package/src/commands/export.js +3 -3
- package/src/commands/motion.js +2 -2
- package/src/commands/objects.js +105 -10
- package/src/commands/project.js +16 -3
- package/src/commands/sculpt-images.js +53 -0
- package/src/commands/shot.js +88 -10
- package/src/commands/view.js +10 -4
- package/src/coplanar-depth.js +216 -0
- package/src/cuts.js +7 -6
- package/src/domains/cast.js +60 -43
- package/src/domains/motion/motion-intent-codec.js +50 -0
- package/src/domains/motion/motion-range-pin-rules.js +23 -0
- package/src/domains/motion/motion-video-capture.js +664 -0
- package/src/domains/motion.js +253 -742
- package/src/domains/objects.js +106 -5
- package/src/domains/scenes.js +89 -32
- package/src/domains/shots.js +33 -7
- package/src/error-boundary.jsx +1 -1
- package/src/execution-telemetry.js +5 -5
- package/src/facing-marks.js +14 -1
- package/src/first-success-guide.jsx +35 -4
- package/src/gizmo-claim.js +3 -1
- package/src/grid-floor.jsx +1 -4
- package/src/hierarchy-model.js +48 -35
- package/src/hierarchy-panel.css +1 -0
- package/src/hierarchy-panel.jsx +54 -102
- package/src/history.js +0 -2
- package/src/i2v-motion-client.js +1 -4
- package/src/ime.js +6 -0
- package/src/keyframe-pack.js +15 -6
- package/src/line-edit.js +4 -4
- package/src/live-control.js +6 -6
- package/src/matte-editor.js +2 -8
- package/src/matte.js +0 -19
- package/src/mesh-graph-clone.js +16 -2
- package/src/mesh-sniff.js +0 -1
- package/src/monotone-cubic.js +40 -0
- package/src/motion/generation.js +1 -1
- package/src/motion-store.js +4 -4
- package/src/motion-trail.js +5 -11
- package/src/multimodel-ingest.js +1 -3
- package/src/object-catalog.jsx +7 -5
- package/src/object-gizmo.jsx +45 -31
- package/src/object-path.js +310 -67
- package/src/object-travel.js +224 -0
- package/src/otio.js +8 -3
- package/src/panels/CharacterTransformPanel.jsx +24 -6
- package/src/panels/ObjectTransformPanel.jsx +4 -3
- package/src/panels/PoseImagePanel.jsx +205 -0
- package/src/panels/PromptBlocksPanel.jsx +3 -1
- package/src/panels/ProxyFigurePanel.jsx +56 -0
- package/src/panels/ReferenceImageField.jsx +1 -1
- package/src/panels/RigControlPanel.jsx +24 -3
- package/src/panels/RigPanel.jsx +2 -1
- package/src/panels/SculptPanel.jsx +405 -0
- package/src/panels/SubjectBox.jsx +1 -1
- package/src/panels/SubjectsPanel.jsx +0 -2
- package/src/panels/VideoCapturePanel.jsx +2 -1
- package/src/panels/pose-image.css +42 -0
- package/src/panels/pose.css +3 -0
- package/src/panels/sculpt.css +162 -0
- package/src/plan-diagram.js +184 -0
- package/src/planview.jsx +127 -80
- package/src/playground.js +3 -5
- package/src/pose-fade.js +77 -0
- package/src/pose-gizmo.css +23 -0
- package/src/pose-gizmo.js +96 -0
- package/src/posestudio.jsx +134 -83
- package/src/project-browser.css +53 -0
- package/src/project-browser.jsx +52 -3
- package/src/project.js +55 -24
- package/src/props.jsx +177 -36
- package/src/proxy-figure.jsx +69 -0
- package/src/range-pin-panel.jsx +0 -1
- package/src/receipt-findings.js +20 -0
- package/src/render-passes.js +0 -1
- package/src/resource-status.jsx +1 -1
- package/src/root-path.js +48 -0
- package/src/route-owner.js +52 -0
- package/src/scene-asset-cache.js +1 -2
- package/src/scene-assets.js +12 -4
- package/src/scene-fbx.js +1 -1
- package/src/scene-mesh-cache.js +0 -1
- package/src/scene-objects.js +319 -19
- package/src/scenes.js +62 -17
- package/src/sculpt-blueprint.js +65 -0
- package/src/sculpt-compare.js +384 -0
- package/src/sculpt-geometry.js +141 -0
- package/src/sculpt-mode.js +168 -0
- package/src/sculpt-recipe.js +385 -0
- package/src/sculpt-run.js +351 -0
- package/src/sculpt-stage.jsx +221 -0
- package/src/settings-menu.jsx +1 -1
- package/src/shell/BoardSlot.jsx +341 -0
- package/src/shell/BottomDock.jsx +58 -14
- package/src/shell/DetailsSlot.jsx +26 -6
- package/src/shell/MenuBar.jsx +78 -6
- package/src/shell/OutlinerSlot.jsx +3 -1
- package/src/shell/TopBar.jsx +17 -3
- package/src/shell/ViewportToolbar.jsx +18 -3
- package/src/shell/dock.css +239 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/log-store.js +4 -4
- package/src/shell/mode.css +20 -0
- package/src/shell/studio-shell-context.js +4 -1
- package/src/shot-authoring.js +69 -6
- package/src/stable-items.js +1 -1
- package/src/stage-attach-frames.js +169 -0
- package/src/stage-canvas-rigs.jsx +402 -0
- package/src/stage-capture.jsx +216 -0
- package/src/stage-characters.jsx +302 -0
- package/src/stage-editor-gizmos.jsx +1229 -0
- package/src/stage-labels-ko.js +204 -0
- package/src/stage-startup-defaults.js +182 -0
- package/src/starter-scenes.js +1 -1
- package/src/storyboard.js +24 -5
- package/src/studio-actions.js +42 -9
- package/src/studio-agent-commands.js +186 -23
- package/src/studio-agent-context.js +11 -9
- package/src/studio-agent-motion.js +2 -1
- package/src/studio-agent-protocol.js +56 -23
- package/src/studio-app-binding.js +52 -23
- package/src/studio-contact-sheet.js +3 -3
- package/src/studio-elements.js +11 -2
- package/src/studio-geometry-facts.js +114 -0
- package/src/styles.css +111 -10
- package/src/theme.js +1 -1
- package/src/timeline-fps.js +13 -0
- package/src/ui-scrub.js +4 -4
- package/src/ui.jsx +6 -4
- package/src/use-asset-shelf.js +60 -0
- package/src/use-camera-tutorial.js +50 -0
- package/src/use-embed-bridge.js +12 -0
- package/src/use-first-success-prompt.js +7 -0
- package/src/use-i2v-capture-modal.js +15 -0
- package/src/use-sculpt-mode.js +95 -0
- package/src/workflow/AgentPanel.jsx +110 -20
- package/src/workflow/WorkflowBuilder.jsx +1 -1
- package/src/workflow/agent-client.js +113 -45
- package/src/workflow/agent-panel.css +35 -0
- package/src/workflow/attachment-image.js +1 -1
- package/src/workflow/canvas-commands.js +3 -3
- package/src/workflow/cozy-scene-node.js +3 -3
- package/src/workflow/local-workflow.js +2 -2
- package/src/workflow/motion-input.js +3 -3
- package/src/workflow/node-schema.js +1 -1
- package/src/workflow/scene-asset-sync.js +2 -9
- package/src/workflow/shot-prompt-node.js +1 -1
- package/src/workflow/video-contract.js +1 -1
- package/src/workflow/workflow-resources.js +0 -8
- package/src/workflow-send.js +87 -0
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/check-eslint.mjs +57 -0
- package/tools/check-knip.mjs +60 -0
- package/tools/check-loc.mjs +54 -0
- package/tools/demo-worker/api-client.mjs +7 -13
- package/tools/demo-worker/generate.mjs +2 -4
- package/tools/eslint-baseline.json +23 -0
- package/tools/kimodo/effector-constraints.mjs +4 -4
- package/tools/kimodo/generate.mjs +1 -1
- package/tools/knip-baseline.json +331 -0
- package/tools/loc-baseline.json +126 -0
- package/tools/process-supervisor.mjs +1 -1
- package/tools/projflow/replay.mjs +4 -4
- package/tools/projflow/runner.mjs +3 -3
- package/tools/qa/studio-control-count.mjs +2 -0
- package/tools/run-tests.mjs +96 -7
- package/dist/assets/analytics-MVZOEW8O.js +0 -1
- package/dist/assets/app-C1sdVbg4.css +0 -1
- package/dist/assets/app-CA55tcI3.js +0 -4876
- package/dist/assets/shot-prompt-Yrt99wZx.js +0 -17
- package/dist/assets/workflow-dNPGY9xz.js +0 -179
|
@@ -0,0 +1,1229 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
3
|
+
import { Line, Text } from "@react-three/drei";
|
|
4
|
+
import * as THREE from "three";
|
|
5
|
+
import { GIZMO_LAYER } from "./dualview.jsx";
|
|
6
|
+
import { craneHeightAt, prepareRailBend, railPoint } from "./camera-follow.js";
|
|
7
|
+
import { insertPathMark, MARK_GAP, MAX_PATH_LEAN, MAX_PATH_MARKS, MAX_PATH_POINTS, nearestPathFraction, pathCurve, pathMarkFractions, pathMarks, pathPointAtFraction } from "./object-path.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The key light drawn as a grabbable sun in the editor view: a warm core with
|
|
11
|
+
* rays, on GIZMO_LAYER so preview, capture and PlayView never see it. A line
|
|
12
|
+
* drops to the floor so its height reads against the stage, like the crane's.
|
|
13
|
+
* The sun's own body is the drag surface — a grab moves it freely on the
|
|
14
|
+
* camera-facing plane (the cursor and the sun stay glued), and the gizmo's
|
|
15
|
+
* arrows (via ObjectGizmo) stay available for single-axis precision. Its
|
|
16
|
+
* colour follows the dial, and while selected a ray points at the stage so
|
|
17
|
+
* the light's direction reads at a glance.
|
|
18
|
+
*/
|
|
19
|
+
export function KeyLightPuck({ keyLight, selected, visible, paneRef, camRef, onSelect, onChange, onDragEnd }) {
|
|
20
|
+
const { gl } = useThree();
|
|
21
|
+
const groupRef = useRef(null);
|
|
22
|
+
const dragRef = useRef(null);
|
|
23
|
+
const stateRef = useRef(null);
|
|
24
|
+
const [dragKind, setDragKind] = useState(null);
|
|
25
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
26
|
+
stateRef.current = { keyLight, visible, onSelect, onChange, onDragEnd };
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
groupRef.current?.traverse((node) => node.layers?.set(GIZMO_LAYER));
|
|
29
|
+
invalidate();
|
|
30
|
+
}, [visible, selected, keyLight, dragKind, invalidate]);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!visible) return undefined;
|
|
33
|
+
const raycaster = new THREE.Raycaster();
|
|
34
|
+
raycaster.layers.set(GIZMO_LAYER);
|
|
35
|
+
const rayFrom = (event) => {
|
|
36
|
+
const pane = paneRef.current;
|
|
37
|
+
const camera = camRef.current;
|
|
38
|
+
if (!pane || !camera) return null;
|
|
39
|
+
const bounds = pane.getBoundingClientRect();
|
|
40
|
+
if (bounds.width < 2 || bounds.height < 2) return null;
|
|
41
|
+
const ndc = new THREE.Vector2(
|
|
42
|
+
((event.clientX - bounds.left) / bounds.width) * 2 - 1,
|
|
43
|
+
-((event.clientY - bounds.top) / bounds.height) * 2 + 1,
|
|
44
|
+
);
|
|
45
|
+
if (Math.abs(ndc.x) > 1 || Math.abs(ndc.y) > 1) return null;
|
|
46
|
+
camera.updateMatrixWorld();
|
|
47
|
+
raycaster.setFromCamera(ndc, camera);
|
|
48
|
+
return raycaster;
|
|
49
|
+
};
|
|
50
|
+
const onDown = (event) => {
|
|
51
|
+
const s = stateRef.current;
|
|
52
|
+
if (event.button !== 0 || event.altKey) return;
|
|
53
|
+
if (!groupRef.current || !rayFrom(event)) return;
|
|
54
|
+
const hit = raycaster.intersectObjects(groupRef.current.children, true)
|
|
55
|
+
.find((entry) => {
|
|
56
|
+
for (let node = entry.object; node; node = node.parent) if (node.userData?.keyLightPick) return true;
|
|
57
|
+
return false;
|
|
58
|
+
});
|
|
59
|
+
if (!hit) return;
|
|
60
|
+
event.stopImmediatePropagation();
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
s.onSelect?.();
|
|
63
|
+
const start = { ...s.keyLight };
|
|
64
|
+
const origin = new THREE.Vector3(start.x, start.y, start.z);
|
|
65
|
+
// free move on the camera-facing plane through the sun: the cursor
|
|
66
|
+
// and the sun stay glued from any viewing angle — no degenerate
|
|
67
|
+
// floor-plane geometry when the sun is viewed from below
|
|
68
|
+
const facing = new THREE.Vector3();
|
|
69
|
+
camRef.current.getWorldDirection(facing);
|
|
70
|
+
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(facing, origin);
|
|
71
|
+
const hitStart = new THREE.Vector3();
|
|
72
|
+
if (!raycaster.ray.intersectPlane(plane, hitStart)) return;
|
|
73
|
+
dragRef.current = { start, plane, hitStart, moved: false };
|
|
74
|
+
setDragKind("move");
|
|
75
|
+
invalidate();
|
|
76
|
+
};
|
|
77
|
+
const onMove = (event) => {
|
|
78
|
+
const drag = dragRef.current;
|
|
79
|
+
if (!drag || !rayFrom(event)) return;
|
|
80
|
+
const world = new THREE.Vector3();
|
|
81
|
+
if (!raycaster.ray.intersectPlane(drag.plane, world)) return;
|
|
82
|
+
drag.moved = true;
|
|
83
|
+
stateRef.current.onChange?.({
|
|
84
|
+
x: drag.start.x + (world.x - drag.hitStart.x),
|
|
85
|
+
y: drag.start.y + (world.y - drag.hitStart.y),
|
|
86
|
+
z: drag.start.z + (world.z - drag.hitStart.z),
|
|
87
|
+
}, { dragging: true });
|
|
88
|
+
invalidate();
|
|
89
|
+
};
|
|
90
|
+
const onUp = () => {
|
|
91
|
+
const drag = dragRef.current;
|
|
92
|
+
if (!drag) return;
|
|
93
|
+
dragRef.current = null;
|
|
94
|
+
setDragKind(null);
|
|
95
|
+
if (drag.moved) stateRef.current.onDragEnd?.();
|
|
96
|
+
invalidate();
|
|
97
|
+
};
|
|
98
|
+
const el = gl.domElement;
|
|
99
|
+
el.addEventListener("pointerdown", onDown);
|
|
100
|
+
window.addEventListener("pointermove", onMove, true);
|
|
101
|
+
window.addEventListener("pointerup", onUp, true);
|
|
102
|
+
return () => {
|
|
103
|
+
el.removeEventListener("pointerdown", onDown);
|
|
104
|
+
window.removeEventListener("pointermove", onMove, true);
|
|
105
|
+
window.removeEventListener("pointerup", onUp, true);
|
|
106
|
+
};
|
|
107
|
+
// paneRef/camRef are stable refs; handlers read live state through stateRef
|
|
108
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
109
|
+
}, [visible, gl, invalidate]);
|
|
110
|
+
const drop = useMemo(() => new Float32Array([0, 0, 0, 0, -keyLight.y, 0]), [keyLight.y]);
|
|
111
|
+
// the sun dims with its own dial, so the stage's mood reads off the puck
|
|
112
|
+
const glow = Math.max(0.18, Math.min(1, keyLight.intensity / 1.12));
|
|
113
|
+
const sunColor = useMemo(
|
|
114
|
+
() => new THREE.Color("#5c5040").lerp(new THREE.Color(selected ? "#ffcf5e" : "#f2b544"), glow).getStyle(),
|
|
115
|
+
[selected, glow],
|
|
116
|
+
);
|
|
117
|
+
if (!visible) return null;
|
|
118
|
+
return (
|
|
119
|
+
<group ref={groupRef} position={[keyLight.x, keyLight.y, keyLight.z]}>
|
|
120
|
+
<mesh userData={{ keyLightPick: true }}>
|
|
121
|
+
<sphereGeometry args={[0.16, 20, 16]} />
|
|
122
|
+
<meshBasicMaterial color={sunColor} toneMapped={false} />
|
|
123
|
+
</mesh>
|
|
124
|
+
{/* rays: eight short spokes so it reads as a sun, not a ball */}
|
|
125
|
+
{Array.from({ length: 8 }, (_, i) => {
|
|
126
|
+
const angle = (i / 8) * Math.PI * 2;
|
|
127
|
+
return (
|
|
128
|
+
<mesh key={i} userData={{ keyLightPick: true }} position={[Math.cos(angle) * 0.27, Math.sin(angle) * 0.27, 0]} rotation={[0, 0, angle + Math.PI / 2]}>
|
|
129
|
+
<cylinderGeometry args={[0.014, 0.014, 0.1, 6]} />
|
|
130
|
+
<meshBasicMaterial color={sunColor} toneMapped={false} />
|
|
131
|
+
</mesh>
|
|
132
|
+
);
|
|
133
|
+
})}
|
|
134
|
+
{/* the real grab surface: generous, invisible, around the whole sun */}
|
|
135
|
+
<mesh userData={{ keyLightPick: true }}>
|
|
136
|
+
<sphereGeometry args={[0.42, 12, 10]} />
|
|
137
|
+
<meshBasicMaterial visible={false} />
|
|
138
|
+
</mesh>
|
|
139
|
+
<lineSegments>
|
|
140
|
+
<bufferGeometry>
|
|
141
|
+
<bufferAttribute attach="attributes-position" array={drop} count={2} itemSize={3} />
|
|
142
|
+
</bufferGeometry>
|
|
143
|
+
<lineBasicMaterial color="#f2b544" transparent opacity={selected ? 0.6 : 0.25} />
|
|
144
|
+
</lineSegments>
|
|
145
|
+
{/* where the light points: a dashed ray toward the stage centre */}
|
|
146
|
+
{selected && (
|
|
147
|
+
<Line
|
|
148
|
+
points={[[0, 0, 0], [-keyLight.x * 0.86, -keyLight.y * 0.86, -keyLight.z * 0.86]]}
|
|
149
|
+
color="#ffb454"
|
|
150
|
+
lineWidth={1.6}
|
|
151
|
+
dashed
|
|
152
|
+
dashSize={0.32}
|
|
153
|
+
gapSize={0.22}
|
|
154
|
+
transparent
|
|
155
|
+
opacity={0.45}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
{/* live readout while the sun is being dragged */}
|
|
159
|
+
{dragKind && (
|
|
160
|
+
<GizmoLabel
|
|
161
|
+
position={[0, 0.5, 0]}
|
|
162
|
+
text={`x ${keyLight.x.toFixed(1)} y ${keyLight.y.toFixed(1)} z ${keyLight.z.toFixed(1)}`}
|
|
163
|
+
camRef={camRef}
|
|
164
|
+
/>
|
|
165
|
+
)}
|
|
166
|
+
</group>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The shot camera drawn as an object in the editor view: a body and a short
|
|
172
|
+
* frustum wireframe on GIZMO_LAYER, synced from the live camera every frame.
|
|
173
|
+
* Preview, capture and PlayView draws all drop GIZMO_LAYER, so the ghost can
|
|
174
|
+
* never reach a recorded frame.
|
|
175
|
+
*/
|
|
176
|
+
export function ShotCameraGhost({ camRef, fovDeg, aspect, visible, selected }) {
|
|
177
|
+
const groupRef = useRef(null);
|
|
178
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
179
|
+
const frustum = useMemo(() => {
|
|
180
|
+
const depth = 0.62;
|
|
181
|
+
const h = Math.tan(THREE.MathUtils.degToRad(fovDeg) / 2) * depth;
|
|
182
|
+
const w = h * aspect;
|
|
183
|
+
const corners = [[-w, h], [w, h], [w, -h], [-w, -h]];
|
|
184
|
+
const points = [];
|
|
185
|
+
for (const [x, y] of corners) points.push(0, 0, 0, x, y, -depth);
|
|
186
|
+
for (let i = 0; i < 4; i++) {
|
|
187
|
+
const [ax, ay] = corners[i];
|
|
188
|
+
const [bx, by] = corners[(i + 1) % 4];
|
|
189
|
+
points.push(ax, ay, -depth, bx, by, -depth);
|
|
190
|
+
}
|
|
191
|
+
return new Float32Array(points);
|
|
192
|
+
}, [fovDeg, aspect]);
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
groupRef.current?.traverse((node) => node.layers?.set(GIZMO_LAYER));
|
|
195
|
+
invalidate();
|
|
196
|
+
}, [visible, frustum, selected, invalidate]);
|
|
197
|
+
useFrame(() => {
|
|
198
|
+
const cam = camRef.current;
|
|
199
|
+
const group = groupRef.current;
|
|
200
|
+
if (!cam || !group) return;
|
|
201
|
+
group.position.copy(cam.position);
|
|
202
|
+
group.quaternion.copy(cam.quaternion);
|
|
203
|
+
});
|
|
204
|
+
if (!visible) return null;
|
|
205
|
+
return (
|
|
206
|
+
<group ref={groupRef}>
|
|
207
|
+
<mesh userData={{ shotCameraPick: true }} position={[0, 0, 0.06]}>
|
|
208
|
+
<boxGeometry args={[0.15, 0.11, 0.2]} />
|
|
209
|
+
<meshBasicMaterial color={selected ? "#ffb454" : "#3b6ea5"} />
|
|
210
|
+
</mesh>
|
|
211
|
+
{/* the lens: a short cone opening toward the view direction */}
|
|
212
|
+
<mesh userData={{ shotCameraPick: true }} rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, -0.075]}>
|
|
213
|
+
<coneGeometry args={[0.052, 0.09, 20, 1, true]} />
|
|
214
|
+
<meshBasicMaterial color={selected ? "#e09a3e" : "#2f5a8a"} side={THREE.DoubleSide} />
|
|
215
|
+
</mesh>
|
|
216
|
+
<lineSegments key={`${fovDeg}:${aspect}`}>
|
|
217
|
+
<bufferGeometry>
|
|
218
|
+
<bufferAttribute attach="attributes-position" array={frustum} count={frustum.length / 3} itemSize={3} />
|
|
219
|
+
</bufferGeometry>
|
|
220
|
+
<lineBasicMaterial color={selected ? "#ffb454" : "#6f9cc9"} transparent opacity={0.85} />
|
|
221
|
+
</lineSegments>
|
|
222
|
+
</group>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The drawn camera rail in the 3D Scene view: editor furniture on the gizmo
|
|
228
|
+
* layer, so PlayView, the ink prepass and exports never see it.
|
|
229
|
+
*/
|
|
230
|
+
export function CameraRailScenePreview({ points, cumLen, length, crane }) {
|
|
231
|
+
const rootRef = useRef(null);
|
|
232
|
+
const lines = useMemo(() => {
|
|
233
|
+
if (!points || points.length < 2) return null;
|
|
234
|
+
const floor = points.map((point) => [point.x, 0.03, point.z]);
|
|
235
|
+
// A craned rail shows WHERE THE LENS RIDES: the same path lifted along
|
|
236
|
+
// the crane's start→end heights by arc progress, with the floor line
|
|
237
|
+
// kept as the track's ground projection.
|
|
238
|
+
const lifted = crane && cumLen && length > 1e-9
|
|
239
|
+
? points.map((point, i) => [point.x, craneHeightAt(crane, cumLen[i] / length), point.z])
|
|
240
|
+
: null;
|
|
241
|
+
return { floor, lifted };
|
|
242
|
+
}, [points, cumLen, length, crane]);
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
rootRef.current?.traverse((node) => node.layers.set(GIZMO_LAYER));
|
|
245
|
+
});
|
|
246
|
+
if (!lines) return null;
|
|
247
|
+
// drei's Line renders screen-space fat lines: a WebGL <line> is always
|
|
248
|
+
// 1px, which reads as a hairline on dense displays.
|
|
249
|
+
return (
|
|
250
|
+
<group ref={rootRef}>
|
|
251
|
+
<Line points={lines.floor} color="#a78bfa" lineWidth={2} transparent opacity={lines.lifted ? 0.35 : 0.8} depthWrite={false} />
|
|
252
|
+
{lines.lifted && (
|
|
253
|
+
<Line points={lines.lifted} color="#a78bfa" lineWidth={2.5} transparent opacity={0.9} depthWrite={false} />
|
|
254
|
+
)}
|
|
255
|
+
</group>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The crane's height marks as grabbable dots on the lifted curve. Click a
|
|
262
|
+
* dot to select it; the selected dot grows a 3-axis gizmo — Y sets the
|
|
263
|
+
* mark's height, X/Z bend the camera path itself by moving the drawn rail
|
|
264
|
+
* control point nearest the mark. A free drag on the dot rides a
|
|
265
|
+
* camera-facing vertical plane (height, plus arc slide for interior marks),
|
|
266
|
+
* Shift-drag slides along the arc at a fixed height, double-click the curve
|
|
267
|
+
* adds a mark and Delete removes a non-endpoint. Dots live on GIZMO_LAYER, so the
|
|
268
|
+
* recording never sees them.
|
|
269
|
+
*/
|
|
270
|
+
export const CRANE_AXES = [
|
|
271
|
+
{ axis: "x", dir: new THREE.Vector3(1, 0, 0), color: "#ff5340" },
|
|
272
|
+
{ axis: "y", dir: new THREE.Vector3(0, 1, 0), color: "#54e05c" },
|
|
273
|
+
{ axis: "z", dir: new THREE.Vector3(0, 0, 1), color: "#3d8bff" },
|
|
274
|
+
];
|
|
275
|
+
export const CRANE_ARROW_LEN = 0.5;
|
|
276
|
+
export const CRANE_GIZMO_SCALE = 0.16; // metres of gizmo per metre of camera distance
|
|
277
|
+
|
|
278
|
+
// The rail-bend preparation is shared with the Top-View board and lives with the
|
|
279
|
+
// rest of the rail math.
|
|
280
|
+
export { prepareRailBend };
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* A floating readout beside a drag — billboarded, sized to the screen, on
|
|
284
|
+
* GIZMO_LAYER so it is editor furniture the recording never sees.
|
|
285
|
+
*/
|
|
286
|
+
export function GizmoLabel({ position, text, camRef }) {
|
|
287
|
+
const ref = useRef(null);
|
|
288
|
+
useFrame(() => {
|
|
289
|
+
const cam = camRef?.current;
|
|
290
|
+
const node = ref.current;
|
|
291
|
+
if (!cam || !node) return;
|
|
292
|
+
node.quaternion.copy(cam.getWorldQuaternion(new THREE.Quaternion()));
|
|
293
|
+
const world = node.getWorldPosition(new THREE.Vector3());
|
|
294
|
+
node.scale.setScalar(Math.max(0.4, cam.getWorldPosition(new THREE.Vector3()).distanceTo(world) * 0.11));
|
|
295
|
+
node.traverse((child) => child.layers?.set(GIZMO_LAYER));
|
|
296
|
+
});
|
|
297
|
+
return (
|
|
298
|
+
<Text
|
|
299
|
+
ref={ref}
|
|
300
|
+
position={position}
|
|
301
|
+
fontSize={0.19}
|
|
302
|
+
color="#ffd27a"
|
|
303
|
+
outlineWidth={0.014}
|
|
304
|
+
outlineColor="#241a05"
|
|
305
|
+
anchorX="center"
|
|
306
|
+
anchorY="bottom"
|
|
307
|
+
renderOrder={1000}
|
|
308
|
+
material-depthTest={false}
|
|
309
|
+
material-transparent
|
|
310
|
+
>
|
|
311
|
+
{text}
|
|
312
|
+
</Text>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// How near (screen pixels) a press must be to the drawn route to count as
|
|
317
|
+
// aimed at it — the line is thin, so the target is a generous band around it.
|
|
318
|
+
const PATH_LINE_PICK_PX = 14;
|
|
319
|
+
// A mark never sits closer than this to its neighbours (fraction of the route),
|
|
320
|
+
// a little over the schema's own gap so a slide never lands on the edge of it.
|
|
321
|
+
const PATH_MARK_CLEARANCE = MARK_GAP + 0.01;
|
|
322
|
+
// The selected dot's lean rings, in the travel frame (Y up, Z forward): bank
|
|
323
|
+
// spins about forward — a torus lies in XY, so its axis is already Z — and
|
|
324
|
+
// pitch about the lateral axis, the same torus turned onto YZ. The rotate tool
|
|
325
|
+
// (E) shows them in place of the move arrows, exactly as the object gizmo does.
|
|
326
|
+
const PATH_RING_R = 0.42;
|
|
327
|
+
const PATH_RINGS = [
|
|
328
|
+
{ key: "bank", color: "#ffb454", rotation: [0, 0, 0] },
|
|
329
|
+
{ key: "pitch", color: "#7cc7ff", rotation: [0, Math.PI / 2, 0] },
|
|
330
|
+
];
|
|
331
|
+
const wrapDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
|
|
332
|
+
|
|
333
|
+
/** A world point in the pane's client pixels, or null behind the camera. */
|
|
334
|
+
function paneProject(world, pane, camera) {
|
|
335
|
+
if (!pane || !camera) return null;
|
|
336
|
+
const bounds = pane.getBoundingClientRect();
|
|
337
|
+
const projected = new THREE.Vector3(world.x, world.y ?? 0, world.z).project(camera);
|
|
338
|
+
return projected.z > 1 ? null : {
|
|
339
|
+
x: bounds.left + ((projected.x + 1) / 2) * bounds.width,
|
|
340
|
+
y: bounds.top + ((1 - projected.y) / 2) * bounds.height,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* A scene object's travel path in the 3D view, edited the way the camera rail
|
|
346
|
+
* is (CraneHandles): the route is a dense curve, and a few MARKS ride on it —
|
|
347
|
+
* the two ends, plus any added by double-clicking the line. Pressing a mark
|
|
348
|
+
* selects it and drags it freely (the curve bends around it, fading out by the
|
|
349
|
+
* neighbouring marks, so the marks beside it stay put); Shift slides it along
|
|
350
|
+
* the route; the 3-axis gizmo on the selected mark bends x / z and lifts y;
|
|
351
|
+
* Delete removes an interior mark. The marks are where a hand takes hold; the
|
|
352
|
+
* route's own points are its shape and are moved by the bend, never one by one.
|
|
353
|
+
*/
|
|
354
|
+
export function ObjectPathHandles({ path, selectedIndex, enabled, mode = "move", paneRef, camRef, onSelect, onChangePath, onDragStart, onDragEnd }) {
|
|
355
|
+
const { gl } = useThree();
|
|
356
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
357
|
+
const groupRef = useRef(null);
|
|
358
|
+
const gizmoRef = useRef(null);
|
|
359
|
+
const dragRef = useRef(null);
|
|
360
|
+
const stateRef = useRef(null);
|
|
361
|
+
// What the pointer is doing: a bend drag lights up the span it deforms.
|
|
362
|
+
const [dragInfo, setDragInfo] = useState(null);
|
|
363
|
+
// Mark index under the pointer, for the hover highlight.
|
|
364
|
+
const [hover, setHover] = useState(null);
|
|
365
|
+
// The pointer is on the line where a dot could still be dropped.
|
|
366
|
+
const [lineHover, setLineHover] = useState(false);
|
|
367
|
+
// Which lean ring the pointer is over, for the hover highlight.
|
|
368
|
+
const [ringHover, setRingHover] = useState(null);
|
|
369
|
+
stateRef.current = { path, selectedIndex, enabled, mode, onSelect, onChangePath, onDragStart, onDragEnd };
|
|
370
|
+
// The line is not a mesh the raycaster can hit, but the object gizmo's
|
|
371
|
+
// selection handler decides "does this press belong to a path handle?" by
|
|
372
|
+
// raycasting GIZMO_LAYER (gizmo-claim.js). This invisible node answers that
|
|
373
|
+
// ray in SCREEN space — near the drawn curve counts — so a press on the
|
|
374
|
+
// route is claimed instead of deselecting the object, which unmounted these
|
|
375
|
+
// handles before the double-click that adds a mark could arrive.
|
|
376
|
+
const lineProxy = useMemo(() => {
|
|
377
|
+
const node = new THREE.Group();
|
|
378
|
+
node.userData.pathLine = true;
|
|
379
|
+
node.raycast = function raycastRoute(raycaster, intersects) {
|
|
380
|
+
const s = stateRef.current;
|
|
381
|
+
const camera = raycaster.camera ?? camRef.current;
|
|
382
|
+
if (!s.enabled || !s.path || !camera || !paneRef.current) return;
|
|
383
|
+
// Any point down the ray lands on the pointer's pixel: that is the pointer.
|
|
384
|
+
const ahead = raycaster.ray.at(Math.max(1, (camera.near ?? 0.1) * 2), new THREE.Vector3());
|
|
385
|
+
const pointer = paneProject(ahead, paneRef.current, camera);
|
|
386
|
+
if (!pointer) return;
|
|
387
|
+
const spot = nearestPathFraction(s.path, (world) => paneProject(world, paneRef.current, camera), pointer.x, pointer.y);
|
|
388
|
+
if (spot && spot.d <= PATH_LINE_PICK_PX) intersects.push({ distance: 1e-3, point: ahead, object: node });
|
|
389
|
+
};
|
|
390
|
+
return node;
|
|
391
|
+
// refs are stable
|
|
392
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
393
|
+
}, []);
|
|
394
|
+
useFrame(() => {
|
|
395
|
+
const gizmo = gizmoRef.current;
|
|
396
|
+
const camera = camRef.current;
|
|
397
|
+
if (!gizmo || !camera) return;
|
|
398
|
+
const world = gizmo.getWorldPosition(new THREE.Vector3());
|
|
399
|
+
gizmo.scale.setScalar(Math.max(0.35, camera.getWorldPosition(new THREE.Vector3()).distanceTo(world) * CRANE_GIZMO_SCALE));
|
|
400
|
+
});
|
|
401
|
+
// Everything here is editor furniture on GIZMO_LAYER — and the pick
|
|
402
|
+
// raycasts look only there, so a handle that mounts later (the rings the
|
|
403
|
+
// rotate tool brings) must be stamped too: `mode` is a dependency.
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
groupRef.current?.traverse((node) => node.layers?.set(GIZMO_LAYER));
|
|
406
|
+
invalidate();
|
|
407
|
+
}, [path, selectedIndex, enabled, mode, dragInfo, invalidate]);
|
|
408
|
+
useEffect(() => {
|
|
409
|
+
if (!enabled) return undefined;
|
|
410
|
+
const raycaster = new THREE.Raycaster();
|
|
411
|
+
raycaster.layers.set(GIZMO_LAYER);
|
|
412
|
+
const rayFrom = (event) => {
|
|
413
|
+
const pane = paneRef.current;
|
|
414
|
+
const camera = camRef.current;
|
|
415
|
+
if (!pane || !camera) return null;
|
|
416
|
+
const bounds = pane.getBoundingClientRect();
|
|
417
|
+
if (bounds.width < 2 || bounds.height < 2) return null;
|
|
418
|
+
const ndc = new THREE.Vector2(
|
|
419
|
+
((event.clientX - bounds.left) / bounds.width) * 2 - 1,
|
|
420
|
+
-((event.clientY - bounds.top) / bounds.height) * 2 + 1,
|
|
421
|
+
);
|
|
422
|
+
if (Math.abs(ndc.x) > 1 || Math.abs(ndc.y) > 1) return null;
|
|
423
|
+
camera.updateMatrixWorld();
|
|
424
|
+
raycaster.setFromCamera(ndc, camera);
|
|
425
|
+
return raycaster;
|
|
426
|
+
};
|
|
427
|
+
const paneScreen = (world) => paneProject(world, paneRef.current, camRef.current);
|
|
428
|
+
// The bend a drag of mark `index` will apply: the route's points with a
|
|
429
|
+
// per-point weight (the camera rail's own preparation, so a mark deforms
|
|
430
|
+
// the route the same way here), seeded with a vertex under the mark when
|
|
431
|
+
// the stroke left none close enough to pull.
|
|
432
|
+
const prepareBend = (s, index) => {
|
|
433
|
+
const fractions = pathMarkFractions(s.path);
|
|
434
|
+
const base = pathPointAtFraction(s.path, fractions[index]);
|
|
435
|
+
if (!base) return null;
|
|
436
|
+
return { base, fractions, bend: prepareRailBend(s.path.points, base, fractions.map((t) => ({ t })), index, { maxControls: MAX_PATH_POINTS }) };
|
|
437
|
+
};
|
|
438
|
+
// The travel frame at mark `index`: the heading the body has there (the
|
|
439
|
+
// route's own direction), as forward / right vectors on the floor.
|
|
440
|
+
const frameAt = (s, index) => {
|
|
441
|
+
const t = pathMarkFractions(s.path)[index];
|
|
442
|
+
const before = pathPointAtFraction(s.path, Math.max(0, t - 0.01));
|
|
443
|
+
const after = pathPointAtFraction(s.path, Math.min(1, t + 0.01));
|
|
444
|
+
const yaw = before && after ? Math.atan2(after.x - before.x, after.z - before.z) : 0;
|
|
445
|
+
return { yaw, forward: new THREE.Vector3(Math.sin(yaw), 0, Math.cos(yaw)), right: new THREE.Vector3(Math.cos(yaw), 0, -Math.sin(yaw)) };
|
|
446
|
+
};
|
|
447
|
+
const onDown = (event) => {
|
|
448
|
+
const s = stateRef.current;
|
|
449
|
+
if (!s.enabled || !s.path || event.button !== 0) return;
|
|
450
|
+
if (!groupRef.current || !rayFrom(event)) return;
|
|
451
|
+
const hits = raycaster.intersectObjects(groupRef.current.children, true);
|
|
452
|
+
// A ring on the selected dot turns its lean: bank about the travel
|
|
453
|
+
// direction, pitch about the lateral axis. The pointer's angle around
|
|
454
|
+
// the ring is read in the ring's own plane, the object gizmo's way.
|
|
455
|
+
const ringHit = hits.find((entry) => entry.object.userData?.pathRing);
|
|
456
|
+
if (ringHit && s.mode === "rotate" && s.selectedIndex != null) {
|
|
457
|
+
const key = ringHit.object.userData.pathRing;
|
|
458
|
+
const fractions = pathMarkFractions(s.path);
|
|
459
|
+
if (s.selectedIndex <= 0 || s.selectedIndex >= fractions.length - 1) return;
|
|
460
|
+
const base = pathPointAtFraction(s.path, fractions[s.selectedIndex]);
|
|
461
|
+
if (!base) return;
|
|
462
|
+
const frame = frameAt(s, s.selectedIndex);
|
|
463
|
+
const spin = key === "bank" ? frame.forward : frame.right;
|
|
464
|
+
const origin = new THREE.Vector3(base.x, base.y, base.z);
|
|
465
|
+
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(spin, origin);
|
|
466
|
+
const hit = new THREE.Vector3();
|
|
467
|
+
if (!raycaster.ray.intersectPlane(plane, hit)) return;
|
|
468
|
+
const tangent = new THREE.Vector3(spin.y, spin.z, spin.x).cross(spin).normalize();
|
|
469
|
+
const binormal = spin.clone().cross(tangent).normalize();
|
|
470
|
+
const offset = hit.clone().sub(origin);
|
|
471
|
+
if (offset.lengthSq() < 1e-8) return;
|
|
472
|
+
event.stopImmediatePropagation();
|
|
473
|
+
event.preventDefault();
|
|
474
|
+
const marks = pathMarks(s.path);
|
|
475
|
+
dragRef.current = {
|
|
476
|
+
ring: key, index: s.selectedIndex, origin, plane, tangent, binormal,
|
|
477
|
+
lastAngle: Math.atan2(offset.dot(binormal), offset.dot(tangent)), turned: 0,
|
|
478
|
+
startValue: marks[s.selectedIndex - 1]?.[key] ?? 0, marks, recorded: false,
|
|
479
|
+
};
|
|
480
|
+
gl.domElement.style.cursor = "grabbing";
|
|
481
|
+
setDragInfo({ ring: key });
|
|
482
|
+
invalidate();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
// The axis gizmo outranks the dots: its arrows sit on the selected
|
|
486
|
+
// mark, and a press on an arrow is a directed move, not a reselect.
|
|
487
|
+
const axisHit = hits.find((entry) => entry.object.userData?.pathAxis);
|
|
488
|
+
if (axisHit && s.selectedIndex != null) {
|
|
489
|
+
const prepared = prepareBend(s, s.selectedIndex);
|
|
490
|
+
if (!prepared?.bend) return;
|
|
491
|
+
const axis = axisHit.object.userData.pathAxis;
|
|
492
|
+
const origin = new THREE.Vector3(prepared.base.x, prepared.base.y, prepared.base.z);
|
|
493
|
+
const dir = CRANE_AXES.find((entry) => entry.axis === axis).dir;
|
|
494
|
+
const eye = new THREE.Vector3();
|
|
495
|
+
camRef.current.getWorldDirection(eye);
|
|
496
|
+
// slide plane: contains the axis and faces the camera as squarely as it can
|
|
497
|
+
const normal = dir.clone().cross(eye).cross(dir);
|
|
498
|
+
if (normal.lengthSq() < 1e-6) return;
|
|
499
|
+
normal.normalize();
|
|
500
|
+
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, origin);
|
|
501
|
+
const hitStart = new THREE.Vector3();
|
|
502
|
+
if (!raycaster.ray.intersectPlane(plane, hitStart)) return;
|
|
503
|
+
event.stopImmediatePropagation();
|
|
504
|
+
event.preventDefault();
|
|
505
|
+
dragRef.current = { axis, index: s.selectedIndex, dir: dir.clone(), plane, hitStart, bend: prepared.bend, recorded: false };
|
|
506
|
+
setDragInfo({ t0: prepared.bend.t0, radius: prepared.bend.radius });
|
|
507
|
+
invalidate();
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const hit = hits.find((entry) => entry.object.userData?.pathIndex !== undefined);
|
|
511
|
+
if (!hit) {
|
|
512
|
+
// A press on the route itself belongs to it too: take it so the
|
|
513
|
+
// fly camera does not, and the object stays selected for the
|
|
514
|
+
// double-click that follows.
|
|
515
|
+
if (hits.some((entry) => entry.object.userData?.pathLine)) {
|
|
516
|
+
event.stopImmediatePropagation();
|
|
517
|
+
event.preventDefault();
|
|
518
|
+
}
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const index = hit.object.userData.pathIndex;
|
|
522
|
+
const prepared = prepareBend(s, index);
|
|
523
|
+
if (!prepared?.bend) return;
|
|
524
|
+
// A press on the mark moves it freely on the camera plane — the sun
|
|
525
|
+
// puck's grammar, so the route can be nudged without aiming at an arrow.
|
|
526
|
+
const facing = new THREE.Vector3();
|
|
527
|
+
camRef.current.getWorldDirection(facing);
|
|
528
|
+
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(facing, new THREE.Vector3(prepared.base.x, prepared.base.y, prepared.base.z));
|
|
529
|
+
const hitStart = new THREE.Vector3();
|
|
530
|
+
if (!raycaster.ray.intersectPlane(plane, hitStart)) return;
|
|
531
|
+
event.stopImmediatePropagation();
|
|
532
|
+
event.preventDefault();
|
|
533
|
+
s.onSelect(index);
|
|
534
|
+
const slide = event.shiftKey && index > 0 && index < prepared.fractions.length - 1;
|
|
535
|
+
dragRef.current = { axis: null, index, slide, plane, hitStart, base: prepared.base, fractions: prepared.fractions, marks: pathMarks(s.path), bend: prepared.bend, recorded: false };
|
|
536
|
+
gl.domElement.style.cursor = "grabbing";
|
|
537
|
+
setDragInfo(slide ? { slide: true } : { t0: prepared.bend.t0, radius: prepared.bend.radius });
|
|
538
|
+
invalidate();
|
|
539
|
+
};
|
|
540
|
+
const onMove = (event) => {
|
|
541
|
+
const s = stateRef.current;
|
|
542
|
+
const drag = dragRef.current;
|
|
543
|
+
if (!drag || !s.enabled || !s.path) return;
|
|
544
|
+
if (!rayFrom(event)) return;
|
|
545
|
+
if (drag.ring) {
|
|
546
|
+
const hit = new THREE.Vector3();
|
|
547
|
+
if (!raycaster.ray.intersectPlane(drag.plane, hit)) return;
|
|
548
|
+
const offset = hit.sub(drag.origin);
|
|
549
|
+
const angle = Math.atan2(offset.dot(drag.binormal), offset.dot(drag.tangent));
|
|
550
|
+
// Wrapped increments: crossing the ring's seam never jumps a turn.
|
|
551
|
+
drag.turned += wrapDeg(((angle - drag.lastAngle) * 180) / Math.PI);
|
|
552
|
+
drag.lastAngle = angle;
|
|
553
|
+
// The lean's own handedness (travelPose): bank is a right-handed
|
|
554
|
+
// turn about forward; pitch reads nose-up, the other way about right.
|
|
555
|
+
const raw = drag.startValue + (drag.ring === "bank" ? drag.turned : -drag.turned);
|
|
556
|
+
const step = event.ctrlKey || event.metaKey ? 5 : 0.5;
|
|
557
|
+
const value = Math.max(-MAX_PATH_LEAN, Math.min(MAX_PATH_LEAN, Math.round(raw / step) * step));
|
|
558
|
+
if (!drag.recorded) {
|
|
559
|
+
s.onDragStart?.();
|
|
560
|
+
drag.recorded = true;
|
|
561
|
+
}
|
|
562
|
+
s.onChangePath({ marks: drag.marks.map((entry, i) => (i + 1 === drag.index ? { ...entry, [drag.ring]: value } : entry)) }, { dragging: true });
|
|
563
|
+
invalidate();
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (drag.slide) {
|
|
567
|
+
// slide along the arc: the ray onto the horizontal plane at the mark
|
|
568
|
+
const floor = new THREE.Plane(new THREE.Vector3(0, 1, 0), -drag.base.y);
|
|
569
|
+
const world = new THREE.Vector3();
|
|
570
|
+
if (!raycaster.ray.intersectPlane(floor, world)) return;
|
|
571
|
+
const spot = nearestPathFraction(s.path, (point) => ({ x: point.x, y: point.z }), world.x, world.z);
|
|
572
|
+
if (!spot) return;
|
|
573
|
+
const t = Math.max(
|
|
574
|
+
drag.fractions[drag.index - 1] + PATH_MARK_CLEARANCE,
|
|
575
|
+
Math.min(drag.fractions[drag.index + 1] - PATH_MARK_CLEARANCE, spot.t),
|
|
576
|
+
);
|
|
577
|
+
if (!drag.recorded) {
|
|
578
|
+
s.onDragStart?.();
|
|
579
|
+
drag.recorded = true;
|
|
580
|
+
}
|
|
581
|
+
// the dot keeps its lean as it slides: matched by place in the row, not by value
|
|
582
|
+
s.onChangePath({ marks: drag.marks.map((entry, i) => (i + 1 === drag.index ? { ...entry, t } : entry)) }, { dragging: true });
|
|
583
|
+
invalidate();
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const world = new THREE.Vector3();
|
|
587
|
+
if (!raycaster.ray.intersectPlane(drag.plane, world)) return;
|
|
588
|
+
let delta;
|
|
589
|
+
if (drag.axis) {
|
|
590
|
+
const travel = world.clone().sub(drag.hitStart).dot(drag.dir);
|
|
591
|
+
delta = drag.dir.clone().multiplyScalar(travel);
|
|
592
|
+
} else {
|
|
593
|
+
delta = world.clone().sub(drag.hitStart);
|
|
594
|
+
}
|
|
595
|
+
if (!drag.recorded) {
|
|
596
|
+
s.onDragStart?.();
|
|
597
|
+
drag.recorded = true;
|
|
598
|
+
}
|
|
599
|
+
// A weighted bump: the points around the mark follow, fading out by the
|
|
600
|
+
// neighbouring marks; the ends of an interior mark's bend stay pinned.
|
|
601
|
+
const moved = drag.bend.startControls.map((entry, i) => {
|
|
602
|
+
const weight = drag.bend.weights[i];
|
|
603
|
+
if (!(weight > 0)) return entry;
|
|
604
|
+
return {
|
|
605
|
+
...entry,
|
|
606
|
+
x: entry.x + delta.x * weight,
|
|
607
|
+
y: Math.max(0, (entry.y ?? 0) + delta.y * weight),
|
|
608
|
+
z: entry.z + delta.z * weight,
|
|
609
|
+
};
|
|
610
|
+
});
|
|
611
|
+
s.onChangePath({ points: moved }, { dragging: true });
|
|
612
|
+
invalidate();
|
|
613
|
+
};
|
|
614
|
+
const onUp = () => {
|
|
615
|
+
const drag = dragRef.current;
|
|
616
|
+
dragRef.current = null;
|
|
617
|
+
setDragInfo(null);
|
|
618
|
+
if (drag) gl.domElement.style.cursor = "";
|
|
619
|
+
if (drag?.recorded) stateRef.current.onDragEnd?.();
|
|
620
|
+
invalidate();
|
|
621
|
+
};
|
|
622
|
+
// Hover: the only cue that the dots are handles. Window-bubble, so it
|
|
623
|
+
// runs after the object gizmo's own cursor reset on the canvas.
|
|
624
|
+
const onHover = (event) => {
|
|
625
|
+
const s = stateRef.current;
|
|
626
|
+
if (dragRef.current || !s.enabled || !s.path || event.buttons !== 0 || event.target !== gl.domElement) {
|
|
627
|
+
setHover((current) => (current === null ? current : null));
|
|
628
|
+
setLineHover((current) => (current ? false : current));
|
|
629
|
+
setRingHover((current) => (current === null ? current : null));
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
let index = null;
|
|
633
|
+
let ring = null;
|
|
634
|
+
if (rayFrom(event) && groupRef.current) {
|
|
635
|
+
const hits = raycaster.intersectObjects(groupRef.current.children, true);
|
|
636
|
+
const axisHit = s.selectedIndex != null && hits.find((entry) => entry.object.userData?.pathAxis);
|
|
637
|
+
const ringHit = s.mode === "rotate" && s.selectedIndex != null && hits.find((entry) => entry.object.userData?.pathRing);
|
|
638
|
+
const dot = hits.find((entry) => entry.object.userData?.pathIndex !== undefined);
|
|
639
|
+
if (ringHit) ring = ringHit.object.userData.pathRing;
|
|
640
|
+
else if (!axisHit && dot) index = dot.object.userData.pathIndex;
|
|
641
|
+
}
|
|
642
|
+
setHover((current) => (current === index ? current : index));
|
|
643
|
+
setRingHover((current) => (current === ring ? current : ring));
|
|
644
|
+
if (index !== null || ring !== null) gl.domElement.style.cursor = "grab";
|
|
645
|
+
// The line itself answers too: it brightens and the cursor says "copy"
|
|
646
|
+
// while a double-click here would still drop a dot.
|
|
647
|
+
let addable = false;
|
|
648
|
+
if (index === null && camRef.current) {
|
|
649
|
+
const fractions = pathMarkFractions(s.path);
|
|
650
|
+
if (fractions.length < MAX_PATH_MARKS) {
|
|
651
|
+
camRef.current.updateMatrixWorld();
|
|
652
|
+
const best = nearestPathFraction(s.path, paneScreen, event.clientX, event.clientY);
|
|
653
|
+
addable = !!best && best.d <= PATH_LINE_PICK_PX && !fractions.some((entry) => Math.abs(entry - best.t) < PATH_MARK_CLEARANCE);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
setLineHover((current) => (current === addable ? current : addable));
|
|
657
|
+
if (addable) gl.domElement.style.cursor = "copy";
|
|
658
|
+
};
|
|
659
|
+
// Double-click the route to drop a mark on it — the crane curve's
|
|
660
|
+
// gesture, so one habit covers both. A mark only gives the next drag
|
|
661
|
+
// something to grab in the middle: it never changes where the object goes.
|
|
662
|
+
const onDouble = (event) => {
|
|
663
|
+
const s = stateRef.current;
|
|
664
|
+
if (!s.enabled || !s.path || !camRef.current) return;
|
|
665
|
+
const fractions = pathMarkFractions(s.path);
|
|
666
|
+
if (fractions.length >= MAX_PATH_MARKS) return;
|
|
667
|
+
camRef.current.updateMatrixWorld();
|
|
668
|
+
const best = nearestPathFraction(s.path, paneScreen, event.clientX, event.clientY);
|
|
669
|
+
if (!best || best.d > PATH_LINE_PICK_PX) return;
|
|
670
|
+
// Not on top of a mark that is already there.
|
|
671
|
+
if (fractions.some((entry) => Math.abs(entry - best.t) < PATH_MARK_CLEARANCE)) return;
|
|
672
|
+
event.stopImmediatePropagation();
|
|
673
|
+
event.preventDefault();
|
|
674
|
+
// The new dot is born with the lean the route already has there, and its
|
|
675
|
+
// neighbours keep theirs.
|
|
676
|
+
const added = insertPathMark(s.path, best.t);
|
|
677
|
+
if (!added) return;
|
|
678
|
+
s.onDragStart?.();
|
|
679
|
+
s.onChangePath({ marks: added.marks });
|
|
680
|
+
s.onDragEnd?.();
|
|
681
|
+
s.onSelect(added.index);
|
|
682
|
+
invalidate();
|
|
683
|
+
};
|
|
684
|
+
// Delete removes the selected interior mark, never the object: while a
|
|
685
|
+
// mark is selected this handler owns the key, and the scene-object delete
|
|
686
|
+
// handler stands down (it checks the same selection). The ends stay — a
|
|
687
|
+
// route is whatever lies between them; "Delete path" clears it.
|
|
688
|
+
const onKey = (event) => {
|
|
689
|
+
const s = stateRef.current;
|
|
690
|
+
if (!s.enabled || !s.path || s.selectedIndex == null) return;
|
|
691
|
+
if (event.key !== "Delete" && event.key !== "Backspace") return;
|
|
692
|
+
if (document.activeElement && /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) return;
|
|
693
|
+
event.preventDefault();
|
|
694
|
+
event.stopImmediatePropagation();
|
|
695
|
+
const fractions = pathMarkFractions(s.path);
|
|
696
|
+
if (s.selectedIndex <= 0 || s.selectedIndex >= fractions.length - 1) return;
|
|
697
|
+
s.onDragStart?.();
|
|
698
|
+
s.onChangePath({ marks: pathMarks(s.path).filter((_, i) => i + 1 !== s.selectedIndex) });
|
|
699
|
+
s.onDragEnd?.();
|
|
700
|
+
s.onSelect(null);
|
|
701
|
+
invalidate();
|
|
702
|
+
};
|
|
703
|
+
const el = gl.domElement;
|
|
704
|
+
el.addEventListener("pointerdown", onDown);
|
|
705
|
+
window.addEventListener("pointermove", onMove, true);
|
|
706
|
+
window.addEventListener("pointerup", onUp, true);
|
|
707
|
+
window.addEventListener("pointermove", onHover);
|
|
708
|
+
el.addEventListener("dblclick", onDouble, true);
|
|
709
|
+
window.addEventListener("keydown", onKey, true);
|
|
710
|
+
return () => {
|
|
711
|
+
el.removeEventListener("pointerdown", onDown);
|
|
712
|
+
window.removeEventListener("pointermove", onMove, true);
|
|
713
|
+
window.removeEventListener("pointerup", onUp, true);
|
|
714
|
+
window.removeEventListener("pointermove", onHover);
|
|
715
|
+
el.removeEventListener("dblclick", onDouble, true);
|
|
716
|
+
window.removeEventListener("keydown", onKey, true);
|
|
717
|
+
};
|
|
718
|
+
// paneRef/camRef are stable; handlers read live state through stateRef
|
|
719
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
720
|
+
}, [enabled, gl, invalidate]);
|
|
721
|
+
const curve = useMemo(() => (path?.points?.length > 1 ? pathCurve(path) : null), [path]);
|
|
722
|
+
const linePoints = useMemo(
|
|
723
|
+
// The curve the object travels, not the chords between its points.
|
|
724
|
+
() => (curve ? curve.points : []).map((point) => [point.x, point.y + 0.02, point.z]),
|
|
725
|
+
[curve],
|
|
726
|
+
);
|
|
727
|
+
const marks = useMemo(
|
|
728
|
+
() => (path ? pathMarkFractions(path).map((t) => pathPointAtFraction(path, t)) : []),
|
|
729
|
+
[path],
|
|
730
|
+
);
|
|
731
|
+
// The dots that carry a lean, in the travel frame they will pose the body in:
|
|
732
|
+
// a yaw from the route's direction there, then the lean itself.
|
|
733
|
+
const leans = useMemo(() => {
|
|
734
|
+
if (!path) return [];
|
|
735
|
+
return pathMarks(path).map((mark, i) => {
|
|
736
|
+
if (!mark.bank && !mark.pitch) return null;
|
|
737
|
+
const before = pathPointAtFraction(path, Math.max(0, mark.t - 0.01));
|
|
738
|
+
const after = pathPointAtFraction(path, Math.min(1, mark.t + 0.01));
|
|
739
|
+
const yaw = before && after ? Math.atan2(after.x - before.x, after.z - before.z) : 0;
|
|
740
|
+
const quaternion = new THREE.Quaternion().setFromEuler(new THREE.Euler(-mark.pitch * (Math.PI / 180), yaw, mark.bank * (Math.PI / 180), "YXZ"));
|
|
741
|
+
return { index: i + 1, quaternion };
|
|
742
|
+
}).filter(Boolean);
|
|
743
|
+
}, [path]);
|
|
744
|
+
// The travel frame the selected dot's rings stand in: a yaw from the route's
|
|
745
|
+
// direction there. Only an interior dot has one — the ends are always level.
|
|
746
|
+
const leanFrame = useMemo(() => {
|
|
747
|
+
if (!path || selectedIndex == null) return null;
|
|
748
|
+
const fractions = pathMarkFractions(path);
|
|
749
|
+
if (selectedIndex <= 0 || selectedIndex >= fractions.length - 1) return null;
|
|
750
|
+
const t = fractions[selectedIndex];
|
|
751
|
+
const before = pathPointAtFraction(path, Math.max(0, t - 0.01));
|
|
752
|
+
const after = pathPointAtFraction(path, Math.min(1, t + 0.01));
|
|
753
|
+
const yaw = before && after ? Math.atan2(after.x - before.x, after.z - before.z) : 0;
|
|
754
|
+
return { quaternion: new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yaw) };
|
|
755
|
+
}, [path, selectedIndex]);
|
|
756
|
+
if (!enabled || !path || linePoints.length < 2) return null;
|
|
757
|
+
const selected = selectedIndex != null ? marks[selectedIndex] : null;
|
|
758
|
+
return (
|
|
759
|
+
<group ref={groupRef}>
|
|
760
|
+
<primitive object={lineProxy} />
|
|
761
|
+
<Line points={linePoints} color={lineHover ? "#d4f7e2" : "#6fcf97"} lineWidth={lineHover ? 4 : 2.5} transparent opacity={lineHover ? 1 : 0.9} />
|
|
762
|
+
{selected && (
|
|
763
|
+
<group ref={gizmoRef} position={[selected.x, selected.y, selected.z]} renderOrder={999}>
|
|
764
|
+
{mode === "rotate" && leanFrame && (
|
|
765
|
+
<group quaternion={leanFrame.quaternion}>
|
|
766
|
+
{PATH_RINGS.map(({ key, color, rotation }) => {
|
|
767
|
+
const lit = dragInfo?.ring === key || (!dragInfo && ringHover === key);
|
|
768
|
+
return (
|
|
769
|
+
<group key={key} rotation={rotation}>
|
|
770
|
+
<mesh renderOrder={999}>
|
|
771
|
+
<torusGeometry args={[PATH_RING_R, 0.022, 8, 48]} />
|
|
772
|
+
<meshStandardMaterial color="#000000" emissive={lit ? "#ffd23d" : color} emissiveIntensity={lit ? 3 : 2.2} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={0.95} />
|
|
773
|
+
</mesh>
|
|
774
|
+
{/* the grab band: a 2 cm ring is nothing to aim at */}
|
|
775
|
+
<mesh userData={{ pathRing: key }}>
|
|
776
|
+
<torusGeometry args={[PATH_RING_R, 0.07, 6, 32]} />
|
|
777
|
+
<meshBasicMaterial visible={false} />
|
|
778
|
+
</mesh>
|
|
779
|
+
</group>
|
|
780
|
+
);
|
|
781
|
+
})}
|
|
782
|
+
</group>
|
|
783
|
+
)}
|
|
784
|
+
{mode !== "rotate" && CRANE_AXES.map(({ axis, dir, color }) => {
|
|
785
|
+
const quat = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
|
|
786
|
+
return (
|
|
787
|
+
<group key={axis} quaternion={quat}>
|
|
788
|
+
<mesh position={[0, CRANE_ARROW_LEN / 2 + 0.08, 0]} renderOrder={999}>
|
|
789
|
+
<cylinderGeometry args={[0.016, 0.016, CRANE_ARROW_LEN, 8]} />
|
|
790
|
+
<meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.2} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={0.95} />
|
|
791
|
+
</mesh>
|
|
792
|
+
<mesh position={[0, CRANE_ARROW_LEN + 0.08 + 0.07, 0]} renderOrder={999}>
|
|
793
|
+
<coneGeometry args={[0.05, 0.14, 14]} />
|
|
794
|
+
<meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.2} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={0.95} />
|
|
795
|
+
</mesh>
|
|
796
|
+
<mesh position={[0, (CRANE_ARROW_LEN + 0.14) / 2 + 0.08, 0]} userData={{ pathAxis: axis }}>
|
|
797
|
+
<cylinderGeometry args={[0.09, 0.09, CRANE_ARROW_LEN + 0.14, 8]} />
|
|
798
|
+
<meshBasicMaterial visible={false} />
|
|
799
|
+
</mesh>
|
|
800
|
+
</group>
|
|
801
|
+
);
|
|
802
|
+
})}
|
|
803
|
+
</group>
|
|
804
|
+
)}
|
|
805
|
+
{marks.map((point, index) => point && (
|
|
806
|
+
<group key={`${index}:${marks.length}`} position={[point.x, point.y, point.z]}>
|
|
807
|
+
<mesh userData={{ pathIndex: index }} renderOrder={998} scale={hover === index ? 1.3 : 1}>
|
|
808
|
+
<sphereGeometry args={[0.085, 16, 12]} />
|
|
809
|
+
{/* the selected mark keeps its own colour; hover only brightens */}
|
|
810
|
+
<meshBasicMaterial color={index === selectedIndex ? (hover === index ? "#ffd08a" : "#ffb454") : hover === index ? "#d4f7e2" : "#6fcf97"} depthTest={false} transparent opacity={0.95} />
|
|
811
|
+
</mesh>
|
|
812
|
+
{/* the real click target: a dot is a dozen pixels from a few
|
|
813
|
+
metres back, so an invisible halo carries the press */}
|
|
814
|
+
{/* a dot that leans the body: a bar through it tilted the way the
|
|
815
|
+
body will sit (across = bank, along = pitch), in the travel frame */}
|
|
816
|
+
{leans.filter((lean) => lean.index === index).map((lean) => (
|
|
817
|
+
<group key="lean" quaternion={lean.quaternion} userData={{ pathLean: true }}>
|
|
818
|
+
<mesh renderOrder={998}>
|
|
819
|
+
<boxGeometry args={[0.7, 0.03, 0.03]} />
|
|
820
|
+
<meshBasicMaterial color="#ffb454" depthTest={false} transparent opacity={0.95} />
|
|
821
|
+
</mesh>
|
|
822
|
+
<mesh renderOrder={998}>
|
|
823
|
+
<boxGeometry args={[0.03, 0.03, 0.46]} />
|
|
824
|
+
<meshBasicMaterial color="#ffd08a" depthTest={false} transparent opacity={0.95} />
|
|
825
|
+
</mesh>
|
|
826
|
+
</group>
|
|
827
|
+
))}
|
|
828
|
+
<mesh userData={{ pathIndex: index }}>
|
|
829
|
+
<sphereGeometry args={[0.24, 10, 8]} />
|
|
830
|
+
<meshBasicMaterial visible={false} />
|
|
831
|
+
</mesh>
|
|
832
|
+
</group>
|
|
833
|
+
))}
|
|
834
|
+
{/* live readout while a drag moves the mark */}
|
|
835
|
+
{dragInfo && selected && (
|
|
836
|
+
<GizmoLabel
|
|
837
|
+
position={[selected.x, selected.y + 0.42, selected.z]}
|
|
838
|
+
text={dragInfo.ring
|
|
839
|
+
? `${dragInfo.ring} ${(pathMarks(path)[selectedIndex - 1]?.[dragInfo.ring] ?? 0).toFixed(1)}°`
|
|
840
|
+
: dragInfo.slide ? `${Math.round(pathMarkFractions(path)[selectedIndex] * 100)}%` : `x ${selected.x.toFixed(1)} y ${selected.y.toFixed(1)} z ${selected.z.toFixed(1)}`}
|
|
841
|
+
camRef={camRef}
|
|
842
|
+
/>
|
|
843
|
+
)}
|
|
844
|
+
{/* the span a bend will actually deform, lit on the route */}
|
|
845
|
+
{dragInfo?.radius != null && curve && (() => {
|
|
846
|
+
const span = [];
|
|
847
|
+
for (let i = 0; i < curve.points.length; i += 1) {
|
|
848
|
+
const t = curve.cumulative[i] / curve.length;
|
|
849
|
+
if (t >= dragInfo.t0 - dragInfo.radius && t <= dragInfo.t0 + dragInfo.radius) {
|
|
850
|
+
span.push([curve.points[i].x, curve.points[i].y + 0.03, curve.points[i].z]);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
if (span.length < 2) return null;
|
|
854
|
+
return <Line points={span} color="#ffb454" lineWidth={4} transparent opacity={0.9} />;
|
|
855
|
+
})()}
|
|
856
|
+
</group>
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export function CraneHandles({ rail, crane, controlPoints, selectedIndex, enabled, paneRef, camRef, onSelect, onChangePoints, onChangeRail, onDragStart, onDragEnd }) {
|
|
861
|
+
const { gl } = useThree();
|
|
862
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
863
|
+
const groupRef = useRef(null);
|
|
864
|
+
const gizmoRef = useRef(null);
|
|
865
|
+
const dragRef = useRef(null);
|
|
866
|
+
const stateRef = useRef(null);
|
|
867
|
+
// What the pointer is doing right now, for the in-scene feedback layer:
|
|
868
|
+
// height-ish drags float a metre readout, a bend drag lights up the arc
|
|
869
|
+
// span the bump will actually deform.
|
|
870
|
+
const [dragInfo, setDragInfo] = useState(null);
|
|
871
|
+
stateRef.current = { rail, crane, controlPoints, selectedIndex, enabled, onSelect, onChangePoints, onChangeRail, onDragStart, onDragEnd };
|
|
872
|
+
// Constant on-screen size for the axis gizmo: it is UI, not set dressing.
|
|
873
|
+
useFrame(() => {
|
|
874
|
+
const gizmo = gizmoRef.current;
|
|
875
|
+
const camera = camRef.current;
|
|
876
|
+
if (!gizmo || !camera) return;
|
|
877
|
+
const world = gizmo.getWorldPosition(new THREE.Vector3());
|
|
878
|
+
gizmo.scale.setScalar(Math.max(0.35, camera.getWorldPosition(new THREE.Vector3()).distanceTo(world) * CRANE_GIZMO_SCALE));
|
|
879
|
+
});
|
|
880
|
+
useEffect(() => {
|
|
881
|
+
groupRef.current?.traverse((node) => node.layers?.set(GIZMO_LAYER));
|
|
882
|
+
});
|
|
883
|
+
useEffect(() => {
|
|
884
|
+
if (!enabled) return undefined;
|
|
885
|
+
const raycaster = new THREE.Raycaster();
|
|
886
|
+
raycaster.layers.set(GIZMO_LAYER);
|
|
887
|
+
const rayFrom = (event) => {
|
|
888
|
+
const pane = paneRef.current;
|
|
889
|
+
const camera = camRef.current;
|
|
890
|
+
if (!pane || !camera) return null;
|
|
891
|
+
const bounds = pane.getBoundingClientRect();
|
|
892
|
+
if (bounds.width < 2 || bounds.height < 2) return null;
|
|
893
|
+
const ndc = new THREE.Vector2(
|
|
894
|
+
((event.clientX - bounds.left) / bounds.width) * 2 - 1,
|
|
895
|
+
-((event.clientY - bounds.top) / bounds.height) * 2 + 1,
|
|
896
|
+
);
|
|
897
|
+
if (Math.abs(ndc.x) > 1 || Math.abs(ndc.y) > 1) return null;
|
|
898
|
+
camera.updateMatrixWorld();
|
|
899
|
+
raycaster.setFromCamera(ndc, camera);
|
|
900
|
+
return raycaster;
|
|
901
|
+
};
|
|
902
|
+
const paneScreen = (world) => {
|
|
903
|
+
const pane = paneRef.current.getBoundingClientRect();
|
|
904
|
+
const projected = world.project(camRef.current);
|
|
905
|
+
return projected.z > 1 ? null : {
|
|
906
|
+
x: pane.left + ((projected.x + 1) / 2) * pane.width,
|
|
907
|
+
y: pane.top + ((1 - projected.y) / 2) * pane.height,
|
|
908
|
+
};
|
|
909
|
+
};
|
|
910
|
+
const onDown = (event) => {
|
|
911
|
+
const s = stateRef.current;
|
|
912
|
+
if (!s.enabled || !s.crane || event.button !== 0) return;
|
|
913
|
+
if (!groupRef.current || !rayFrom(event)) return;
|
|
914
|
+
const hits = raycaster.intersectObjects(groupRef.current.children, true);
|
|
915
|
+
// The axis gizmo outranks the dots: its arrows sit on the selected
|
|
916
|
+
// dot, and a press on an arrow is a directed move, not a reselect.
|
|
917
|
+
const axisHit = hits.find((entry) => entry.object.userData?.craneAxis);
|
|
918
|
+
if (axisHit && s.selectedIndex != null && s.crane.points[s.selectedIndex]) {
|
|
919
|
+
const axis = axisHit.object.userData.craneAxis;
|
|
920
|
+
const point = s.crane.points[s.selectedIndex];
|
|
921
|
+
const base = railPoint(s.rail, point.t * s.rail.length);
|
|
922
|
+
const origin = new THREE.Vector3(base.x, point.height, base.z);
|
|
923
|
+
const dir = CRANE_AXES.find((entry) => entry.axis === axis).dir;
|
|
924
|
+
const eye = new THREE.Vector3();
|
|
925
|
+
camRef.current.getWorldDirection(eye);
|
|
926
|
+
// slide plane: contains the axis and faces the camera as squarely
|
|
927
|
+
// as it can — the same construction the object gizmo uses
|
|
928
|
+
const normal = dir.clone().cross(eye).cross(dir);
|
|
929
|
+
if (normal.lengthSq() < 1e-6) return;
|
|
930
|
+
normal.normalize();
|
|
931
|
+
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, origin);
|
|
932
|
+
const hitStart = new THREE.Vector3();
|
|
933
|
+
if (!raycaster.ray.intersectPlane(plane, hitStart)) return;
|
|
934
|
+
// x/z bend the rail: a weighted bump around the mark's base, so
|
|
935
|
+
// the path deforms locally and the marks beside it stay put.
|
|
936
|
+
const bend = axis === "y" ? null : prepareRailBend(s.controlPoints, base, s.crane.points, s.selectedIndex);
|
|
937
|
+
if (axis !== "y" && !bend) return;
|
|
938
|
+
event.stopImmediatePropagation();
|
|
939
|
+
event.preventDefault();
|
|
940
|
+
dragRef.current = {
|
|
941
|
+
axis,
|
|
942
|
+
index: s.selectedIndex,
|
|
943
|
+
dir: dir.clone(),
|
|
944
|
+
plane,
|
|
945
|
+
hitStart,
|
|
946
|
+
startHeight: point.height,
|
|
947
|
+
startControls: bend?.startControls ?? null,
|
|
948
|
+
bendWeights: bend?.weights ?? null,
|
|
949
|
+
recorded: false,
|
|
950
|
+
};
|
|
951
|
+
setDragInfo(axis === "y" ? { kind: "height" } : { kind: "bend", t0: bend.t0, radius: bend.radius });
|
|
952
|
+
invalidate();
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
const hit = hits.find((entry) => entry.object.userData?.craneIndex !== undefined);
|
|
956
|
+
if (!hit) return;
|
|
957
|
+
event.stopImmediatePropagation();
|
|
958
|
+
event.preventDefault();
|
|
959
|
+
const index = hit.object.userData.craneIndex;
|
|
960
|
+
s.onSelect(index);
|
|
961
|
+
dragRef.current = { index, slide: event.shiftKey, recorded: false };
|
|
962
|
+
setDragInfo({ kind: event.shiftKey ? "slide" : "free" });
|
|
963
|
+
invalidate();
|
|
964
|
+
};
|
|
965
|
+
const onMove = (event) => {
|
|
966
|
+
const s = stateRef.current;
|
|
967
|
+
const drag = dragRef.current;
|
|
968
|
+
if (!drag || !s.enabled || !s.crane || !s.rail) return;
|
|
969
|
+
if (!rayFrom(event)) return;
|
|
970
|
+
const points = s.crane.points;
|
|
971
|
+
const point = points[drag.index];
|
|
972
|
+
if (!point) return;
|
|
973
|
+
if (!drag.recorded) {
|
|
974
|
+
s.onDragStart?.();
|
|
975
|
+
drag.recorded = true;
|
|
976
|
+
}
|
|
977
|
+
if (drag.axis) {
|
|
978
|
+
const world = new THREE.Vector3();
|
|
979
|
+
if (!raycaster.ray.intersectPlane(drag.plane, world)) return;
|
|
980
|
+
const travel = world.sub(drag.hitStart).dot(drag.dir);
|
|
981
|
+
if (drag.axis === "y") {
|
|
982
|
+
const height = Math.max(0.1, Math.min(12, drag.startHeight + travel));
|
|
983
|
+
s.onChangePoints(points.map((entry, i) => (i === drag.index ? { ...entry, height } : entry)), { dragging: true });
|
|
984
|
+
} else if (drag.startControls && drag.bendWeights && s.onChangeRail) {
|
|
985
|
+
const dx = drag.axis === "x" ? travel : 0;
|
|
986
|
+
const dz = drag.axis === "z" ? travel : 0;
|
|
987
|
+
const moved = drag.startControls.map((entry, i) => (drag.bendWeights[i] > 0
|
|
988
|
+
? { ...entry, x: entry.x + dx * drag.bendWeights[i], z: entry.z + dz * drag.bendWeights[i] }
|
|
989
|
+
: entry));
|
|
990
|
+
s.onChangeRail(moved, { dragging: true });
|
|
991
|
+
}
|
|
992
|
+
invalidate();
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
if (drag.slide && drag.index > 0 && drag.index < points.length - 1) {
|
|
996
|
+
// slide along the arc: ray onto the horizontal plane at the mark
|
|
997
|
+
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -point.height);
|
|
998
|
+
const world = new THREE.Vector3();
|
|
999
|
+
if (!raycaster.ray.intersectPlane(plane, world)) return;
|
|
1000
|
+
let bestI = 0;
|
|
1001
|
+
let bestD = Infinity;
|
|
1002
|
+
for (let i = 0; i < s.rail.points.length; i += 1) {
|
|
1003
|
+
const d = Math.hypot(s.rail.points[i].x - world.x, s.rail.points[i].z - world.z);
|
|
1004
|
+
if (d < bestD) {
|
|
1005
|
+
bestD = d;
|
|
1006
|
+
bestI = i;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
const t = Math.max(
|
|
1010
|
+
points[drag.index - 1].t + 0.02,
|
|
1011
|
+
Math.min(points[drag.index + 1].t - 0.02, s.rail.cumLen[bestI] / s.rail.length),
|
|
1012
|
+
);
|
|
1013
|
+
s.onChangePoints(points.map((entry, i) => (i === drag.index ? { ...entry, t } : entry)), { dragging: true });
|
|
1014
|
+
} else {
|
|
1015
|
+
// free move: ray onto the camera-facing vertical plane at the base.
|
|
1016
|
+
// Its y sets the height; its x/z re-projects an interior mark onto
|
|
1017
|
+
// the nearest rail arc position, so one drag steers both axes — the
|
|
1018
|
+
// rail is the only x/z a crane mark can occupy.
|
|
1019
|
+
const base = railPoint(s.rail, point.t * s.rail.length);
|
|
1020
|
+
const camera = camRef.current;
|
|
1021
|
+
const facing = new THREE.Vector3();
|
|
1022
|
+
camera.getWorldDirection(facing);
|
|
1023
|
+
facing.y = 0;
|
|
1024
|
+
if (facing.lengthSq() < 1e-6) return; // looking straight down: height is unreadable
|
|
1025
|
+
facing.normalize();
|
|
1026
|
+
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(facing, new THREE.Vector3(base.x, 0, base.z));
|
|
1027
|
+
const world = new THREE.Vector3();
|
|
1028
|
+
if (!raycaster.ray.intersectPlane(plane, world)) return;
|
|
1029
|
+
const height = Math.max(0.1, Math.min(12, world.y));
|
|
1030
|
+
let t = point.t;
|
|
1031
|
+
if (drag.index > 0 && drag.index < points.length - 1) {
|
|
1032
|
+
let bestI = 0;
|
|
1033
|
+
let bestD = Infinity;
|
|
1034
|
+
for (let i = 0; i < s.rail.points.length; i += 1) {
|
|
1035
|
+
const d = Math.hypot(s.rail.points[i].x - world.x, s.rail.points[i].z - world.z);
|
|
1036
|
+
if (d < bestD) {
|
|
1037
|
+
bestD = d;
|
|
1038
|
+
bestI = i;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
t = Math.max(
|
|
1042
|
+
points[drag.index - 1].t + 0.02,
|
|
1043
|
+
Math.min(points[drag.index + 1].t - 0.02, s.rail.cumLen[bestI] / s.rail.length),
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
s.onChangePoints(points.map((entry, i) => (i === drag.index ? { ...entry, t, height } : entry)), { dragging: true });
|
|
1047
|
+
}
|
|
1048
|
+
invalidate();
|
|
1049
|
+
};
|
|
1050
|
+
const onUp = () => {
|
|
1051
|
+
if (dragRef.current?.recorded) stateRef.current.onDragEnd?.();
|
|
1052
|
+
dragRef.current = null;
|
|
1053
|
+
setDragInfo(null);
|
|
1054
|
+
};
|
|
1055
|
+
const onDouble = (event) => {
|
|
1056
|
+
const s = stateRef.current;
|
|
1057
|
+
if (!s.enabled || !s.crane || !s.rail || !camRef.current) return;
|
|
1058
|
+
camRef.current.updateMatrixWorld();
|
|
1059
|
+
let best = null;
|
|
1060
|
+
for (let i = 0; i < s.rail.points.length; i += 1) {
|
|
1061
|
+
const t = s.rail.cumLen[i] / s.rail.length;
|
|
1062
|
+
const screen = paneScreen(new THREE.Vector3(s.rail.points[i].x, craneHeightAt(s.crane, t), s.rail.points[i].z));
|
|
1063
|
+
if (!screen) continue;
|
|
1064
|
+
const d = Math.hypot(screen.x - event.clientX, screen.y - event.clientY);
|
|
1065
|
+
if (!best || d < best.d) best = { d, t };
|
|
1066
|
+
}
|
|
1067
|
+
if (!best || best.d > 14) return;
|
|
1068
|
+
if (s.crane.points.length >= 8) return;
|
|
1069
|
+
if (s.crane.points.some((entry) => Math.abs(entry.t - best.t) < 0.03)) return;
|
|
1070
|
+
event.stopImmediatePropagation();
|
|
1071
|
+
event.preventDefault();
|
|
1072
|
+
const added = [...s.crane.points, { t: best.t, height: craneHeightAt(s.crane, best.t) }].sort((a, b) => a.t - b.t);
|
|
1073
|
+
s.onChangePoints(added);
|
|
1074
|
+
s.onSelect(added.findIndex((entry) => entry.t === best.t));
|
|
1075
|
+
invalidate();
|
|
1076
|
+
};
|
|
1077
|
+
const onKey = (event) => {
|
|
1078
|
+
const s = stateRef.current;
|
|
1079
|
+
if (!s.enabled || !s.crane) return;
|
|
1080
|
+
if (event.key !== "Delete" && event.key !== "Backspace") return;
|
|
1081
|
+
if (document.activeElement && /INPUT|TEXTAREA/.test(document.activeElement.tagName)) return;
|
|
1082
|
+
const index = s.selectedIndex;
|
|
1083
|
+
if (index == null || index <= 0 || index >= s.crane.points.length - 1) return;
|
|
1084
|
+
event.preventDefault();
|
|
1085
|
+
s.onChangePoints(s.crane.points.filter((_, i) => i !== index));
|
|
1086
|
+
s.onSelect(null);
|
|
1087
|
+
invalidate();
|
|
1088
|
+
};
|
|
1089
|
+
const el = gl.domElement;
|
|
1090
|
+
el.addEventListener("pointerdown", onDown, true);
|
|
1091
|
+
window.addEventListener("pointermove", onMove, true);
|
|
1092
|
+
window.addEventListener("pointerup", onUp, true);
|
|
1093
|
+
el.addEventListener("dblclick", onDouble, true);
|
|
1094
|
+
window.addEventListener("keydown", onKey);
|
|
1095
|
+
return () => {
|
|
1096
|
+
el.removeEventListener("pointerdown", onDown, true);
|
|
1097
|
+
window.removeEventListener("pointermove", onMove, true);
|
|
1098
|
+
window.removeEventListener("pointerup", onUp, true);
|
|
1099
|
+
el.removeEventListener("dblclick", onDouble, true);
|
|
1100
|
+
window.removeEventListener("keydown", onKey);
|
|
1101
|
+
};
|
|
1102
|
+
// paneRef/camRef are stable refs; handlers read live state through stateRef
|
|
1103
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1104
|
+
}, [enabled, gl, invalidate]);
|
|
1105
|
+
if (!enabled || !crane || !rail) return null;
|
|
1106
|
+
const selectedPoint = selectedIndex != null ? crane.points[selectedIndex] : null;
|
|
1107
|
+
const selectedBase = selectedPoint ? railPoint(rail, selectedPoint.t * rail.length) : null;
|
|
1108
|
+
return (
|
|
1109
|
+
<group ref={groupRef}>
|
|
1110
|
+
{selectedPoint && (
|
|
1111
|
+
<group ref={gizmoRef} position={[selectedBase.x, selectedPoint.height, selectedBase.z]} renderOrder={999}>
|
|
1112
|
+
{CRANE_AXES.map(({ axis, dir, color }) => {
|
|
1113
|
+
const quat = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
|
|
1114
|
+
return (
|
|
1115
|
+
<group key={axis} quaternion={quat}>
|
|
1116
|
+
<mesh position={[0, CRANE_ARROW_LEN / 2 + 0.08, 0]} renderOrder={999}>
|
|
1117
|
+
<cylinderGeometry args={[0.016, 0.016, CRANE_ARROW_LEN, 8]} />
|
|
1118
|
+
<meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.2} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={0.95} />
|
|
1119
|
+
</mesh>
|
|
1120
|
+
<mesh position={[0, CRANE_ARROW_LEN + 0.08 + 0.07, 0]} renderOrder={999}>
|
|
1121
|
+
<coneGeometry args={[0.05, 0.14, 14]} />
|
|
1122
|
+
<meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.2} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={0.95} />
|
|
1123
|
+
</mesh>
|
|
1124
|
+
<mesh position={[0, (CRANE_ARROW_LEN + 0.14) / 2 + 0.08, 0]} userData={{ craneAxis: axis }}>
|
|
1125
|
+
<cylinderGeometry args={[0.09, 0.09, CRANE_ARROW_LEN + 0.14, 8]} />
|
|
1126
|
+
<meshBasicMaterial visible={false} />
|
|
1127
|
+
</mesh>
|
|
1128
|
+
</group>
|
|
1129
|
+
);
|
|
1130
|
+
})}
|
|
1131
|
+
</group>
|
|
1132
|
+
)}
|
|
1133
|
+
{crane.points.map((point, index) => {
|
|
1134
|
+
const base = railPoint(rail, point.t * rail.length);
|
|
1135
|
+
return (
|
|
1136
|
+
<group key={`${index}:${crane.points.length}`} position={[base.x, point.height, base.z]}>
|
|
1137
|
+
<mesh userData={{ craneIndex: index }} renderOrder={998}>
|
|
1138
|
+
<sphereGeometry args={[0.085, 16, 12]} />
|
|
1139
|
+
<meshBasicMaterial color={index === selectedIndex ? "#ffb454" : "#a78bfa"} depthTest={false} transparent opacity={0.95} />
|
|
1140
|
+
</mesh>
|
|
1141
|
+
{/* the real click target: a dot is a dozen pixels from a few
|
|
1142
|
+
metres back, so an invisible halo carries the press */}
|
|
1143
|
+
<mesh userData={{ craneIndex: index }}>
|
|
1144
|
+
<sphereGeometry args={[0.24, 10, 8]} />
|
|
1145
|
+
<meshBasicMaterial visible={false} />
|
|
1146
|
+
</mesh>
|
|
1147
|
+
</group>
|
|
1148
|
+
);
|
|
1149
|
+
})}
|
|
1150
|
+
{/* live readout while a drag changes the mark's height */}
|
|
1151
|
+
{dragInfo && dragInfo.kind !== "bend" && selectedPoint && (
|
|
1152
|
+
<GizmoLabel
|
|
1153
|
+
position={[selectedBase.x, selectedPoint.height + 0.28, selectedBase.z]}
|
|
1154
|
+
text={dragInfo.kind === "slide" ? `${Math.round(selectedPoint.t * 100)}%` : `${selectedPoint.height.toFixed(1)} m`}
|
|
1155
|
+
camRef={camRef}
|
|
1156
|
+
/>
|
|
1157
|
+
)}
|
|
1158
|
+
{/* the span a bend drag will actually deform, lit on the floor path */}
|
|
1159
|
+
{dragInfo?.kind === "bend" && (() => {
|
|
1160
|
+
const span = [];
|
|
1161
|
+
for (let i = 0; i < rail.points.length; i += 1) {
|
|
1162
|
+
const t = rail.cumLen[i] / rail.length;
|
|
1163
|
+
if (t >= dragInfo.t0 - dragInfo.radius && t <= dragInfo.t0 + dragInfo.radius) {
|
|
1164
|
+
span.push([rail.points[i].x, 0.035, rail.points[i].z]);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
if (span.length < 2) return null;
|
|
1168
|
+
return <Line points={span} color="#ffb454" lineWidth={4} transparent opacity={0.9} />;
|
|
1169
|
+
})()}
|
|
1170
|
+
</group>
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
export function ShotPathPreview({ waypoints, start, activeWaypointId }) {
|
|
1175
|
+
const rootRef = useRef(null);
|
|
1176
|
+
const line = useMemo(() => {
|
|
1177
|
+
if (!waypoints.length) return null;
|
|
1178
|
+
const points = [{ x: start.x, z: start.z }, ...waypoints];
|
|
1179
|
+
const positions = new Float32Array(points.length * 3);
|
|
1180
|
+
points.forEach((point, i) => {
|
|
1181
|
+
positions[i * 3] = point.x;
|
|
1182
|
+
positions[i * 3 + 1] = 0.03;
|
|
1183
|
+
positions[i * 3 + 2] = point.z;
|
|
1184
|
+
});
|
|
1185
|
+
const geometry = new THREE.BufferGeometry();
|
|
1186
|
+
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
|
1187
|
+
return geometry;
|
|
1188
|
+
}, [start.x, start.z, waypoints]);
|
|
1189
|
+
useEffect(() => {
|
|
1190
|
+
// Every render: drei's Text meshes appear asynchronously, and each new
|
|
1191
|
+
// node must land on the gizmo layer before the next capture.
|
|
1192
|
+
rootRef.current?.traverse((node) => node.layers.set(GIZMO_LAYER));
|
|
1193
|
+
});
|
|
1194
|
+
if (!waypoints.length) return null;
|
|
1195
|
+
return (
|
|
1196
|
+
<group ref={rootRef}>
|
|
1197
|
+
{line && (
|
|
1198
|
+
<line geometry={line}>
|
|
1199
|
+
<lineBasicMaterial color="#4e9fb3" transparent opacity={0.8} depthWrite={false} />
|
|
1200
|
+
</line>
|
|
1201
|
+
)}
|
|
1202
|
+
{waypoints.map((waypoint, i) => {
|
|
1203
|
+
const active = waypoint.id === activeWaypointId;
|
|
1204
|
+
const color = active ? "#ffd76c" : "#4e9fb3";
|
|
1205
|
+
return (
|
|
1206
|
+
<group key={waypoint.id} position={[waypoint.x, 0.03, waypoint.z]}>
|
|
1207
|
+
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
|
1208
|
+
<ringGeometry args={[0.13, 0.19, 28]} />
|
|
1209
|
+
<meshBasicMaterial color={color} depthWrite={false} />
|
|
1210
|
+
</mesh>
|
|
1211
|
+
<Text
|
|
1212
|
+
position={[0, 0.02, 0.34]}
|
|
1213
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
1214
|
+
fontSize={0.24}
|
|
1215
|
+
color={color}
|
|
1216
|
+
anchorX="center"
|
|
1217
|
+
anchorY="middle"
|
|
1218
|
+
outlineWidth={0.03}
|
|
1219
|
+
outlineColor="#1c1a17"
|
|
1220
|
+
outlineOpacity={0.85}
|
|
1221
|
+
>
|
|
1222
|
+
{String(i + 1)}
|
|
1223
|
+
</Text>
|
|
1224
|
+
</group>
|
|
1225
|
+
);
|
|
1226
|
+
})}
|
|
1227
|
+
</group>
|
|
1228
|
+
);
|
|
1229
|
+
}
|