cozyclay 2.0.2 → 2.1.1
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 +61 -0
- package/README.md +17 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/bin/agent/agent-routes.mjs +211 -36
- 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 +4 -4
- package/bin/live/cli.mjs +23 -8
- package/bin/live/client.mjs +1 -1
- package/bin/live/studio.mjs +5 -2
- package/bin/mcp-runtime.mjs +2 -2
- package/bin/telemetry-state.mjs +3 -3
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-C9pMpW7s.js +1 -0
- package/dist/assets/app-B_2XB9rw.css +1 -0
- package/dist/assets/app-DAuvKPP8.js +4920 -0
- package/dist/assets/{demo-ByeYp-dY.js → demo-CvKuBgy5.js} +1 -1
- package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-msLI71g1.js} +1 -1
- package/dist/assets/{landing-BaXkpFip.js → landing-ChUvB_zL.js} +1 -1
- package/dist/assets/scene-asset-sync-DKkpjKBV.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-BPk2Ni95.js} +1 -1
- package/dist/assets/workflow-jJRBR94Z.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +328 -222
- package/dist/llms.txt +14 -0
- package/dist/sitemap.xml +1 -1
- 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 +813 -278
- 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 +184 -6
- package/src/ardy/timeline.jsx +121 -36
- package/src/ardy/to-cskel27.js +0 -2
- package/src/ardy/waypoints.js +0 -3
- 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 +6 -7
- 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 +15 -2
- 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 +79 -28
- 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-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 +49 -4
- 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 +58 -9
- 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 +60 -16
- package/src/shell/CompositionGuideButton.jsx +20 -0
- package/src/shell/DetailsSlot.jsx +26 -6
- package/src/shell/MenuBar.jsx +78 -6
- package/src/shell/OutlinerSlot.jsx +3 -1
- package/src/shell/ShotPreviewHeader.jsx +17 -0
- package/src/shell/TopBar.jsx +18 -3
- package/src/shell/ViewportToolbar.jsx +36 -31
- 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/shell/viewport.css +12 -0
- 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 +213 -23
- package/src/studio-agent-context.js +11 -9
- package/src/studio-agent-motion.js +2 -1
- package/src/studio-agent-protocol.js +60 -24
- package/src/studio-app-binding.js +56 -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 +92 -9
- 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 +106 -18
- 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/gt-render/browser.mjs +23 -0
- package/tools/headless-studio.mjs +85 -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 +95 -7
- package/dist/assets/analytics-MVZOEW8O.js +0 -1
- package/dist/assets/app-BxBLC6GT.js +0 -4876
- package/dist/assets/app-CJdxvnrl.css +0 -1
- package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
- package/dist/assets/workflow-B5XZwVfe.js +0 -179
package/src/app-stage.jsx
CHANGED
|
@@ -4,2487 +4,144 @@
|
|
|
4
4
|
// extraction safe. Behavior-identical by construction; verified by the build
|
|
5
5
|
// and the browser QA suites.
|
|
6
6
|
import {
|
|
7
|
-
memo,
|
|
8
|
-
useEffect,
|
|
9
|
-
useMemo,
|
|
10
7
|
useRef,
|
|
11
8
|
useState,
|
|
12
9
|
} from "react";
|
|
13
|
-
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
|
14
|
-
import { Line, Text, useFBX } from "@react-three/drei";
|
|
15
|
-
import * as THREE from "three";
|
|
16
|
-
import { SkeletonUtils } from "three/examples/jsm/Addons.js";
|
|
17
|
-
import { addFacingMarks } from "./facing-marks.js";
|
|
18
|
-
import { retimeMotion } from "./ardy/retime.js";
|
|
19
|
-
import {
|
|
20
|
-
TRAIL_EFFECTOR_JOINTS,
|
|
21
|
-
TRAIL_TRACKS,
|
|
22
|
-
falloffWeight,
|
|
23
|
-
jointTrailPoints,
|
|
24
|
-
trailEditRange,
|
|
25
|
-
} from "./motion-trail.js";
|
|
26
|
-
import { pickTrailPoint } from "./trail-pick.js";
|
|
27
|
-
import Timeline from "./ardy/timeline.jsx";
|
|
28
|
-
import { FlyControls, aimAt } from "./controls.jsx";
|
|
29
|
-
import { GIZMO_LAYER } from "./dualview.jsx";
|
|
30
|
-
import {
|
|
31
|
-
CURVE_GRAB_RADIUS_PX,
|
|
32
|
-
LINE_EDIT_TRACK_IDS,
|
|
33
|
-
PINNED_CURVE_ENDS,
|
|
34
|
-
curveToPoints2d,
|
|
35
|
-
validateLineEdit,
|
|
36
|
-
} from "./line-edit.js";
|
|
37
|
-
import { craneHeightAt, railPoint } from "./camera-follow.js";
|
|
38
|
-
import { CUTOUT_KIND, DEFAULT_SCENE_OBJECTS, MESH_KIND, SCENE_ATTACH_BONES, updateSceneObject } from "./scene-objects.js";
|
|
39
10
|
import { imageFilesFrom } from "./scene-assets.js";
|
|
40
11
|
import { splitDroppedFiles } from "./scene-mesh.js";
|
|
41
|
-
import {
|
|
42
|
-
SCENES_QUARANTINE_KEY,
|
|
43
|
-
createSceneDocument,
|
|
44
|
-
DEFAULT_SUBJECT_ONE,
|
|
45
|
-
DEFAULT_SUBJECT_TWO,
|
|
46
|
-
loadSceneDocumentFromStorage,
|
|
47
|
-
} from "./scenes.js";
|
|
48
|
-
import ObjectGizmo from "./object-gizmo.jsx";
|
|
49
|
-
import { MAX_PATH_POINTS } from "./object-path.js";
|
|
50
|
-
import { track } from "./analytics.js";
|
|
51
|
-
import { ko, isKo } from "./locale.js";
|
|
52
|
-
import { isPlaygroundEmbed, takePlaygroundProject } from "./playground.js";
|
|
53
|
-
import { applyPartColours } from "./part-colours.js";
|
|
54
|
-
import { POSE_BONES, applyHipsOffset, applyPose, primeBindPose, normalizeBoneName } from "./poses.js";
|
|
55
|
-
import { FK_TRACKS, IK_TRACKS, MID_TRACKS } from "./ardy/ik.js";
|
|
56
|
-
import { RENDER_ACTIVITY_EVENT } from "./use-render-activity.js";
|
|
57
|
-
import { CUSTOM_MOVE, SHOT_ASPECT_RATIOS, SUBJECT_HEIGHT_M } from "./shot.js";
|
|
58
|
-
import { sampleAt } from "./sample-at.js";
|
|
59
|
-
import { shotAtFrame } from "./cuts.js";
|
|
60
|
-
|
|
61
|
-
// Stated the way a crew states a setup: how far back, which side, how high the
|
|
62
|
-
// lens rides, and what glass is on it. Order matters — Medium is the setup a
|
|
63
|
-
// director reaches for first, so it leads.
|
|
64
|
-
export const PRESETS = {
|
|
65
|
-
medium: { label: ko("Medium", "미디엄"), distance: 2.6, azimuth: 22, elevation: 6, fov: 45, targetY: 1.35, two: false },
|
|
66
|
-
wide: { label: ko("Wide", "와이드"), distance: 7, azimuth: 25, elevation: 4, fov: 38, targetY: 1.2, two: false },
|
|
67
|
-
closeup: { label: ko("Close-Up", "클로즈업"), distance: 1.3, azimuth: 16, elevation: 2, fov: 45, targetY: 1.55, two: false },
|
|
68
|
-
low: { label: ko("Low Angle", "로우 앵글"), distance: 3.5, azimuth: 20, elevation: -14, fov: 50, targetY: 1.1, two: false },
|
|
69
|
-
high: { label: ko("High Angle", "하이 앵글"), distance: 4.5, azimuth: 20, elevation: 16, fov: 45, targetY: 1.1, two: false },
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
export const RIG_HIERARCHY_FOCUS = {
|
|
73
|
-
"rig.torso": "spine",
|
|
74
|
-
"rig.hips": "hips",
|
|
75
|
-
"rig.spine": "spine",
|
|
76
|
-
"rig.chest": "chest",
|
|
77
|
-
"rig.neck": "neck",
|
|
78
|
-
"rig.head": "head",
|
|
79
|
-
"rig.leftArm": "leftHand",
|
|
80
|
-
"rig.leftShoulder": "leftShoulder",
|
|
81
|
-
"rig.leftElbow": "leftElbow",
|
|
82
|
-
"rig.leftHand": "leftHand",
|
|
83
|
-
"rig.rightArm": "rightHand",
|
|
84
|
-
"rig.rightShoulder": "rightShoulder",
|
|
85
|
-
"rig.rightElbow": "rightElbow",
|
|
86
|
-
"rig.rightHand": "rightHand",
|
|
87
|
-
"rig.leftLeg": "leftFoot",
|
|
88
|
-
"rig.leftKnee": "leftKnee",
|
|
89
|
-
"rig.leftFoot": "leftFoot",
|
|
90
|
-
"rig.rightLeg": "rightFoot",
|
|
91
|
-
"rig.rightKnee": "rightKnee",
|
|
92
|
-
"rig.rightFoot": "rightFoot",
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
export const HIERARCHY_INSPECTOR_TITLES = {
|
|
96
|
-
shot: ko("Shot settings", "샷 설정"),
|
|
97
|
-
camera: ko("Camera", "카메라"),
|
|
98
|
-
light: ko("Light", "조명"),
|
|
99
|
-
characters: ko("Characters", "인물"),
|
|
100
|
-
characterA: ko("Character 1", "인물 1"),
|
|
101
|
-
rig: ko("Rig", "리그"),
|
|
102
|
-
characterB: ko("Character 2", "인물 2"),
|
|
103
|
-
environment: ko("Environment", "환경"),
|
|
104
|
-
props: ko("Props", "소품"),
|
|
105
|
-
"rig.torso": ko("Torso", "몸통"),
|
|
106
|
-
"rig.hips": ko("Root / Hips", "루트 / 엉덩이"),
|
|
107
|
-
"rig.spine": ko("Spine", "척추"),
|
|
108
|
-
"rig.chest": ko("Chest", "가슴"),
|
|
109
|
-
"rig.neck": ko("Neck", "목"),
|
|
110
|
-
"rig.head": ko("Head", "머리"),
|
|
111
|
-
"rig.leftArm": ko("Left Arm", "왼팔"),
|
|
112
|
-
"rig.leftShoulder": ko("Left Shoulder", "왼쪽 어깨"),
|
|
113
|
-
"rig.leftElbow": ko("Left Elbow", "왼쪽 팔꿈치"),
|
|
114
|
-
"rig.leftHand": ko("Left Hand", "왼손"),
|
|
115
|
-
"rig.rightArm": ko("Right Arm", "오른팔"),
|
|
116
|
-
"rig.rightShoulder": ko("Right Shoulder", "오른쪽 어깨"),
|
|
117
|
-
"rig.rightElbow": ko("Right Elbow", "오른쪽 팔꿈치"),
|
|
118
|
-
"rig.rightHand": ko("Right Hand", "오른손"),
|
|
119
|
-
"rig.leftLeg": ko("Left Leg", "왼다리"),
|
|
120
|
-
"rig.leftKnee": ko("Left Knee", "왼쪽 무릎"),
|
|
121
|
-
"rig.leftFoot": ko("Left Foot", "왼발"),
|
|
122
|
-
"rig.rightLeg": ko("Right Leg", "오른다리"),
|
|
123
|
-
"rig.rightKnee": ko("Right Knee", "오른쪽 무릎"),
|
|
124
|
-
"rig.rightFoot": ko("Right Foot", "오른발"),
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
/* ----------------------------------------- carried props (attachment) --- */
|
|
128
|
-
|
|
129
|
-
/** The rig rows that ARE an attach frame: `rig.<track id>`, the same camel
|
|
130
|
-
* spellings SCENE_ATTACH_BONES uses. The group rows above them (rig.torso,
|
|
131
|
-
* rig.leftArm …) name a limb, not a frame, so they are deliberately absent. */
|
|
132
|
-
export const ATTACH_BONE_ROWS = new Map(SCENE_ATTACH_BONES.map((bone) => [`rig.${bone}`, bone]));
|
|
133
|
-
|
|
134
|
-
/** Attach track id -> the Mixamo bone carrying that frame on the shipped rigs.
|
|
135
|
-
* TRAIL_EFFECTOR_JOINTS names the cskel27 joint, and cskel27 and Mixamo agree
|
|
136
|
-
* everywhere except the spine, which sits one bone up the Mixamo chain (the
|
|
137
|
-
* same shift ardy/playback.js SKINNING_MAP makes). */
|
|
138
|
-
export const ATTACH_BONE_NAMES = Object.fromEntries(SCENE_ATTACH_BONES.map((bone) => {
|
|
139
|
-
const joint = TRAIL_EFFECTOR_JOINTS[bone];
|
|
140
|
-
return [bone, joint === "Spine1" ? "Spine" : joint === "Spine2" ? "Spine1" : joint];
|
|
141
|
-
}));
|
|
142
|
-
|
|
143
|
-
// One scratch set for the whole module: resolving a frame runs per attached
|
|
144
|
-
// prop per frame and must never allocate.
|
|
145
|
-
export const attachPos = new THREE.Vector3();
|
|
146
|
-
export const attachQuat = new THREE.Quaternion();
|
|
147
|
-
export const attachScale = new THREE.Vector3();
|
|
148
|
-
export const attachRootPos = new THREE.Vector3();
|
|
149
|
-
export const attachRootQuat = new THREE.Quaternion();
|
|
150
|
-
export const ATTACH_UNIT_SCALE = new THREE.Vector3(1, 1, 1);
|
|
151
|
-
export const attachWorldMatrix = new THREE.Matrix4();
|
|
152
|
-
export const attachLocalMatrix = new THREE.Matrix4();
|
|
153
|
-
export const attachToMatrix = new THREE.Matrix4();
|
|
154
|
-
export const attachInverseMatrix = new THREE.Matrix4();
|
|
155
|
-
export const placePos = new THREE.Vector3();
|
|
156
|
-
export const placeQuat = new THREE.Quaternion();
|
|
157
|
-
export const placeScale = new THREE.Vector3();
|
|
158
|
-
export const placeEuler = new THREE.Euler();
|
|
159
|
-
|
|
160
|
-
/** The bone a rig offers under `mixamoName`, by the project's matching rule
|
|
161
|
-
* (normalised names equal, or one a suffix of the other; first depth-first
|
|
162
|
-
* match wins — poses.js and ardy/playback.js agree). Cached per rig: this is
|
|
163
|
-
* asked once per attached prop per rendered frame. */
|
|
164
|
-
export const attachBoneCache = new WeakMap();
|
|
165
|
-
export function attachBoneOf(rig, mixamoName) {
|
|
166
|
-
let byName = attachBoneCache.get(rig);
|
|
167
|
-
if (!byName) attachBoneCache.set(rig, (byName = new Map()));
|
|
168
|
-
if (byName.has(mixamoName)) return byName.get(mixamoName);
|
|
169
|
-
const target = normalizeBoneName(mixamoName);
|
|
170
|
-
let found = null;
|
|
171
|
-
rig.traverse((node) => {
|
|
172
|
-
if (found || !node.isBone) return;
|
|
173
|
-
const norm = normalizeBoneName(node.name);
|
|
174
|
-
if (norm === target || norm.endsWith(target)) found = node;
|
|
175
|
-
});
|
|
176
|
-
byName.set(mixamoName, found);
|
|
177
|
-
return found;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* The LIVE world frame an attachment rides, or null when the rig (or that bone)
|
|
182
|
-
* is not there — a dangling attachment then renders where its numbers say,
|
|
183
|
-
* which is exactly what a detached prop does.
|
|
184
|
-
*
|
|
185
|
-
* The frame is deliberately UNSCALED. The shipped rigs are Mixamo centimetres
|
|
186
|
-
* scaled by 0.01 on the model, so a raw bone matrix would push a hundredfold
|
|
187
|
-
* into every carried prop's local numbers and leave the Inspector unreadable.
|
|
188
|
-
* A carried prop takes the bone's place and facing; its size stays its own.
|
|
189
|
-
*
|
|
190
|
-
* `bone === null` is the character's animated ROOT: the hips' travel (during
|
|
191
|
-
* playback the whole root trajectory lives in the bones — see
|
|
192
|
-
* ardy/playback.js) carried at the character's own yaw, so a prop dropped on
|
|
193
|
-
* the character walks with the body instead of rolling with the hips.
|
|
194
|
-
*/
|
|
195
|
-
export function attachFrameMatrix(rig, bone, out = new THREE.Matrix4()) {
|
|
196
|
-
if (!rig) return null;
|
|
197
|
-
const name = ATTACH_BONE_NAMES[bone ?? "hips"];
|
|
198
|
-
if (!name) return null;
|
|
199
|
-
const node = attachBoneOf(rig, name);
|
|
200
|
-
if (!node) return null;
|
|
201
|
-
// Ancestors included: the bones are written imperatively by playback and by
|
|
202
|
-
// the export, either of which can land before the renderer's own pass.
|
|
203
|
-
node.updateWorldMatrix(true, false);
|
|
204
|
-
node.matrixWorld.decompose(attachPos, attachQuat, attachScale);
|
|
205
|
-
if (bone != null) return out.compose(attachPos, attachQuat, ATTACH_UNIT_SCALE);
|
|
206
|
-
// The Character group carries the scene placement and the clip-to-scene yaw;
|
|
207
|
-
// the rig model hangs off it, so its parent IS that group.
|
|
208
|
-
const group = rig.parent ?? rig;
|
|
209
|
-
group.updateWorldMatrix(true, false);
|
|
210
|
-
group.matrixWorld.decompose(attachRootPos, attachRootQuat, attachScale);
|
|
211
|
-
return out.compose(attachPos, attachRootQuat, ATTACH_UNIT_SCALE);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/** A prop's placement as a matrix — the same compose props.jsx renders from
|
|
215
|
-
* (position, XYZ euler in degrees, per-axis scale). */
|
|
216
|
-
export function sceneObjectMatrix(object, out = new THREE.Matrix4()) {
|
|
217
|
-
return out.compose(
|
|
218
|
-
placePos.set(object.x ?? 0, object.y ?? 0, object.z ?? 0),
|
|
219
|
-
placeQuat.setFromEuler(placeEuler.set(
|
|
220
|
-
(object.rotX ?? 0) * THREE.MathUtils.DEG2RAD,
|
|
221
|
-
(object.rot ?? 0) * THREE.MathUtils.DEG2RAD,
|
|
222
|
-
(object.rotZ ?? 0) * THREE.MathUtils.DEG2RAD,
|
|
223
|
-
)),
|
|
224
|
-
placeScale.set(object.scaleX ?? 1, object.scaleY ?? 1, object.scaleZ ?? 1),
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/** …and back out into the nine authored channels, or null if the matrix could
|
|
229
|
-
* not be read as a placement at all (a singular frame inverts to zeros, and a
|
|
230
|
-
* zero matrix decomposes to NaN). Rounded, because a matrix round trip
|
|
231
|
-
* otherwise turns a typed 1.5 into 1.4999999999999998 in the Inspector the
|
|
232
|
-
* moment a prop is picked up and put down again. */
|
|
233
|
-
export function sceneObjectPlacement(matrix) {
|
|
234
|
-
matrix.decompose(placePos, placeQuat, placeScale);
|
|
235
|
-
placeEuler.setFromQuaternion(placeQuat);
|
|
236
|
-
const round = (value) => Math.round(value * 1e6) / 1e6;
|
|
237
|
-
const placement = {
|
|
238
|
-
x: round(placePos.x), y: round(placePos.y), z: round(placePos.z),
|
|
239
|
-
rotX: round(placeEuler.x * THREE.MathUtils.RAD2DEG),
|
|
240
|
-
rot: round(placeEuler.y * THREE.MathUtils.RAD2DEG),
|
|
241
|
-
rotZ: round(placeEuler.z * THREE.MathUtils.RAD2DEG),
|
|
242
|
-
scaleX: round(placeScale.x), scaleY: round(placeScale.y), scaleZ: round(placeScale.z),
|
|
243
|
-
};
|
|
244
|
-
return Object.values(placement).every((value) => Number.isFinite(value)) ? placement : null;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* The nine channels rewritten so a prop does not MOVE when it changes frames.
|
|
249
|
-
*
|
|
250
|
-
* `world` is where the prop actually IS — read off the live group in the scene
|
|
251
|
-
* graph, NOT recomputed from its numbers. That distinction is the whole point:
|
|
252
|
-
* an attach->attach drop would otherwise have to reconstruct the world
|
|
253
|
-
* transform through the frame the prop is leaving, and any disagreement
|
|
254
|
-
* between that reconstruction and what the renderer last drew (a rig posed a
|
|
255
|
-
* beat later than the last drawn frame, a frame that no longer resolves) lands
|
|
256
|
-
* on screen as a jump. Reading the group makes every drop the same one-frame
|
|
257
|
-
* conversion the world->attach and attach->world cases already were.
|
|
258
|
-
*
|
|
259
|
-
* Returns null when the frame it needs is missing — converting against a rig
|
|
260
|
-
* that is not on stage would be precisely the jump this exists to prevent —
|
|
261
|
-
* and the caller then refuses the whole drop rather than re-labelling numbers
|
|
262
|
-
* it could not convert.
|
|
263
|
-
*/
|
|
264
|
-
export function attachPlacementPatch(world, attach, frameOf) {
|
|
265
|
-
if (!world) return null;
|
|
266
|
-
if (!attach) return sceneObjectPlacement(attachLocalMatrix.copy(world));
|
|
267
|
-
const frame = frameOf(attach.characterId, attach.bone ?? null, attachToMatrix);
|
|
268
|
-
if (!frame) return null;
|
|
269
|
-
return sceneObjectPlacement(attachLocalMatrix.copy(world).premultiply(attachInverseMatrix.copy(frame).invert()));
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Write a computed placement straight onto the record, around
|
|
274
|
-
* updateSceneObject. Deliberate, and the only call site allowed to: those
|
|
275
|
-
* limits are WORLD limits (y floors at the deck, x/z stay inside the room),
|
|
276
|
-
* and while a prop is attached its numbers are a LOCAL transform in a bone
|
|
277
|
-
* frame — a hand rides 1.3 m above the deck, so a carried prop's local y is
|
|
278
|
-
* routinely negative and clamping it to the floor IS the visual jump the
|
|
279
|
-
* conversion exists to prevent. Every value here comes out of a decomposed
|
|
280
|
-
* matrix, so the record shape is sound by construction.
|
|
281
|
-
*/
|
|
282
|
-
export function placeSceneObject(objects, id, placement) {
|
|
283
|
-
if (!placement) return objects;
|
|
284
|
-
let changed = false;
|
|
285
|
-
const next = objects.map((object) => {
|
|
286
|
-
if (object.id !== id) return object;
|
|
287
|
-
if (Object.entries(placement).every(([key, value]) => object[key] === value)) return object;
|
|
288
|
-
changed = true;
|
|
289
|
-
return { ...object, ...placement };
|
|
290
|
-
});
|
|
291
|
-
return changed ? next : objects;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
export const CAMERA_MOVE_LABELS_KO = new Map([
|
|
295
|
-
["Static / locked-off", ko("Static / locked-off", "고정 샷")],
|
|
296
|
-
["Zoom in", ko("Zoom in", "줌 인")],
|
|
297
|
-
["Zoom out", ko("Zoom out", "줌 아웃")],
|
|
298
|
-
["Push-in (dolly in)", ko("Push-in (dolly in)", "푸시인(돌리 인)")],
|
|
299
|
-
["Pull-out (dolly out)", ko("Pull-out (dolly out)", "풀아웃(돌리 아웃)")],
|
|
300
|
-
["Pan left", ko("Pan left", "왼쪽 팬")],
|
|
301
|
-
["Pan right", ko("Pan right", "오른쪽 팬")],
|
|
302
|
-
["Tilt up", ko("Tilt up", "틸트 업")],
|
|
303
|
-
["Tilt down", ko("Tilt down", "틸트 다운")],
|
|
304
|
-
["Tracking / follow", ko("Tracking / follow", "트래킹 / 팔로우")],
|
|
305
|
-
["Orbit / arc", ko("Orbit / arc", "오빗 / 아크")],
|
|
306
|
-
["Crane up", ko("Crane up", "크레인 업")],
|
|
307
|
-
["Crane down", ko("Crane down", "크레인 다운")],
|
|
308
|
-
["Handheld", ko("Handheld", "핸드헬드")],
|
|
309
|
-
["Crash zoom in", ko("Crash zoom in", "크래시 줌 인")],
|
|
310
|
-
["Dolly-zoom (vertigo)", ko("Dolly-zoom (vertigo)", "돌리 줌(버티고)")],
|
|
311
|
-
["Whip pan", ko("Whip pan", "휩 팬")],
|
|
312
|
-
["Aerial / drone", ko("Aerial / drone", "공중 / 드론")],
|
|
313
|
-
[CUSTOM_MOVE, ko(CUSTOM_MOVE, "직접 입력…")],
|
|
314
|
-
]);
|
|
315
|
-
|
|
316
|
-
export const POSE_LABELS_KO = new Map([
|
|
317
|
-
["T-pose", ko("T-pose", "T 포즈")],
|
|
318
|
-
["Relaxed", ko("Relaxed", "편안한 자세")],
|
|
319
|
-
["Contrapposto", ko("Contrapposto", "콘트라포스토")],
|
|
320
|
-
["Walking", ko("Walking", "걷는 자세")],
|
|
321
|
-
["Seated", ko("Seated", "앉은 자세")],
|
|
322
|
-
["Arms crossed", ko("Arms crossed", "팔짱")],
|
|
323
|
-
["Pointing", ko("Pointing", "가리키기")],
|
|
324
|
-
["Hands on hips", ko("Hands on hips", "허리에 손")],
|
|
325
|
-
["Looking back", ko("Looking back", "뒤돌아보기")],
|
|
326
|
-
["Hands up", ko("Hands up", "손 올리기")],
|
|
327
|
-
]);
|
|
328
|
-
|
|
329
|
-
export const SHOT_SIZE_LABELS_KO = new Map([
|
|
330
|
-
["extreme close-up", ko("extreme close-up", "익스트림 클로즈업")],
|
|
331
|
-
["close-up", ko("close-up", "클로즈업")],
|
|
332
|
-
["medium close-up", ko("medium close-up", "미디엄 클로즈업")],
|
|
333
|
-
["medium shot", ko("medium shot", "미디엄 샷")],
|
|
334
|
-
["medium-wide shot", ko("medium-wide shot", "미디엄 와이드 샷")],
|
|
335
|
-
["wide shot", ko("wide shot", "와이드 샷")],
|
|
336
|
-
["extreme wide shot", ko("extreme wide shot", "익스트림 와이드 샷")],
|
|
337
|
-
]);
|
|
338
|
-
|
|
339
|
-
export const SHOT_LEVEL_LABELS_KO = new Map([
|
|
340
|
-
["overhead", ko("overhead", "오버헤드")],
|
|
341
|
-
["high angle", ko("high angle", "하이 앵글")],
|
|
342
|
-
["eye level", ko("eye level", "아이 레벨")],
|
|
343
|
-
["chest level", ko("chest level", "가슴 높이")],
|
|
344
|
-
["hip level", ko("hip level", "엉덩이 높이")],
|
|
345
|
-
["knee level", ko("knee level", "무릎 높이")],
|
|
346
|
-
["ground level", ko("ground level", "바닥 높이")],
|
|
347
|
-
]);
|
|
348
|
-
|
|
349
|
-
export const SCENE_RENDERER_LABELS_KO = new Map([
|
|
350
|
-
["cube", ko("cube", "큐브")],
|
|
351
|
-
["sphere", ko("sphere", "구")],
|
|
352
|
-
["capsule", ko("capsule", "캡슐")],
|
|
353
|
-
["cylinder", ko("cylinder", "원기둥")],
|
|
354
|
-
["cone", ko("cone", "원뿔")],
|
|
355
|
-
["plane", ko("plane", "평면")],
|
|
356
|
-
["chair", ko("chair", "의자")],
|
|
357
|
-
["car", ko("car", "자동차")],
|
|
358
|
-
["aircraft", ko("aircraft", "비행기")],
|
|
359
|
-
[CUTOUT_KIND, ko("cutout", "컷아웃")],
|
|
360
|
-
[MESH_KIND, ko("Mesh", "모델")],
|
|
361
|
-
]);
|
|
362
|
-
|
|
363
|
-
export const SCENE_OBJECT_NAME_LABELS_KO = new Map([
|
|
364
|
-
["Cube", ko("Cube", "큐브")],
|
|
365
|
-
["Sphere", ko("Sphere", "구")],
|
|
366
|
-
["Capsule", ko("Capsule", "캡슐")],
|
|
367
|
-
["Cylinder", ko("Cylinder", "원기둥")],
|
|
368
|
-
["Cone", ko("Cone", "원뿔")],
|
|
369
|
-
["Plane", ko("Plane", "평면")],
|
|
370
|
-
["Chair", ko("Chair", "의자")],
|
|
371
|
-
["Car", ko("Car", "자동차")],
|
|
372
|
-
["Plane (aircraft)", ko("Plane (aircraft)", "비행기")],
|
|
373
|
-
["Mesh", ko("Mesh", "모델")],
|
|
374
|
-
["Model", ko("Model", "모델")],
|
|
375
|
-
]);
|
|
376
|
-
|
|
377
|
-
export function poseLabelKo(pose) {
|
|
378
|
-
return pose?.custom ? pose.label : POSE_LABELS_KO.get(pose?.label) ?? pose?.label ?? "";
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
export function cameraMoveLabelKo(move) {
|
|
382
|
-
return CAMERA_MOVE_LABELS_KO.get(move) ?? move;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
export function sceneRendererLabelKo(renderer) {
|
|
386
|
-
return SCENE_RENDERER_LABELS_KO.get(renderer) ?? renderer;
|
|
387
|
-
}
|
|
12
|
+
import { ko } from "./locale.js";
|
|
388
13
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
export
|
|
441
|
-
|
|
442
|
-
export
|
|
443
|
-
|
|
444
|
-
export
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
export const DEFAULT_SUBJECT = DEFAULT_SUBJECT_ONE;
|
|
520
|
-
export const DEFAULT_SUBJECT2 = DEFAULT_SUBJECT_TWO;
|
|
521
|
-
export const DEFAULT_ENVIRONMENT = "a sunlit modern living room";
|
|
522
|
-
export const DEFAULT_CAMERA_POSITION = { x: 0.97, y: 1.62, z: 2.39 };
|
|
523
|
-
export const REST_BONES = Object.fromEntries(POSE_BONES.map((b) => [b.id, [0, 0, 0]]));
|
|
524
|
-
// Two clocks, one boundary. The app timeline runs the production 24 fps —
|
|
525
|
-
// the rate the reference footage and the recorded export are counted in.
|
|
526
|
-
// ARDY Core generates on its trained 20 fps clock and that cannot move, so
|
|
527
|
-
// takes are RETIMED inbound (bridge → app, retimeMotion) and frame numbers
|
|
528
|
-
// are CONVERTED outbound (app → bridge, toArdyFrame). Nothing between the
|
|
529
|
-
// boundaries may mix the clocks.
|
|
530
|
-
export const TIMELINE_FPS = 24;
|
|
531
|
-
// The motion bridge uses the Studio's fixed 24 fps production clock.
|
|
532
|
-
export const ARDY_FPS = 24;
|
|
533
|
-
export const toArdyFrame = (frame) => Math.round((frame * ARDY_FPS) / TIMELINE_FPS);
|
|
534
|
-
|
|
535
|
-
// Outbound converters: timeline-frame entries → strictly-ascending bridge
|
|
536
|
-
// frames. Rounding can land two timeline frames on one bridge frame; the
|
|
537
|
-
// first wins — the bridge refuses non-ascending lists outright.
|
|
538
|
-
/** Where a placed pose lands, in TIMELINE frames, for a clip of `clipFrames`. */
|
|
539
|
-
export function posePlacementFrame(placement, clipFrames, playheadFrame) {
|
|
540
|
-
const last = Math.max(0, clipFrames - 1);
|
|
541
|
-
if (placement === "end") return last;
|
|
542
|
-
if (placement === "middle") return Math.round(last / 2);
|
|
543
|
-
if (placement === "playhead") return Math.max(0, Math.min(last, playheadFrame));
|
|
544
|
-
return 0;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
export function toArdyFrameEntries(entries) {
|
|
548
|
-
const out = [];
|
|
549
|
-
for (const entry of entries) {
|
|
550
|
-
const frame = toArdyFrame(entry.frame);
|
|
551
|
-
if (out.length && frame <= out[out.length - 1].frame) continue;
|
|
552
|
-
out.push({ ...entry, frame });
|
|
553
|
-
}
|
|
554
|
-
return out;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
export function toArdySegments(segments) {
|
|
558
|
-
const out = [];
|
|
559
|
-
for (const segment of segments) {
|
|
560
|
-
const startFrame = toArdyFrame(segment.startFrame);
|
|
561
|
-
const endFrame = toArdyFrame(segment.endFrame);
|
|
562
|
-
if (endFrame <= startFrame) continue; // a zero-length rounding remnant covers nothing
|
|
563
|
-
out.push({ ...segment, startFrame, endFrame });
|
|
564
|
-
}
|
|
565
|
-
return out;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
export const DEFAULT_DURATION_S = 15; // pre-motion timeline duration; shown as duration × TIMELINE_FPS frames
|
|
569
|
-
export const DEFAULT_PLAYBACK_SPEED = 1;
|
|
570
|
-
export const BRIDGE_RECHECK_MS = 3000;
|
|
571
|
-
export const ARDY_PROMPT_HORIZON_FRAMES = 2 * TIMELINE_FPS; // core model horizon: 2 seconds, counted on the timeline clock
|
|
572
|
-
export const MAX_WAYPOINTS = 32; // ARDY bridge contract: a root path holds 2..32 distinct waypoint frames
|
|
573
|
-
export const ARDY_PROMPT_MAX = 500; // bridge contract: prompt must be non-empty, capped at 500 chars
|
|
574
|
-
export const ARDY_DURATION_MIN = 1; // the UI works in whole seconds; the bridge floor is 0.15 s
|
|
575
|
-
export const ARDY_DURATION_MAX = 1200; // bridge contract: duration capped at 1200 s
|
|
576
|
-
export const ARDY_SEED_MAX = 2 ** 31 - 1; // bridge contract: optional seed, integer in 0..2**31-1
|
|
577
|
-
// Scheduled inpainting (contract C3): how hard a regeneration holds onto the
|
|
578
|
-
// take that is already loaded. 0 turns preserving OFF entirely; 0.5 is the
|
|
579
|
-
// paper's recommended setting. The app ships this RAW number and nothing else:
|
|
580
|
-
// the strength -> denoising-schedule mapping (sigma_s = round(1000 * strength),
|
|
581
|
-
// sigma_e = min(50, sigma_s), diffusion time 0..1000) lives on the box side, and
|
|
582
|
-
// duplicating it here is exactly how the two would silently drift apart.
|
|
583
|
-
export const ARDY_PRESERVE_DEFAULT = 0.5;
|
|
584
|
-
export const DEFAULT_PROMPT_CLIPS = [];
|
|
585
|
-
|
|
586
|
-
/** The prompt-block schedule: every authored block in frame order, with the gaps
|
|
587
|
-
* between them filled by the base prompt so the bridge always receives one
|
|
588
|
-
* contiguous 0..clipFrames sequence. Blocks shorter than 3 frames are dropped —
|
|
589
|
-
* they cannot carry a generation.
|
|
590
|
-
* Pure and shared on purpose: the request assembly decides which blocks count as
|
|
591
|
-
* "edited", and the preserve panel has to name the SAME blocks. Re-deriving them
|
|
592
|
-
* approximately beside the slider is how the UI starts promising a locality the
|
|
593
|
-
* wire does not carry. */
|
|
594
|
-
export function buildPromptSchedule(sourcePromptClips, clipFrames, prompt) {
|
|
595
|
-
const segments = [];
|
|
596
|
-
let cursor = 0;
|
|
597
|
-
for (const clip of sourcePromptClips) {
|
|
598
|
-
const startFrame = Math.max(cursor, Math.min(clipFrames, clip.startFrame));
|
|
599
|
-
const endFrame = Math.max(startFrame, Math.min(clipFrames, clip.endFrame));
|
|
600
|
-
if (startFrame > cursor) segments.push({ startFrame: cursor, endFrame: startFrame, prompt });
|
|
601
|
-
if (endFrame - startFrame >= 3) segments.push({ startFrame, endFrame, prompt: clip.text.trim() || prompt });
|
|
602
|
-
cursor = Math.max(cursor, endFrame);
|
|
603
|
-
if (cursor >= clipFrames) break;
|
|
604
|
-
}
|
|
605
|
-
if (cursor < clipFrames) segments.push({ startFrame: cursor, endFrame: clipFrames, prompt });
|
|
606
|
-
if (segments.length === 0) segments.push({ startFrame: 0, endFrame: clipFrames, prompt });
|
|
607
|
-
return segments;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
/** The DISTINCT ik track ids keyed inside the half-open timeline span
|
|
611
|
-
* [startFrame, endFrame). `ikState.keys` is Map(frame -> Map(trackId -> pose)),
|
|
612
|
-
* so a frame with several dragged effectors contributes all of them. */
|
|
613
|
-
export function ikTracksInRange(ikState, ikFrames, startFrame, endFrame) {
|
|
614
|
-
const tracks = new Set();
|
|
615
|
-
for (const frame of ikFrames) {
|
|
616
|
-
if (frame < startFrame || frame >= endFrame) continue;
|
|
617
|
-
for (const trackId of ikState?.keys?.get(frame)?.keys() ?? []) tracks.add(trackId);
|
|
618
|
-
}
|
|
619
|
-
return [...tracks];
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
/* Which limb a keyed IK track frees once the preserve mask is grouped
|
|
623
|
-
(contract C3v2). The single source of truth for track -> mask group is
|
|
624
|
-
TRACK_GROUPS in tools/kimodo/preserve-mask.mjs; nothing under tools/ may be
|
|
625
|
-
imported into src/, so the rule is transcribed here — as USER-FACING LABELS
|
|
626
|
-
only, never as a second copy of the mapping the wire depends on:
|
|
627
|
-
leftHand, leftElbow -> leftArm rightHand, rightElbow -> rightArm
|
|
628
|
-
leftFoot, leftKnee -> leftLeg rightFoot, rightKnee -> rightLeg
|
|
629
|
-
head, neck -> head spine, chest, leftShoulder,
|
|
630
|
-
hips -> torso AND root rightShoulder -> torso
|
|
631
|
-
The track ids are the same namespace on both sides (src/ardy/ik.js owns
|
|
632
|
-
them), so the app ships whatever ids it has and the mask builder maps them.
|
|
633
|
-
Torso and pelvis tracks are worded as the whole body: the pelvis carries the
|
|
634
|
-
global transform as well as a bone, and naming a limb beside it would promise
|
|
635
|
-
a locality the mask does not have. */
|
|
636
|
-
export const PRESERVE_TRACK_LIMBS = {
|
|
637
|
-
leftHand: "leftArm",
|
|
638
|
-
leftElbow: "leftArm",
|
|
639
|
-
rightHand: "rightArm",
|
|
640
|
-
rightElbow: "rightArm",
|
|
641
|
-
leftFoot: "leftLeg",
|
|
642
|
-
leftKnee: "leftLeg",
|
|
643
|
-
rightFoot: "rightLeg",
|
|
644
|
-
rightKnee: "rightLeg",
|
|
645
|
-
head: "head",
|
|
646
|
-
neck: "head",
|
|
647
|
-
spine: "body",
|
|
648
|
-
chest: "body",
|
|
649
|
-
leftShoulder: "body",
|
|
650
|
-
rightShoulder: "body",
|
|
651
|
-
hips: "body",
|
|
652
|
-
};
|
|
653
|
-
// A fixed reading order, so the same edit always reads back the same way.
|
|
654
|
-
export const PRESERVE_LIMB_ORDER = ["head", "leftArm", "rightArm", "leftLeg", "rightLeg"];
|
|
655
|
-
export const preserveLimbLabel = (limb) => ({
|
|
656
|
-
head: ko("head", "머리"),
|
|
657
|
-
leftArm: ko("left arm", "왼팔"),
|
|
658
|
-
rightArm: ko("right arm", "오른팔"),
|
|
659
|
-
leftLeg: ko("left leg", "왼발"),
|
|
660
|
-
rightLeg: ko("right leg", "오른발"),
|
|
661
|
-
}[limb]);
|
|
662
|
-
|
|
663
|
-
/** One muted line naming what a grouped preserve run will regenerate, or "" when
|
|
664
|
-
* no track can be attributed — then the request carries no `tracks` either and
|
|
665
|
-
* the panel's existing whole-take wording is already the truth. */
|
|
666
|
-
export function preserveTracksSummary(tracks) {
|
|
667
|
-
const limbs = new Set();
|
|
668
|
-
for (const trackId of tracks) {
|
|
669
|
-
const limb = PRESERVE_TRACK_LIMBS[trackId];
|
|
670
|
-
if (limb) limbs.add(limb);
|
|
671
|
-
}
|
|
672
|
-
if (limbs.size === 0) return "";
|
|
673
|
-
if (limbs.has("body")) return ko("regenerates the whole body", "몸 전체를 다시 생성");
|
|
674
|
-
const names = PRESERVE_LIMB_ORDER.filter((limb) => limbs.has(limb)).map(preserveLimbLabel);
|
|
675
|
-
return isKo ? `${names.join("·")}만 다시 생성` : `regenerates ${names.join(" · ")} only`;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
/* --------------------------- line editing (C6) ---------------------------
|
|
679
|
-
* "Grab the joint's motion path and pull it; the joint follows the pulled path
|
|
680
|
-
* exactly" (ProjFlow). The arithmetic lives in ./line-edit.js; what stays here
|
|
681
|
-
* is the panel's own vocabulary and the one thing App owns that the pure module
|
|
682
|
-
* cannot — the mapping from a track id to a HUMAN LABEL, which is read straight
|
|
683
|
-
* out of ik.js's three tables so the picker can never drift from the pose
|
|
684
|
-
* studio's own naming. An id in LINE_EDIT_TRACK_IDS with no entry in those
|
|
685
|
-
* tables is a bug, and falling back to the raw id makes it visible instead of
|
|
686
|
-
* silent. */
|
|
687
|
-
export const LINE_EDIT_TRACK_OPTIONS = LINE_EDIT_TRACK_IDS.map((id) => ({
|
|
688
|
-
value: id,
|
|
689
|
-
label: [...IK_TRACKS, ...MID_TRACKS, ...FK_TRACKS].find((track) => track.id === id)?.label ?? id,
|
|
690
|
-
}));
|
|
691
|
-
/** The same human label, by id — what a version-strip chip says a refinement
|
|
692
|
-
* touched ("Refine · Left Hand"). */
|
|
693
|
-
export const lineTrackLabel = (id) => LINE_EDIT_TRACK_OPTIONS.find((option) => option.value === id)?.label ?? id;
|
|
694
|
-
/** The default edited track: a wrist is the joint the paper's own demo draws
|
|
695
|
-
* with, and the one whose screen trajectory a user can actually see. */
|
|
696
|
-
export const LINE_EDIT_DEFAULT_TRACK = "leftHand";
|
|
697
|
-
/** Draw a grab handle every Nth frame. Fewer would leave the curve looking
|
|
698
|
-
* un-grabbable in the gaps; more turns a 200-frame range into a solid bead of
|
|
699
|
-
* dots. The hit test tolerates CURVE_GRAB_RADIUS_PX regardless, so the handles
|
|
700
|
-
* are an affordance rather than the actual geometry. */
|
|
701
|
-
export const LINE_CURVE_MARKER_STRIDE = 4;
|
|
702
|
-
/** The shortest curve with anything draggable in it: both ends are hard-pinned
|
|
703
|
-
* to the original trajectory, so a range needs more than 2 x PINNED_CURVE_ENDS
|
|
704
|
-
* frames before the middle can move at all. */
|
|
705
|
-
export const MIN_CURVE_POINTS = PINNED_CURVE_ENDS * 2 + 1;
|
|
706
|
-
/** How long a released drag has to sit still before it costs a preview. Long
|
|
707
|
-
* enough that pull-pull-pull is one request instead of three, short enough that
|
|
708
|
-
* the loop still feels like it answers the gesture — a preview round trip is
|
|
709
|
-
* ~1 s on the warm service, so 150 ms is noise against it. */
|
|
710
|
-
export const LINE_PREVIEW_DEBOUNCE_MS = 150;
|
|
711
|
-
/** How often the line-edit capability is re-probed while the bridge is up and
|
|
712
|
-
* the answer is still no.
|
|
713
|
-
*
|
|
714
|
-
* The bridge answers `capabilities.lineEdit` from a LAZY ssh probe of the
|
|
715
|
-
* ProjFlow box behind a 5 s cache, so the very first /ardy/health of a session
|
|
716
|
-
* can land on a cold cache and come back false — a box that is perfectly fine
|
|
717
|
-
* two seconds later. Asking once per bridge-up transition therefore made the
|
|
718
|
-
* whole mode dead until a reload (first boot broken, second boot fine, which is
|
|
719
|
-
* exactly how it was reported). A capability is an ADVERTISEMENT, so it is
|
|
720
|
-
* allowed to arrive late; what is not allowed is to assume it. 4 s sits just
|
|
721
|
-
* under the bridge's own 5 s TTL, so a retry can actually see a new answer
|
|
722
|
-
* rather than re-reading the same cached false. */
|
|
723
|
-
export const LINE_CAPABILITY_RETRY_MS = 4000;
|
|
724
|
-
/** Why a validateLineEdit code refused, said once, in the user's language.
|
|
725
|
-
* Keyed by the pure module's stable codes so the copy and the check can never
|
|
726
|
-
* disagree about which field was wrong. */
|
|
727
|
-
export const LINE_EDIT_REFUSALS = {
|
|
728
|
-
sourceMotion: ["The current take has no bridge source — generate it once before editing a path", "현재 테이크에 브리지 원본이 없어요 — 궤적을 편집하기 전에 한 번 생성하세요"],
|
|
729
|
-
track: ["Pick a joint to edit first", "먼저 편집할 관절을 고르세요"],
|
|
730
|
-
frameRange: ["The frame range must sit inside the clip and span at least 2 frames", "프레임 구간은 클립 안에 있어야 하고 최소 2프레임이어야 해요"],
|
|
731
|
-
points: ["Pull a longer stretch of the path — a single point is not a path", "궤적을 좀 더 길게 잡아당겨 주세요 — 점 하나는 경로가 아니에요"],
|
|
732
|
-
camera: ["The camera could not be captured — nudge the view and try again", "카메라를 캡처하지 못했어요 — 뷰를 조금 움직인 뒤 다시 시도하세요"],
|
|
733
|
-
prompt: ["The motion prompt is not usable for a line edit", "모션 프롬프트를 라인 편집에 쓸 수 없어요"],
|
|
734
|
-
pins: ["The pinned moments could not be sent — try clearing them and pinning again", "찍은 순간을 보낼 수 없어요 — 지우고 다시 찍어 보세요"],
|
|
735
|
-
shape: ["The line edit could not be assembled", "라인 편집을 구성하지 못했어요"],
|
|
736
|
-
};
|
|
737
|
-
/** Why curveToPoints2d refused. Same shape, different stage: these are about
|
|
738
|
-
* the CURVE, before there is a payload to validate. */
|
|
739
|
-
export const LINE_CURVE_REFUSALS = {
|
|
740
|
-
empty: ["This joint is not visible over the chosen frames — frame it in view first", "선택한 구간에서 이 관절이 보이지 않아요 — 먼저 화면 안에 들어오도록 잡아 주세요"],
|
|
741
|
-
offscreen: ["The pulled path leaves the frame — keep it inside the dashed border", "잡아당긴 궤적이 화면을 벗어났어요 — 점선 테두리 안에 두세요"],
|
|
742
|
-
};
|
|
743
|
-
|
|
744
|
-
// Named ingest failures, in both locales. A reason the user cannot act on is
|
|
745
|
-
// not a message: each line says what was wrong with THIS source.
|
|
746
|
-
export const MULTIMODEL_REASONS = {
|
|
747
|
-
"url-empty": ["Enter a video URL first", "영상 URL을 먼저 입력하세요"],
|
|
748
|
-
"url-protocol-relative": ["Use a full https:// address", "https:// 로 시작하는 전체 주소를 쓰세요"],
|
|
749
|
-
"url-scheme-unsupported": ["Only http(s) or a local /path is accepted", "http(s) 또는 로컬 /경로만 사용할 수 있어요"],
|
|
750
|
-
"url-platform-page": [
|
|
751
|
-
"YouTube/Vimeo pages are players, not video files, and block browser downloads. Start the dev bridge to use the URL directly, or download the clip and use Choose video.",
|
|
752
|
-
"유튜브·비메오 주소는 영상 파일이 아니라 플레이어 페이지이고 브라우저 다운로드를 막습니다. 개발 브리지를 켜면 URL을 그대로 쓸 수 있고, 아니면 영상을 내려받아 '영상 선택'을 쓰세요.",
|
|
753
|
-
],
|
|
754
|
-
"url-malformed": ["That address could not be parsed", "주소를 해석할 수 없어요"],
|
|
755
|
-
"fetch-failed": ["The host refused a browser download (CORS or offline)", "호스트가 브라우저 다운로드를 거부했어요(CORS 또는 오프라인)"],
|
|
756
|
-
"not-a-video": ["That address did not return a video (check the path)", "그 주소는 영상을 반환하지 않았어요(경로를 확인하세요)"],
|
|
757
|
-
"decode-failed": ["This browser cannot decode that video", "이 브라우저가 디코딩할 수 없는 영상이에요"],
|
|
758
|
-
"duration-unreadable": ["The clip length could not be read", "클립 길이를 읽지 못했어요"],
|
|
759
|
-
"dimensions-unreadable": ["The frame size could not be read", "프레임 크기를 읽지 못했어요"],
|
|
760
|
-
"probe-timeout": ["The video never reported metadata", "영상이 메타데이터를 보내지 않았어요"],
|
|
761
|
-
"fetch-unavailable": ["This browser cannot download files", "이 브라우저는 파일을 내려받을 수 없어요"],
|
|
762
|
-
"footage-load-timeout": ["The clip never became seekable", "클립이 탐색 가능한 상태가 되지 않았어요"],
|
|
763
|
-
"seek-timeout": ["Seeking inside the clip stalled", "클립 내부 탐색이 멈췄어요"],
|
|
764
|
-
"pose-runtime-unavailable": ["The pose engine is missing from this build", "이 빌드에 포즈 엔진이 없어요"],
|
|
765
|
-
"pose-model-download-failed": ["The pose model could not be downloaded (offline?)", "포즈 모델을 내려받지 못했어요(오프라인인가요?)"],
|
|
766
|
-
"pose-engine-download-failed": ["The pose engine could not be downloaded (offline?)", "포즈 엔진을 내려받지 못했어요(오프라인인가요?)"],
|
|
767
|
-
"pose-engine-init-failed": ["The pose engine failed to start on this device", "이 기기에서 포즈 엔진을 시작하지 못했어요"],
|
|
768
|
-
"rest-unavailable": ["The rig rest data (/ardy/cskel27-rest.json) could not be loaded", "리그 레스트 데이터(/ardy/cskel27-rest.json)를 불러오지 못했어요"],
|
|
769
|
-
"no-person-found": ["No person was detected in the footage", "영상에서 사람을 감지하지 못했어요"],
|
|
770
|
-
"no-person-in-photo": ["No person was detected in that photograph", "사진에서 사람을 감지하지 못했어요"],
|
|
771
|
-
"image-load-timeout": ["The photograph never finished decoding", "사진 디코딩이 끝나지 않았어요"],
|
|
772
|
-
"pose-partly-occluded": ["Shoulders and hips must both be visible in the photograph", "사진에 어깨와 골반이 모두 보여야 해요"],
|
|
773
|
-
"no-usable-pose": ["A person was seen, but no stable pose could be fitted", "사람은 보였지만 안정적인 포즈를 만들지 못했어요"],
|
|
774
|
-
"rig-not-loaded": ["Subject 1's rig is not loaded yet", "인물 1 리그가 아직 로드되지 않았어요"],
|
|
775
|
-
"bridge-unreachable": ["The dev bridge did not answer", "개발 브리지가 응답하지 않아요"],
|
|
776
|
-
"bridge-footage-incomplete": ["The bridge stream ended without footage", "브리지 전송이 영상 없이 끝났어요"],
|
|
777
|
-
"footage-url-invalid": ["That address could not be used for a bridge download", "이 주소는 브리지 다운로드에 쓸 수 없어요"],
|
|
778
|
-
"footage-probe-failed": ["The bridge could not read that page's video info", "브리지가 그 페이지의 영상 정보를 읽지 못했어요"],
|
|
779
|
-
"footage-live-unsupported": ["Live streams have no fixed length and cannot be ingested", "라이브 스트림은 길이가 없어 인제스트할 수 없어요"],
|
|
780
|
-
"footage-too-long": ["That video is over 15 minutes — trim or pick a shorter one", "15분이 넘는 영상이에요 — 잘라내거나 더 짧은 걸 골라 주세요"],
|
|
781
|
-
"footage-download-failed": ["The bridge could not download that video", "브리지가 영상을 내려받지 못했어요"],
|
|
782
|
-
"footage-normalize-failed": ["The bridge could not convert that video for extraction", "브리지가 영상을 추출용으로 변환하지 못했어요"],
|
|
783
|
-
"footage-timeout": ["The bridge download took too long and was stopped", "브리지 다운로드가 너무 오래 걸려 중단됐어요"],
|
|
784
|
-
"bridge-extract-incomplete": ["The bridge stream ended without a take", "브리지 전송이 테이크 없이 끝났어요"],
|
|
785
|
-
"extract-host-missing": ["The bridge has no GPU box configured (CCLAY_EXTRACT_HOST)", "브리지에 GPU 박스가 설정돼 있지 않아요(CCLAY_EXTRACT_HOST)"],
|
|
786
|
-
"extract-bridge-required": ["GVHMR extraction requires the local GPU bridge", "GVHMR 추출에는 로컬 GPU 브리지가 필요해요"],
|
|
787
|
-
"extract-backend-unsupported": ["This bridge is not configured for GVHMR", "이 브리지가 GVHMR로 설정되지 않았어요"],
|
|
788
|
-
"extract-upload-failed": ["The footage could not be copied to the GPU box", "영상을 GPU 박스로 복사하지 못했어요"],
|
|
789
|
-
"extract-upload-too-large": ["That clip is too large to upload for extraction (300 MB cap)", "추출 업로드 한도(300MB)를 넘는 영상이에요"],
|
|
790
|
-
"extract-upload-empty": ["No video bytes arrived at the bridge", "브리지에 영상 데이터가 도착하지 않았어요"],
|
|
791
|
-
"extract-footage-unknown": ["The bridge no longer holds that download — re-ingest the URL", "브리지에 그 다운로드가 더 이상 없어요 — URL을 다시 넣어 주세요"],
|
|
792
|
-
"extract-run-failed": ["GVHMR failed on the GPU box (see the bridge log)", "GPU 박스에서 GVHMR 실행이 실패했어요(브리지 로그 확인)"],
|
|
793
|
-
"extract-no-person": ["The GPU box tracked no person in that footage", "GPU 박스가 영상에서 사람을 추적하지 못했어요"],
|
|
794
|
-
"extract-convert-failed": ["The extracted take could not be converted for the timeline", "추출된 테이크를 타임라인용으로 변환하지 못했어요"],
|
|
795
|
-
"extract-timeout": ["GPU extraction took too long and was stopped", "GPU 추출이 너무 오래 걸려 중단됐어요"],
|
|
796
|
-
};
|
|
797
|
-
|
|
798
|
-
// Extraction samples and bakes straight onto the production clock — an
|
|
799
|
-
// extracted take never touches ARDY, so it never needs retiming either.
|
|
800
|
-
export const MULTIMODEL_SAMPLE_FPS = TIMELINE_FPS;
|
|
14
|
+
import {
|
|
15
|
+
PRESETS,
|
|
16
|
+
editorFrameForSubject,
|
|
17
|
+
} from "./stage-startup-defaults.js";
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
PRESETS,
|
|
21
|
+
SHOT_ASPECT_PRESETS,
|
|
22
|
+
characterModelUrl,
|
|
23
|
+
CHARACTER_MODEL_LABELS,
|
|
24
|
+
nextCharacterId,
|
|
25
|
+
DEMO_MOTION_URL,
|
|
26
|
+
DEMO_MOTION_PROMPT,
|
|
27
|
+
OBJECT_DELETE_UNDO_MS,
|
|
28
|
+
ASSET_DELETE_UNDO_MS,
|
|
29
|
+
CLAY,
|
|
30
|
+
CLAY_B,
|
|
31
|
+
CLAY_X,
|
|
32
|
+
defaultCharacterTint,
|
|
33
|
+
editorFrameForSubject,
|
|
34
|
+
DEFAULT_SUBJECT,
|
|
35
|
+
DEFAULT_SUBJECT2,
|
|
36
|
+
DEFAULT_ENVIRONMENT,
|
|
37
|
+
DEFAULT_CAMERA_POSITION,
|
|
38
|
+
REST_BONES,
|
|
39
|
+
GIZMO_HOTKEYS,
|
|
40
|
+
WORKSPACE_LAYOUT_KEY,
|
|
41
|
+
DEFAULT_WORKSPACE_LAYOUT,
|
|
42
|
+
loadWorkspaceLayout,
|
|
43
|
+
loadSceneStartup,
|
|
44
|
+
} from "./stage-startup-defaults.js";
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
RIG_HIERARCHY_FOCUS,
|
|
48
|
+
HIERARCHY_INSPECTOR_TITLES,
|
|
49
|
+
POSE_LABELS_KO,
|
|
50
|
+
CAMERA_MOVE_LABELS_KO,
|
|
51
|
+
SHOT_SIZE_LABELS_KO,
|
|
52
|
+
SHOT_LEVEL_LABELS_KO,
|
|
53
|
+
SCENE_RENDERER_LABELS_KO,
|
|
54
|
+
SCENE_OBJECT_NAME_LABELS_KO,
|
|
55
|
+
poseLabelKo,
|
|
56
|
+
cameraMoveLabelKo,
|
|
57
|
+
sceneRendererLabelKo,
|
|
58
|
+
sceneObjectNameDisplayKo,
|
|
59
|
+
viewShortKo,
|
|
60
|
+
slateLineKo,
|
|
61
|
+
moveSequenceSlateKo,
|
|
62
|
+
hierarchyIdForIkFocus,
|
|
63
|
+
} from "./stage-labels-ko.js";
|
|
64
|
+
|
|
65
|
+
export * from "./stage-attach-frames.js";
|
|
66
|
+
|
|
67
|
+
export { Character, MotionTrails } from "./stage-characters.jsx";
|
|
68
|
+
|
|
69
|
+
export {
|
|
70
|
+
ShotRig,
|
|
71
|
+
MoveRig,
|
|
72
|
+
FollowCamRig,
|
|
73
|
+
ShotLookApplier,
|
|
74
|
+
CameraGlide,
|
|
75
|
+
EditorCamSeed,
|
|
76
|
+
RenderLoopController,
|
|
77
|
+
ContextLossGuard,
|
|
78
|
+
ViewportLayoutInvalidator,
|
|
79
|
+
} from "./stage-canvas-rigs.jsx";
|
|
80
|
+
|
|
81
|
+
export {
|
|
82
|
+
KeyLightPuck,
|
|
83
|
+
ShotCameraGhost,
|
|
84
|
+
CameraRailScenePreview,
|
|
85
|
+
CRANE_AXES,
|
|
86
|
+
CRANE_ARROW_LEN,
|
|
87
|
+
CRANE_GIZMO_SCALE,
|
|
88
|
+
prepareRailBend,
|
|
89
|
+
GizmoLabel,
|
|
90
|
+
ObjectPathHandles,
|
|
91
|
+
CraneHandles,
|
|
92
|
+
ShotPathPreview,
|
|
93
|
+
} from "./stage-editor-gizmos.jsx";
|
|
94
|
+
|
|
95
|
+
export {
|
|
96
|
+
CAPTURE_W,
|
|
97
|
+
CAPTURE_H,
|
|
98
|
+
MCP_CAPTURE_W,
|
|
99
|
+
MCP_CAPTURE_H,
|
|
100
|
+
MCP_CAPTURE_SAMPLE_STEP,
|
|
101
|
+
CAPTURE_FOG_NEAR,
|
|
102
|
+
CAPTURE_FOG_FAR,
|
|
103
|
+
CaptureRig,
|
|
104
|
+
captureMcpFrame,
|
|
105
|
+
} from "./stage-capture.jsx";
|
|
106
|
+
export {
|
|
107
|
+
TIMELINE_FPS,
|
|
108
|
+
ARDY_FPS,
|
|
109
|
+
toArdyFrame,
|
|
110
|
+
posePlacementFrame,
|
|
111
|
+
toArdyFrameEntries,
|
|
112
|
+
toArdySegments,
|
|
113
|
+
DEFAULT_DURATION_S,
|
|
114
|
+
DEFAULT_PLAYBACK_SPEED,
|
|
115
|
+
BRIDGE_RECHECK_MS,
|
|
116
|
+
ARDY_PROMPT_HORIZON_FRAMES,
|
|
117
|
+
MAX_WAYPOINTS,
|
|
118
|
+
ARDY_PROMPT_MAX,
|
|
119
|
+
ARDY_DURATION_MIN,
|
|
120
|
+
ARDY_DURATION_MAX,
|
|
121
|
+
ARDY_SEED_MAX,
|
|
122
|
+
ARDY_PRESERVE_DEFAULT,
|
|
123
|
+
DEFAULT_PROMPT_CLIPS,
|
|
124
|
+
buildPromptSchedule,
|
|
125
|
+
ikTracksInRange,
|
|
126
|
+
PRESERVE_TRACK_LIMBS,
|
|
127
|
+
PRESERVE_LIMB_ORDER,
|
|
128
|
+
preserveLimbLabel,
|
|
129
|
+
preserveTracksSummary,
|
|
130
|
+
LINE_EDIT_TRACK_OPTIONS,
|
|
131
|
+
lineTrackLabel,
|
|
132
|
+
LINE_EDIT_DEFAULT_TRACK,
|
|
133
|
+
LINE_CURVE_MARKER_STRIDE,
|
|
134
|
+
MIN_CURVE_POINTS,
|
|
135
|
+
LINE_PREVIEW_DEBOUNCE_MS,
|
|
136
|
+
LINE_CAPABILITY_RETRY_MS,
|
|
137
|
+
LINE_EDIT_REFUSALS,
|
|
138
|
+
LINE_CURVE_REFUSALS,
|
|
139
|
+
MULTIMODEL_REASONS,
|
|
140
|
+
MULTIMODEL_SAMPLE_FPS,
|
|
141
|
+
} from "./ardy-bridge-contract.js";
|
|
801
142
|
|
|
802
143
|
/* ------------------------------------------------------------------ 3D --- */
|
|
803
144
|
|
|
804
|
-
// Memoized: unchanged cast members skip re-rendering on every playhead tick.
|
|
805
|
-
export const Character = memo(function Character({ url, position, rot, tint, pose, scale = 1, onRig, pickId, partColoursEnabled = false, partColoursMode = "shaded" }) {
|
|
806
|
-
const fbx = useFBX(url);
|
|
807
|
-
const model = useMemo(() => {
|
|
808
|
-
const clone = SkeletonUtils.clone(fbx);
|
|
809
|
-
// Mixamo exports centimetres; `scale` is the FILMED person's stature.
|
|
810
|
-
// THE INVARIANT: extraction divided this take's root travel by that
|
|
811
|
-
// stature, so the clip and the scale must travel together — apply one
|
|
812
|
-
// without the other and every stride overshoots by the same factor and
|
|
813
|
-
// the feet skate. It belongs HERE, on the world transform, and never
|
|
814
|
-
// inside rig space: playback.js derives prep.scale from the bind pose,
|
|
815
|
-
// so a scaled bone hierarchy would be measured as a different rig.
|
|
816
|
-
clone.scale.setScalar(0.01 * scale);
|
|
817
|
-
// The joint shells (Alpha_Joints / Beta_Joints — elbows, knees, the
|
|
818
|
-
// exoskeleton bands) render in a darkened shade of the body tint so
|
|
819
|
-
// the segments read as separate pieces.
|
|
820
|
-
// The shipped clay is intentionally pale, but the stage deck is pale too.
|
|
821
|
-
// Lift neither with emissive nor a second light; a small value drop keeps
|
|
822
|
-
// the same authored hue while restoring silhouette contrast in the editor.
|
|
823
|
-
const bodyTint = new THREE.Color(tint).multiplyScalar(0.9);
|
|
824
|
-
const jointTint = bodyTint.clone().multiplyScalar(0.62);
|
|
825
|
-
clone.traverse((child) => {
|
|
826
|
-
if (child.isMesh) {
|
|
827
|
-
// warm clay reads as a maquette; cold grey reads as a broken render
|
|
828
|
-
child.material = new THREE.MeshStandardMaterial({
|
|
829
|
-
color: /_Joints$/i.test(child.name) ? jointTint : bodyTint,
|
|
830
|
-
// A slightly tighter highlight gives the clay enough form to read
|
|
831
|
-
// at blocking distance without turning it into shiny plastic.
|
|
832
|
-
roughness: 0.66,
|
|
833
|
-
metalness: 0,
|
|
834
|
-
envMapIntensity: 0.35,
|
|
835
|
-
});
|
|
836
|
-
child.frustumCulled = false;
|
|
837
|
-
// The subject's own shadow is what plants it on the deck; a blocking
|
|
838
|
-
// frame without one leaves the figure floating over flat colour.
|
|
839
|
-
child.castShadow = true;
|
|
840
|
-
child.receiveShadow = true;
|
|
841
|
-
}
|
|
842
|
-
});
|
|
843
|
-
if (partColoursEnabled) applyPartColours(clone, partColoursMode);
|
|
844
|
-
addFacingMarks(clone);
|
|
845
|
-
// Stamp the bind pose while the rig is still untouched: the pose effect
|
|
846
|
-
// below runs immediately after and would otherwise be baked into "rest".
|
|
847
|
-
primeBindPose(clone);
|
|
848
|
-
return clone;
|
|
849
|
-
}, [fbx, tint, partColoursEnabled, partColoursMode]);
|
|
850
|
-
|
|
851
|
-
// A new stature (a fresh extraction on this character) must not rebuild the
|
|
852
|
-
// clone — that would drop the rig the playback effects hold. Only the world
|
|
853
|
-
// transform moves; the same invariant as above applies.
|
|
854
|
-
useEffect(() => {
|
|
855
|
-
model.scale.setScalar(0.01 * scale);
|
|
856
|
-
}, [model, scale]);
|
|
857
|
-
|
|
858
|
-
useEffect(() => {
|
|
859
|
-
// During playback the pose prop is null and the motion drives the rig;
|
|
860
|
-
// this effect can flush AFTER the playback effect in a later commit
|
|
861
|
-
// (measured: playback at t, pose reset at t+1.8ms), and resetting to
|
|
862
|
-
// REST then would clobber the animation back to the bind pose. So a
|
|
863
|
-
// null pose means "playback owns the rig" — do not touch it.
|
|
864
|
-
if (!pose) return;
|
|
865
|
-
// every joint is reset first, otherwise switching presets leaves stale limbs
|
|
866
|
-
applyPose(model, { ...REST_BONES, ...pose.bones });
|
|
867
|
-
// A measured pose (photo, bottled take frame) carries its hips height;
|
|
868
|
-
// rotations alone would leave a crouch floating at standing height.
|
|
869
|
-
applyHipsOffset(model, pose.rootY ?? 0);
|
|
870
|
-
}, [model, pose]);
|
|
871
|
-
|
|
872
|
-
const onRigRef = useRef(onRig);
|
|
873
|
-
onRigRef.current = onRig;
|
|
874
|
-
// Report on MODEL change only — a per-render callback identity change
|
|
875
|
-
// must not re-run this effect.
|
|
876
|
-
useEffect(() => {
|
|
877
|
-
onRigRef.current?.(model);
|
|
878
|
-
}, [model]);
|
|
879
|
-
|
|
880
|
-
return (
|
|
881
|
-
// characterPick makes the body a first-class click target: the Scene
|
|
882
|
-
// picker walks up from any hit mesh, finds the tag, and routes the
|
|
883
|
-
// selection to the hierarchy so the Inspector owns the controls.
|
|
884
|
-
<group position={position} rotation={[0, (rot * Math.PI) / 180, 0]} userData={pickId ? { characterPick: pickId } : undefined}>
|
|
885
|
-
<primitive object={model} />
|
|
886
|
-
</group>
|
|
887
|
-
);
|
|
888
|
-
});
|
|
889
|
-
|
|
890
|
-
/** Selection marker for the picked cast member: a Unity-style XYZ tripod
|
|
891
|
-
* plus a ground ring at the feet. X/Z arrows also drag the character on the
|
|
892
|
-
* floor (Y is display-only — characters stand on the deck). */
|
|
893
|
-
export function ShotRig({ preset, nonce, fovDeg, charA, charB, showB, probeX, probeZ, camRef, look, onMetrics, appliedPresetRef, lastPosRef }) {
|
|
894
|
-
useEffect(() => {
|
|
895
|
-
const cam = camRef.current;
|
|
896
|
-
if (!cam) return;
|
|
897
|
-
// A preset is applied once per (preset, nonce), not once per mount. The
|
|
898
|
-
// Canvas has no Suspense boundary of its own, so a cast member whose FBX
|
|
899
|
-
// is still downloading suspends the whole scene graph; when it resolves,
|
|
900
|
-
// React remounts the siblings — this rig included, on a fresh camera
|
|
901
|
-
// object — and a per-mount seed would snap the shot back to the preset,
|
|
902
|
-
// discarding a frame_shot or a manual move that landed in the meantime
|
|
903
|
-
// (#86 on slow CI runners). Both refs live in App so they survive the
|
|
904
|
-
// remount: the stamp says whether this preset was already applied, the
|
|
905
|
-
// position ref (with look.current) says where the camera last was.
|
|
906
|
-
const stamp = `${preset}:${nonce}`;
|
|
907
|
-
if (appliedPresetRef && appliedPresetRef.current === stamp && lastPosRef?.current) {
|
|
908
|
-
const last = lastPosRef.current;
|
|
909
|
-
cam.position.set(last.x, last.y, last.z);
|
|
910
|
-
cam.rotation.order = "YXZ";
|
|
911
|
-
cam.rotation.set(look.current.pitch, look.current.yaw, 0);
|
|
912
|
-
return;
|
|
913
|
-
}
|
|
914
|
-
if (appliedPresetRef) appliedPresetRef.current = stamp;
|
|
915
|
-
const p = PRESETS[preset];
|
|
916
|
-
const az = (p.azimuth * Math.PI) / 180;
|
|
917
|
-
const el = (p.elevation * Math.PI) / 180;
|
|
918
|
-
const aim =
|
|
919
|
-
p.aimMid && showB ? { x: (charA.x + charB.x) / 2, z: (charA.z + charB.z) / 2 } : { x: charA.x, z: charA.z };
|
|
920
|
-
const horizontal = p.distance * Math.cos(el);
|
|
921
|
-
cam.position.set(
|
|
922
|
-
aim.x + horizontal * Math.sin(az),
|
|
923
|
-
p.height ?? Math.max(p.targetY + p.distance * Math.sin(el), 0.15),
|
|
924
|
-
aim.z + horizontal * Math.cos(az),
|
|
925
|
-
);
|
|
926
|
-
const angles = aimAt(cam.position, { x: aim.x, y: p.targetY, z: aim.z });
|
|
927
|
-
look.current.yaw = angles.yaw;
|
|
928
|
-
look.current.pitch = angles.pitch;
|
|
929
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
930
|
-
}, [preset, nonce]);
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
useEffect(() => {
|
|
934
|
-
const cam = camRef.current;
|
|
935
|
-
if (!cam) return;
|
|
936
|
-
cam.fov = fovDeg;
|
|
937
|
-
cam.updateProjectionMatrix();
|
|
938
|
-
}, [fovDeg, camRef]);
|
|
939
|
-
|
|
940
|
-
// A blocking tool must not claim a framing it is not holding, so the subject's
|
|
941
|
-
// feet and eyes are projected into the frame every tick.
|
|
942
|
-
const probe = useRef(new THREE.Vector3());
|
|
943
|
-
const lastMetrics = useRef({ x: NaN, y: NaN, z: NaN, visible: null });
|
|
944
|
-
useFrame(() => {
|
|
945
|
-
const cam = camRef.current;
|
|
946
|
-
if (!cam) return;
|
|
947
|
-
let visible = false;
|
|
948
|
-
for (const y of [0.1, SUBJECT_HEIGHT_M * 0.94]) {
|
|
949
|
-
// During motion playback the probe follows the played root, so the
|
|
950
|
-
// "subject out of frame" caption stays honest.
|
|
951
|
-
probe.current.set(probeX ?? charA.x, y, probeZ ?? charA.z).project(cam);
|
|
952
|
-
if (probe.current.z < 1 && Math.abs(probe.current.x) <= 1 && Math.abs(probe.current.y) <= 1) visible = true;
|
|
953
|
-
}
|
|
954
|
-
const previous = lastMetrics.current;
|
|
955
|
-
if (
|
|
956
|
-
Math.abs(previous.x - cam.position.x) +
|
|
957
|
-
Math.abs(previous.y - cam.position.y) +
|
|
958
|
-
Math.abs(previous.z - cam.position.z) >
|
|
959
|
-
1e-4 ||
|
|
960
|
-
previous.visible !== visible
|
|
961
|
-
) {
|
|
962
|
-
lastMetrics.current = {
|
|
963
|
-
x: cam.position.x,
|
|
964
|
-
y: cam.position.y,
|
|
965
|
-
z: cam.position.z,
|
|
966
|
-
visible,
|
|
967
|
-
};
|
|
968
|
-
onMetrics(cam.position, visible);
|
|
969
|
-
}
|
|
970
|
-
});
|
|
971
|
-
|
|
972
|
-
return null;
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
/** Plays an authored A→B camera move by driving the shot camera exactly the
|
|
976
|
-
way a fly-drag does — position on the camera, orientation through the look
|
|
977
|
-
ref — so ShotRig's metrics, the slate, and the inset all stay honest for
|
|
978
|
-
free. A right-drag interrupts the dolly: the user always outranks it.
|
|
979
|
-
|
|
980
|
-
Two frame sources can drive the move. Standalone preview advances authored
|
|
981
|
-
frames at production cadence; follow mode reads the timeline playhead, so the camera and
|
|
982
|
-
the character motion are two views of the same time axis — playing or
|
|
983
|
-
scrubbing frame 72 at 24 fps puts the camera exactly 3 s into its move. */
|
|
984
|
-
export function MoveRig({ playing, following, followFrame, fps, keys, shots, scene, camRef, look, isInterrupted, onDone }) {
|
|
985
|
-
const invalidate = useThree((state) => state.invalidate);
|
|
986
|
-
const preview = useRef({ frame: 0, finished: false, notified: false });
|
|
987
|
-
const handlers = useRef({ isInterrupted, onDone });
|
|
988
|
-
handlers.current = { isInterrupted, onDone };
|
|
989
|
-
// The follow branch only re-applies when its time changes; in demand mode
|
|
990
|
-
// each apply needs one more frame so ShotRig's metrics see the new pose.
|
|
991
|
-
const appliedFrame = useRef(null);
|
|
992
|
-
useEffect(() => {
|
|
993
|
-
// Arming or reshaping the move must schedule a frame by hand: this
|
|
994
|
-
// component owns no three objects, so in demand mode nothing else will.
|
|
995
|
-
appliedFrame.current = null;
|
|
996
|
-
if (following) invalidate();
|
|
997
|
-
}, [keys, shots, scene, fps, following, invalidate]);
|
|
998
|
-
useEffect(() => {
|
|
999
|
-
// A paused scrub changes the playhead without starting the render loop.
|
|
1000
|
-
if (following && !playing) invalidate();
|
|
1001
|
-
}, [followFrame, following, playing, invalidate]);
|
|
1002
|
-
useEffect(() => {
|
|
1003
|
-
if (!playing || keys.length < 1) return undefined;
|
|
1004
|
-
const firstFrame = keys[0].frame;
|
|
1005
|
-
const lastFrame = keys[keys.length - 1].frame;
|
|
1006
|
-
const spanFrames = Math.max(lastFrame - firstFrame, 1);
|
|
1007
|
-
const spanSeconds = Math.max(spanFrames / Math.max(fps, 1), 0.1);
|
|
1008
|
-
const tickMs = (spanSeconds * 1000) / spanFrames;
|
|
1009
|
-
let tick = 0;
|
|
1010
|
-
preview.current = { frame: firstFrame, finished: false, notified: false };
|
|
1011
|
-
invalidate();
|
|
1012
|
-
const timer = window.setInterval(() => {
|
|
1013
|
-
if (preview.current.finished) return;
|
|
1014
|
-
if (handlers.current.isInterrupted?.()) {
|
|
1015
|
-
preview.current.finished = true;
|
|
1016
|
-
invalidate();
|
|
1017
|
-
return;
|
|
1018
|
-
}
|
|
1019
|
-
tick += 1;
|
|
1020
|
-
preview.current = {
|
|
1021
|
-
frame: firstFrame + (lastFrame - firstFrame) * Math.min(tick / spanFrames, 1),
|
|
1022
|
-
finished: tick >= spanFrames,
|
|
1023
|
-
notified: false,
|
|
1024
|
-
};
|
|
1025
|
-
invalidate();
|
|
1026
|
-
}, tickMs);
|
|
1027
|
-
return () => window.clearInterval(timer);
|
|
1028
|
-
}, [playing, keys, fps, invalidate]);
|
|
1029
|
-
useFrame(() => {
|
|
1030
|
-
if ((playing && keys.length < 1) || (!playing && following && !shots.some((shot) => shot.cameraKeys.length))) return;
|
|
1031
|
-
const cam = camRef.current;
|
|
1032
|
-
if (!cam) return;
|
|
1033
|
-
const apply = (frame) => {
|
|
1034
|
-
// Timeline/PlayView sampling follows the editorial strips. Standalone
|
|
1035
|
-
// preview stays scoped to the selected strip's keys. Entering a Shot
|
|
1036
|
-
// applies its authored camera immediately (a hard cut). Leaving into a
|
|
1037
|
-
// gap returns null and deliberately applies nothing: the last physical
|
|
1038
|
-
// camera pose stays put and FlyControls regain ownership, so we do not
|
|
1039
|
-
// invent a second cut to an arbitrary "free camera" preset.
|
|
1040
|
-
const timelineShot = following ? shotAtFrame(shots, frame) : null;
|
|
1041
|
-
const sampledShot = timelineShot
|
|
1042
|
-
? { ...timelineShot, camera: { mode: "keys" } }
|
|
1043
|
-
: following ? null : { camera: { mode: "keys" }, cameraKeys: keys };
|
|
1044
|
-
const f = sampleAt(scene, sampledShot, frame).camera;
|
|
1045
|
-
if (!f) return null;
|
|
1046
|
-
cam.position.set(f.pos.x, f.pos.y, f.pos.z);
|
|
1047
|
-
look.current.yaw = f.yaw;
|
|
1048
|
-
look.current.pitch = f.pitch;
|
|
1049
|
-
if (Math.abs(cam.fov - f.fovDeg) > 1e-3) {
|
|
1050
|
-
cam.fov = f.fovDeg;
|
|
1051
|
-
cam.updateProjectionMatrix();
|
|
1052
|
-
}
|
|
1053
|
-
return f;
|
|
1054
|
-
};
|
|
1055
|
-
if (playing) {
|
|
1056
|
-
const f = apply(preview.current.frame);
|
|
1057
|
-
if (preview.current.finished && !preview.current.notified) {
|
|
1058
|
-
preview.current.notified = true;
|
|
1059
|
-
handlers.current.onDone(f ? f.fovDeg : cam.fov);
|
|
1060
|
-
}
|
|
1061
|
-
return;
|
|
1062
|
-
}
|
|
1063
|
-
if (!following) return;
|
|
1064
|
-
if (isInterrupted?.()) return; // manual viewport framing owns the paused camera
|
|
1065
|
-
if (appliedFrame.current === followFrame) return;
|
|
1066
|
-
appliedFrame.current = followFrame;
|
|
1067
|
-
apply(followFrame);
|
|
1068
|
-
invalidate();
|
|
1069
|
-
});
|
|
1070
|
-
return null;
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
/**
|
|
1074
|
-
* Applies the precomputed follow-camera track to the shot camera. The track
|
|
1075
|
-
* is derived offline from the subject trajectory (camera-follow.js), so this
|
|
1076
|
-
* rig only samples it at the playhead — scrub, play, PlayView and Record all
|
|
1077
|
-
* replay the identical deterministic move. Null-rendering, so every apply
|
|
1078
|
-
* must invalidate() by hand in demand mode, like MoveRig.
|
|
1079
|
-
*/
|
|
1080
|
-
export function FollowCamRig({ enabled, frame, scene, shot, camRef, look, isInterrupted }) {
|
|
1081
|
-
const invalidate = useThree((state) => state.invalidate);
|
|
1082
|
-
const applied = useRef(null);
|
|
1083
|
-
useEffect(() => {
|
|
1084
|
-
applied.current = null;
|
|
1085
|
-
if (enabled) invalidate();
|
|
1086
|
-
}, [scene, shot, enabled, invalidate]);
|
|
1087
|
-
useEffect(() => {
|
|
1088
|
-
if (enabled) invalidate();
|
|
1089
|
-
}, [frame, enabled, invalidate]);
|
|
1090
|
-
useFrame(() => {
|
|
1091
|
-
if (!enabled) return;
|
|
1092
|
-
if (isInterrupted?.()) return; // the user is flying; yield until released
|
|
1093
|
-
const cam = camRef.current;
|
|
1094
|
-
if (!cam) return;
|
|
1095
|
-
const sample = sampleAt(scene, shot, frame).camera;
|
|
1096
|
-
if (!sample || applied.current === frame) return;
|
|
1097
|
-
applied.current = frame;
|
|
1098
|
-
cam.position.set(sample.pos.x, sample.pos.y, sample.pos.z);
|
|
1099
|
-
look.current.yaw = sample.yaw;
|
|
1100
|
-
look.current.pitch = sample.pitch;
|
|
1101
|
-
invalidate();
|
|
1102
|
-
});
|
|
1103
|
-
return null;
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
/**
|
|
1107
|
-
* Applies the shot camera's yaw/pitch from its look ref every frame. Before
|
|
1108
|
-
* the camera split, FlyControls owned the shot camera and performed this
|
|
1109
|
-
* apply as a side effect of flying; with the fly controls bound to the
|
|
1110
|
-
* editor camera, the recording camera still needs its aim (ShotRig, follow,
|
|
1111
|
-
* frame-selection) reflected in its actual rotation — the preview and the
|
|
1112
|
-
* capture read the camera object, not the ref.
|
|
1113
|
-
*/
|
|
1114
|
-
export function ShotLookApplier({ camRef, look }) {
|
|
1115
|
-
useFrame(() => {
|
|
1116
|
-
const cam = camRef.current;
|
|
1117
|
-
if (!cam) return;
|
|
1118
|
-
if (cam.rotation.order !== "YXZ") cam.rotation.order = "YXZ";
|
|
1119
|
-
if (cam.rotation.x !== look.current.pitch || cam.rotation.y !== look.current.yaw || cam.rotation.z !== 0) {
|
|
1120
|
-
cam.rotation.set(look.current.pitch, look.current.yaw, 0);
|
|
1121
|
-
}
|
|
1122
|
-
});
|
|
1123
|
-
return null;
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
/**
|
|
1127
|
-
* Seeds the editor camera once, inside the Canvas root — App-level effects
|
|
1128
|
-
* run before R3F mounts its children, so the ref is still null there. The
|
|
1129
|
-
* seed frames both the subject and the shot camera: the first editor frame
|
|
1130
|
-
* must read as "the set with the camera in it".
|
|
1131
|
-
*/
|
|
1132
|
-
/**
|
|
1133
|
-
* A short editor-camera glide: 260 ms ease toward a target orientation (and
|
|
1134
|
-
* optionally a position), instead of the teleport that made "look at the
|
|
1135
|
-
* light" and F-framing feel like a cut. Any user press cancels it — the
|
|
1136
|
-
* operator always outranks the tour guide.
|
|
1137
|
-
*/
|
|
1138
|
-
export function CameraGlide({ glide, camRef, lookRef, onDone }) {
|
|
1139
|
-
const { gl } = useThree();
|
|
1140
|
-
const invalidate = useThree((state) => state.invalidate);
|
|
1141
|
-
const animRef = useRef(null);
|
|
1142
|
-
useEffect(() => {
|
|
1143
|
-
if (!glide) {
|
|
1144
|
-
animRef.current = null;
|
|
1145
|
-
return undefined;
|
|
1146
|
-
}
|
|
1147
|
-
const cam = camRef.current;
|
|
1148
|
-
if (!cam) return undefined;
|
|
1149
|
-
const fromPos = cam.position.clone();
|
|
1150
|
-
const toPos = glide.position ? new THREE.Vector3(glide.position.x, glide.position.y, glide.position.z) : fromPos.clone();
|
|
1151
|
-
const angles = aimAt(toPos, glide.target);
|
|
1152
|
-
const from = { yaw: lookRef.current.yaw, pitch: lookRef.current.pitch };
|
|
1153
|
-
let dYaw = angles.yaw - from.yaw;
|
|
1154
|
-
while (dYaw > Math.PI) dYaw -= 2 * Math.PI;
|
|
1155
|
-
while (dYaw < -Math.PI) dYaw += 2 * Math.PI;
|
|
1156
|
-
// elapsed accumulates render-loop deltas: the glide needs no wall clock
|
|
1157
|
-
animRef.current = { fromPos, toPos, from, dYaw, dPitch: angles.pitch - from.pitch, elapsed: 0 };
|
|
1158
|
-
const cancel = () => {
|
|
1159
|
-
animRef.current = null;
|
|
1160
|
-
onDone?.();
|
|
1161
|
-
};
|
|
1162
|
-
gl.domElement.addEventListener("pointerdown", cancel, true);
|
|
1163
|
-
invalidate();
|
|
1164
|
-
return () => gl.domElement.removeEventListener("pointerdown", cancel, true);
|
|
1165
|
-
// camRef/lookRef are stable refs; onDone identity is per-glide noise
|
|
1166
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1167
|
-
}, [glide, gl, invalidate]);
|
|
1168
|
-
useFrame((_, delta) => {
|
|
1169
|
-
const anim = animRef.current;
|
|
1170
|
-
const cam = camRef.current;
|
|
1171
|
-
if (!anim || !cam) return;
|
|
1172
|
-
anim.elapsed += Math.min(delta, 0.1) * 1000;
|
|
1173
|
-
const t = Math.min(1, anim.elapsed / 260);
|
|
1174
|
-
const eased = 1 - (1 - t) ** 3;
|
|
1175
|
-
cam.position.lerpVectors(anim.fromPos, anim.toPos, eased);
|
|
1176
|
-
const yaw = anim.from.yaw + anim.dYaw * eased;
|
|
1177
|
-
const pitch = anim.from.pitch + anim.dPitch * eased;
|
|
1178
|
-
lookRef.current = { yaw, pitch };
|
|
1179
|
-
cam.rotation.order = "YXZ";
|
|
1180
|
-
cam.rotation.set(pitch, yaw, 0);
|
|
1181
|
-
if (t >= 1) {
|
|
1182
|
-
animRef.current = null;
|
|
1183
|
-
onDone?.();
|
|
1184
|
-
} else {
|
|
1185
|
-
invalidate();
|
|
1186
|
-
}
|
|
1187
|
-
});
|
|
1188
|
-
return null;
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
export function EditorCamSeed({ camRef, lookRef, shotCamRef, subject }) {
|
|
1192
|
-
const seeded = useRef(false);
|
|
1193
|
-
const invalidate = useThree((state) => state.invalidate);
|
|
1194
|
-
useEffect(() => {
|
|
1195
|
-
if (seeded.current) return;
|
|
1196
|
-
const cam = camRef.current;
|
|
1197
|
-
if (!cam) return;
|
|
1198
|
-
seeded.current = true;
|
|
1199
|
-
// The selected character owns the first read. The shot camera remains a
|
|
1200
|
-
// useful reference in the inset; mixing its position into this seed pushed
|
|
1201
|
-
// the editor eye several metres back and made the mannequin look tiny.
|
|
1202
|
-
const frame = editorFrameForSubject(subject);
|
|
1203
|
-
const { target, position } = frame;
|
|
1204
|
-
cam.position.set(position.x, position.y, position.z);
|
|
1205
|
-
const angles = aimAt(cam.position, target);
|
|
1206
|
-
lookRef.current = { yaw: angles.yaw, pitch: angles.pitch };
|
|
1207
|
-
cam.rotation.order = "YXZ";
|
|
1208
|
-
cam.rotation.set(angles.pitch, angles.yaw, 0);
|
|
1209
|
-
invalidate();
|
|
1210
|
-
});
|
|
1211
|
-
return null;
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
/**
|
|
1215
|
-
* The key light drawn as a grabbable sun in the editor view: a warm core with
|
|
1216
|
-
* rays, on GIZMO_LAYER so preview, capture and PlayView never see it. A line
|
|
1217
|
-
* drops to the floor so its height reads against the stage, like the crane's.
|
|
1218
|
-
* The sun's own body is the drag surface — a grab moves it freely on the
|
|
1219
|
-
* camera-facing plane (the cursor and the sun stay glued), and the gizmo's
|
|
1220
|
-
* arrows (via ObjectGizmo) stay available for single-axis precision. Its
|
|
1221
|
-
* colour follows the dial, and while selected a ray points at the stage so
|
|
1222
|
-
* the light's direction reads at a glance.
|
|
1223
|
-
*/
|
|
1224
|
-
export function KeyLightPuck({ keyLight, selected, visible, paneRef, camRef, onSelect, onChange, onDragEnd }) {
|
|
1225
|
-
const { gl } = useThree();
|
|
1226
|
-
const groupRef = useRef(null);
|
|
1227
|
-
const dragRef = useRef(null);
|
|
1228
|
-
const stateRef = useRef(null);
|
|
1229
|
-
const [dragKind, setDragKind] = useState(null);
|
|
1230
|
-
const invalidate = useThree((state) => state.invalidate);
|
|
1231
|
-
stateRef.current = { keyLight, visible, onSelect, onChange, onDragEnd };
|
|
1232
|
-
useEffect(() => {
|
|
1233
|
-
groupRef.current?.traverse((node) => node.layers?.set(GIZMO_LAYER));
|
|
1234
|
-
invalidate();
|
|
1235
|
-
}, [visible, selected, keyLight, dragKind, invalidate]);
|
|
1236
|
-
useEffect(() => {
|
|
1237
|
-
if (!visible) return undefined;
|
|
1238
|
-
const raycaster = new THREE.Raycaster();
|
|
1239
|
-
raycaster.layers.set(GIZMO_LAYER);
|
|
1240
|
-
const rayFrom = (event) => {
|
|
1241
|
-
const pane = paneRef.current;
|
|
1242
|
-
const camera = camRef.current;
|
|
1243
|
-
if (!pane || !camera) return null;
|
|
1244
|
-
const bounds = pane.getBoundingClientRect();
|
|
1245
|
-
if (bounds.width < 2 || bounds.height < 2) return null;
|
|
1246
|
-
const ndc = new THREE.Vector2(
|
|
1247
|
-
((event.clientX - bounds.left) / bounds.width) * 2 - 1,
|
|
1248
|
-
-((event.clientY - bounds.top) / bounds.height) * 2 + 1,
|
|
1249
|
-
);
|
|
1250
|
-
if (Math.abs(ndc.x) > 1 || Math.abs(ndc.y) > 1) return null;
|
|
1251
|
-
camera.updateMatrixWorld();
|
|
1252
|
-
raycaster.setFromCamera(ndc, camera);
|
|
1253
|
-
return raycaster;
|
|
1254
|
-
};
|
|
1255
|
-
const onDown = (event) => {
|
|
1256
|
-
const s = stateRef.current;
|
|
1257
|
-
if (event.button !== 0 || event.altKey) return;
|
|
1258
|
-
if (!groupRef.current || !rayFrom(event)) return;
|
|
1259
|
-
const hit = raycaster.intersectObjects(groupRef.current.children, true)
|
|
1260
|
-
.find((entry) => {
|
|
1261
|
-
for (let node = entry.object; node; node = node.parent) if (node.userData?.keyLightPick) return true;
|
|
1262
|
-
return false;
|
|
1263
|
-
});
|
|
1264
|
-
if (!hit) return;
|
|
1265
|
-
event.stopImmediatePropagation();
|
|
1266
|
-
event.preventDefault();
|
|
1267
|
-
s.onSelect?.();
|
|
1268
|
-
const start = { ...s.keyLight };
|
|
1269
|
-
const origin = new THREE.Vector3(start.x, start.y, start.z);
|
|
1270
|
-
// free move on the camera-facing plane through the sun: the cursor
|
|
1271
|
-
// and the sun stay glued from any viewing angle — no degenerate
|
|
1272
|
-
// floor-plane geometry when the sun is viewed from below
|
|
1273
|
-
const facing = new THREE.Vector3();
|
|
1274
|
-
camRef.current.getWorldDirection(facing);
|
|
1275
|
-
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(facing, origin);
|
|
1276
|
-
const hitStart = new THREE.Vector3();
|
|
1277
|
-
if (!raycaster.ray.intersectPlane(plane, hitStart)) return;
|
|
1278
|
-
dragRef.current = { start, plane, hitStart, moved: false };
|
|
1279
|
-
setDragKind("move");
|
|
1280
|
-
invalidate();
|
|
1281
|
-
};
|
|
1282
|
-
const onMove = (event) => {
|
|
1283
|
-
const drag = dragRef.current;
|
|
1284
|
-
if (!drag || !rayFrom(event)) return;
|
|
1285
|
-
const world = new THREE.Vector3();
|
|
1286
|
-
if (!raycaster.ray.intersectPlane(drag.plane, world)) return;
|
|
1287
|
-
drag.moved = true;
|
|
1288
|
-
stateRef.current.onChange?.({
|
|
1289
|
-
x: drag.start.x + (world.x - drag.hitStart.x),
|
|
1290
|
-
y: drag.start.y + (world.y - drag.hitStart.y),
|
|
1291
|
-
z: drag.start.z + (world.z - drag.hitStart.z),
|
|
1292
|
-
}, { dragging: true });
|
|
1293
|
-
invalidate();
|
|
1294
|
-
};
|
|
1295
|
-
const onUp = () => {
|
|
1296
|
-
const drag = dragRef.current;
|
|
1297
|
-
if (!drag) return;
|
|
1298
|
-
dragRef.current = null;
|
|
1299
|
-
setDragKind(null);
|
|
1300
|
-
if (drag.moved) stateRef.current.onDragEnd?.();
|
|
1301
|
-
invalidate();
|
|
1302
|
-
};
|
|
1303
|
-
const el = gl.domElement;
|
|
1304
|
-
el.addEventListener("pointerdown", onDown);
|
|
1305
|
-
window.addEventListener("pointermove", onMove, true);
|
|
1306
|
-
window.addEventListener("pointerup", onUp, true);
|
|
1307
|
-
return () => {
|
|
1308
|
-
el.removeEventListener("pointerdown", onDown);
|
|
1309
|
-
window.removeEventListener("pointermove", onMove, true);
|
|
1310
|
-
window.removeEventListener("pointerup", onUp, true);
|
|
1311
|
-
};
|
|
1312
|
-
// paneRef/camRef are stable refs; handlers read live state through stateRef
|
|
1313
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1314
|
-
}, [visible, gl, invalidate]);
|
|
1315
|
-
const drop = useMemo(() => new Float32Array([0, 0, 0, 0, -keyLight.y, 0]), [keyLight.y]);
|
|
1316
|
-
// the sun dims with its own dial, so the stage's mood reads off the puck
|
|
1317
|
-
const glow = Math.max(0.18, Math.min(1, keyLight.intensity / 1.12));
|
|
1318
|
-
const sunColor = useMemo(
|
|
1319
|
-
() => new THREE.Color("#5c5040").lerp(new THREE.Color(selected ? "#ffcf5e" : "#f2b544"), glow).getStyle(),
|
|
1320
|
-
[selected, glow],
|
|
1321
|
-
);
|
|
1322
|
-
if (!visible) return null;
|
|
1323
|
-
return (
|
|
1324
|
-
<group ref={groupRef} position={[keyLight.x, keyLight.y, keyLight.z]}>
|
|
1325
|
-
<mesh userData={{ keyLightPick: true }}>
|
|
1326
|
-
<sphereGeometry args={[0.16, 20, 16]} />
|
|
1327
|
-
<meshBasicMaterial color={sunColor} toneMapped={false} />
|
|
1328
|
-
</mesh>
|
|
1329
|
-
{/* rays: eight short spokes so it reads as a sun, not a ball */}
|
|
1330
|
-
{Array.from({ length: 8 }, (_, i) => {
|
|
1331
|
-
const angle = (i / 8) * Math.PI * 2;
|
|
1332
|
-
return (
|
|
1333
|
-
<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]}>
|
|
1334
|
-
<cylinderGeometry args={[0.014, 0.014, 0.1, 6]} />
|
|
1335
|
-
<meshBasicMaterial color={sunColor} toneMapped={false} />
|
|
1336
|
-
</mesh>
|
|
1337
|
-
);
|
|
1338
|
-
})}
|
|
1339
|
-
{/* the real grab surface: generous, invisible, around the whole sun */}
|
|
1340
|
-
<mesh userData={{ keyLightPick: true }}>
|
|
1341
|
-
<sphereGeometry args={[0.42, 12, 10]} />
|
|
1342
|
-
<meshBasicMaterial visible={false} />
|
|
1343
|
-
</mesh>
|
|
1344
|
-
<lineSegments>
|
|
1345
|
-
<bufferGeometry>
|
|
1346
|
-
<bufferAttribute attach="attributes-position" array={drop} count={2} itemSize={3} />
|
|
1347
|
-
</bufferGeometry>
|
|
1348
|
-
<lineBasicMaterial color="#f2b544" transparent opacity={selected ? 0.6 : 0.25} />
|
|
1349
|
-
</lineSegments>
|
|
1350
|
-
{/* where the light points: a dashed ray toward the stage centre */}
|
|
1351
|
-
{selected && (
|
|
1352
|
-
<Line
|
|
1353
|
-
points={[[0, 0, 0], [-keyLight.x * 0.86, -keyLight.y * 0.86, -keyLight.z * 0.86]]}
|
|
1354
|
-
color="#ffb454"
|
|
1355
|
-
lineWidth={1.6}
|
|
1356
|
-
dashed
|
|
1357
|
-
dashSize={0.32}
|
|
1358
|
-
gapSize={0.22}
|
|
1359
|
-
transparent
|
|
1360
|
-
opacity={0.45}
|
|
1361
|
-
/>
|
|
1362
|
-
)}
|
|
1363
|
-
{/* live readout while the sun is being dragged */}
|
|
1364
|
-
{dragKind && (
|
|
1365
|
-
<GizmoLabel
|
|
1366
|
-
position={[0, 0.5, 0]}
|
|
1367
|
-
text={`x ${keyLight.x.toFixed(1)} y ${keyLight.y.toFixed(1)} z ${keyLight.z.toFixed(1)}`}
|
|
1368
|
-
camRef={camRef}
|
|
1369
|
-
/>
|
|
1370
|
-
)}
|
|
1371
|
-
</group>
|
|
1372
|
-
);
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
/**
|
|
1376
|
-
* The shot camera drawn as an object in the editor view: a body and a short
|
|
1377
|
-
* frustum wireframe on GIZMO_LAYER, synced from the live camera every frame.
|
|
1378
|
-
* Preview, capture and PlayView draws all drop GIZMO_LAYER, so the ghost can
|
|
1379
|
-
* never reach a recorded frame.
|
|
1380
|
-
*/
|
|
1381
|
-
export function ShotCameraGhost({ camRef, fovDeg, aspect, visible, selected }) {
|
|
1382
|
-
const groupRef = useRef(null);
|
|
1383
|
-
const invalidate = useThree((state) => state.invalidate);
|
|
1384
|
-
const frustum = useMemo(() => {
|
|
1385
|
-
const depth = 0.62;
|
|
1386
|
-
const h = Math.tan(THREE.MathUtils.degToRad(fovDeg) / 2) * depth;
|
|
1387
|
-
const w = h * aspect;
|
|
1388
|
-
const corners = [[-w, h], [w, h], [w, -h], [-w, -h]];
|
|
1389
|
-
const points = [];
|
|
1390
|
-
for (const [x, y] of corners) points.push(0, 0, 0, x, y, -depth);
|
|
1391
|
-
for (let i = 0; i < 4; i++) {
|
|
1392
|
-
const [ax, ay] = corners[i];
|
|
1393
|
-
const [bx, by] = corners[(i + 1) % 4];
|
|
1394
|
-
points.push(ax, ay, -depth, bx, by, -depth);
|
|
1395
|
-
}
|
|
1396
|
-
return new Float32Array(points);
|
|
1397
|
-
}, [fovDeg, aspect]);
|
|
1398
|
-
useEffect(() => {
|
|
1399
|
-
groupRef.current?.traverse((node) => node.layers?.set(GIZMO_LAYER));
|
|
1400
|
-
invalidate();
|
|
1401
|
-
}, [visible, frustum, selected, invalidate]);
|
|
1402
|
-
useFrame(() => {
|
|
1403
|
-
const cam = camRef.current;
|
|
1404
|
-
const group = groupRef.current;
|
|
1405
|
-
if (!cam || !group) return;
|
|
1406
|
-
group.position.copy(cam.position);
|
|
1407
|
-
group.quaternion.copy(cam.quaternion);
|
|
1408
|
-
});
|
|
1409
|
-
if (!visible) return null;
|
|
1410
|
-
return (
|
|
1411
|
-
<group ref={groupRef}>
|
|
1412
|
-
<mesh userData={{ shotCameraPick: true }} position={[0, 0, 0.06]}>
|
|
1413
|
-
<boxGeometry args={[0.15, 0.11, 0.2]} />
|
|
1414
|
-
<meshBasicMaterial color={selected ? "#ffb454" : "#3b6ea5"} />
|
|
1415
|
-
</mesh>
|
|
1416
|
-
{/* the lens: a short cone opening toward the view direction */}
|
|
1417
|
-
<mesh userData={{ shotCameraPick: true }} rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, -0.075]}>
|
|
1418
|
-
<coneGeometry args={[0.052, 0.09, 20, 1, true]} />
|
|
1419
|
-
<meshBasicMaterial color={selected ? "#e09a3e" : "#2f5a8a"} side={THREE.DoubleSide} />
|
|
1420
|
-
</mesh>
|
|
1421
|
-
<lineSegments key={`${fovDeg}:${aspect}`}>
|
|
1422
|
-
<bufferGeometry>
|
|
1423
|
-
<bufferAttribute attach="attributes-position" array={frustum} count={frustum.length / 3} itemSize={3} />
|
|
1424
|
-
</bufferGeometry>
|
|
1425
|
-
<lineBasicMaterial color={selected ? "#ffb454" : "#6f9cc9"} transparent opacity={0.85} />
|
|
1426
|
-
</lineSegments>
|
|
1427
|
-
</group>
|
|
1428
|
-
);
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
export function RenderLoopController({ stageRef }) {
|
|
1432
|
-
const frameloop = useThree((state) => state.frameloop);
|
|
1433
|
-
const setFrameloop = useThree((state) => state.setFrameloop);
|
|
1434
|
-
const invalidate = useThree((state) => state.invalidate);
|
|
1435
|
-
useEffect(() => {
|
|
1436
|
-
const apply = (next) => {
|
|
1437
|
-
setFrameloop(next ? "always" : "demand");
|
|
1438
|
-
if (next) invalidate();
|
|
1439
|
-
};
|
|
1440
|
-
const onActivity = (event) => apply(event.detail);
|
|
1441
|
-
window.addEventListener(RENDER_ACTIVITY_EVENT, onActivity);
|
|
1442
|
-
return () => window.removeEventListener(RENDER_ACTIVITY_EVENT, onActivity);
|
|
1443
|
-
}, [invalidate, setFrameloop]);
|
|
1444
|
-
useEffect(() => {
|
|
1445
|
-
if (stageRef.current) stageRef.current.dataset.actualRenderLoop = frameloop;
|
|
1446
|
-
}, [frameloop, stageRef]);
|
|
1447
|
-
return null;
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
/** GPU resets — sleep/wake, a driver restart, VRAM pressure from an export —
|
|
1451
|
-
* kill the WebGL context, and without listeners the stage goes black and
|
|
1452
|
-
* stays black with no message. preventDefault on webglcontextlost is the
|
|
1453
|
-
* documented opt-in for browser-driven restoration; on restore, kick the
|
|
1454
|
-
* demand loop so the first frame actually paints. */
|
|
1455
|
-
export function ContextLossGuard({ onLostChange }) {
|
|
1456
|
-
const gl = useThree((state) => state.gl);
|
|
1457
|
-
const invalidate = useThree((state) => state.invalidate);
|
|
1458
|
-
useEffect(() => {
|
|
1459
|
-
const canvas = gl.domElement;
|
|
1460
|
-
const onLost = (event) => {
|
|
1461
|
-
event.preventDefault();
|
|
1462
|
-
onLostChange(true);
|
|
1463
|
-
};
|
|
1464
|
-
const onRestored = () => {
|
|
1465
|
-
onLostChange(false);
|
|
1466
|
-
invalidate();
|
|
1467
|
-
};
|
|
1468
|
-
canvas.addEventListener("webglcontextlost", onLost);
|
|
1469
|
-
canvas.addEventListener("webglcontextrestored", onRestored);
|
|
1470
|
-
return () => {
|
|
1471
|
-
canvas.removeEventListener("webglcontextlost", onLost);
|
|
1472
|
-
canvas.removeEventListener("webglcontextrestored", onRestored);
|
|
1473
|
-
};
|
|
1474
|
-
}, [gl, invalidate, onLostChange]);
|
|
1475
|
-
return null;
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
|
-
export function ViewportLayoutInvalidator({ insetX, insetY, insetWidth, insetHeight, hierarchyWidth, sidebarWidth, timelineHeight, planZoom }) {
|
|
1479
|
-
const invalidate = useThree((state) => state.invalidate);
|
|
1480
|
-
useEffect(() => {
|
|
1481
|
-
// The pane frame is regular DOM while its picture is a scissored WebGL
|
|
1482
|
-
// viewport. In demand mode a drag can commit its final DOM position just
|
|
1483
|
-
// after the continuous loop stops, leaving the picture at the previous
|
|
1484
|
-
// coordinates. Redraw now and once more after layout has settled.
|
|
1485
|
-
invalidate();
|
|
1486
|
-
const frame = requestAnimationFrame(invalidate);
|
|
1487
|
-
return () => cancelAnimationFrame(frame);
|
|
1488
|
-
}, [invalidate, insetX, insetY, insetWidth, insetHeight, hierarchyWidth, sidebarWidth, timelineHeight, planZoom]);
|
|
1489
|
-
return null;
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
/** The authored root path on the set floor while path editing is on: numbered
|
|
1493
|
-
pins connected in walk order. Lives on the gizmo layer, so the shot camera
|
|
1494
|
-
shows it but exports never do (the bird's-eye board draws its own copy). */
|
|
1495
|
-
/**
|
|
1496
|
-
* The drawn camera rail in the 3D Scene view: editor furniture on the gizmo
|
|
1497
|
-
* layer, so PlayView, the ink prepass and exports never see it.
|
|
1498
|
-
*/
|
|
1499
|
-
export function CameraRailScenePreview({ points, cumLen, length, crane }) {
|
|
1500
|
-
const rootRef = useRef(null);
|
|
1501
|
-
const lines = useMemo(() => {
|
|
1502
|
-
if (!points || points.length < 2) return null;
|
|
1503
|
-
const floor = points.map((point) => [point.x, 0.03, point.z]);
|
|
1504
|
-
// A craned rail shows WHERE THE LENS RIDES: the same path lifted along
|
|
1505
|
-
// the crane's start→end heights by arc progress, with the floor line
|
|
1506
|
-
// kept as the track's ground projection.
|
|
1507
|
-
const lifted = crane && cumLen && length > 1e-9
|
|
1508
|
-
? points.map((point, i) => [point.x, craneHeightAt(crane, cumLen[i] / length), point.z])
|
|
1509
|
-
: null;
|
|
1510
|
-
return { floor, lifted };
|
|
1511
|
-
}, [points, cumLen, length, crane]);
|
|
1512
|
-
useEffect(() => {
|
|
1513
|
-
rootRef.current?.traverse((node) => node.layers.set(GIZMO_LAYER));
|
|
1514
|
-
});
|
|
1515
|
-
if (!lines) return null;
|
|
1516
|
-
// drei's Line renders screen-space fat lines: a WebGL <line> is always
|
|
1517
|
-
// 1px, which reads as a hairline on dense displays.
|
|
1518
|
-
return (
|
|
1519
|
-
<group ref={rootRef}>
|
|
1520
|
-
<Line points={lines.floor} color="#a78bfa" lineWidth={2} transparent opacity={lines.lifted ? 0.35 : 0.8} depthWrite={false} />
|
|
1521
|
-
{lines.lifted && (
|
|
1522
|
-
<Line points={lines.lifted} color="#a78bfa" lineWidth={2.5} transparent opacity={0.9} depthWrite={false} />
|
|
1523
|
-
)}
|
|
1524
|
-
</group>
|
|
1525
|
-
);
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
/**
|
|
1530
|
-
* The crane's height marks as grabbable dots on the lifted curve. Click a
|
|
1531
|
-
* dot to select it; the selected dot grows a 3-axis gizmo — Y sets the
|
|
1532
|
-
* mark's height, X/Z bend the camera path itself by moving the drawn rail
|
|
1533
|
-
* control point nearest the mark. A free drag on the dot rides a
|
|
1534
|
-
* camera-facing vertical plane (height, plus arc slide for interior marks),
|
|
1535
|
-
* Shift-drag slides along the arc at a fixed height, double-click the curve
|
|
1536
|
-
* adds a mark and Delete removes a non-endpoint. Dots live on GIZMO_LAYER, so the
|
|
1537
|
-
* recording never sees them.
|
|
1538
|
-
*/
|
|
1539
|
-
export const CRANE_AXES = [
|
|
1540
|
-
{ axis: "x", dir: new THREE.Vector3(1, 0, 0), color: "#ff5340" },
|
|
1541
|
-
{ axis: "y", dir: new THREE.Vector3(0, 1, 0), color: "#54e05c" },
|
|
1542
|
-
{ axis: "z", dir: new THREE.Vector3(0, 0, 1), color: "#3d8bff" },
|
|
1543
|
-
];
|
|
1544
|
-
export const CRANE_ARROW_LEN = 0.5;
|
|
1545
|
-
export const CRANE_GIZMO_SCALE = 0.16; // metres of gizmo per metre of camera distance
|
|
1546
|
-
|
|
1547
|
-
/**
|
|
1548
|
-
* Prepare an x/z rail bend for one crane mark: a copy of the drawn control
|
|
1549
|
-
* points (with one inserted at the mark's base when none is close enough to
|
|
1550
|
-
* anchor the bend) plus a per-point weight — a cosine bump centred on the
|
|
1551
|
-
* anchor that fades to zero by the neighbouring crane marks, so dragging one
|
|
1552
|
-
* mark can never tow the marks beside it. For an interior mark both rail
|
|
1553
|
-
* ends are pinned outright.
|
|
1554
|
-
*/
|
|
1555
|
-
export function prepareRailBend(controlPoints, base, marks, markIndex) {
|
|
1556
|
-
const controls = (controlPoints ?? []).map((entry) => ({ ...entry }));
|
|
1557
|
-
if (controls.length < 2) return null;
|
|
1558
|
-
// nearest control point / segment to the mark's base
|
|
1559
|
-
let nearestIdx = 0;
|
|
1560
|
-
let nearestD = Infinity;
|
|
1561
|
-
for (let i = 0; i < controls.length; i += 1) {
|
|
1562
|
-
const d = Math.hypot(controls[i].x - base.x, controls[i].z - base.z);
|
|
1563
|
-
if (d < nearestD) {
|
|
1564
|
-
nearestD = d;
|
|
1565
|
-
nearestIdx = i;
|
|
1566
|
-
}
|
|
1567
|
-
}
|
|
1568
|
-
const interior = markIndex > 0 && markIndex < marks.length - 1;
|
|
1569
|
-
let anchorIdx = nearestIdx;
|
|
1570
|
-
if (interior && nearestD > 0.25) {
|
|
1571
|
-
// no control point near the mark: bend needs a vertex to pull, so one
|
|
1572
|
-
// is seeded at the base, on the segment the base projects onto
|
|
1573
|
-
let segmentIdx = 0;
|
|
1574
|
-
let segmentD = Infinity;
|
|
1575
|
-
for (let i = 0; i < controls.length - 1; i += 1) {
|
|
1576
|
-
const ax = controls[i].x;
|
|
1577
|
-
const az = controls[i].z;
|
|
1578
|
-
const bx = controls[i + 1].x;
|
|
1579
|
-
const bz = controls[i + 1].z;
|
|
1580
|
-
const lenSq = Math.max((bx - ax) ** 2 + (bz - az) ** 2, 1e-9);
|
|
1581
|
-
const u = Math.max(0, Math.min(1, ((base.x - ax) * (bx - ax) + (base.z - az) * (bz - az)) / lenSq));
|
|
1582
|
-
const d = Math.hypot(ax + (bx - ax) * u - base.x, az + (bz - az) * u - base.z);
|
|
1583
|
-
if (d < segmentD) {
|
|
1584
|
-
segmentD = d;
|
|
1585
|
-
segmentIdx = i;
|
|
1586
|
-
}
|
|
1587
|
-
}
|
|
1588
|
-
controls.splice(segmentIdx + 1, 0, { x: base.x, z: base.z });
|
|
1589
|
-
anchorIdx = segmentIdx + 1;
|
|
1590
|
-
}
|
|
1591
|
-
// normalized arc parameter of each control point along the control polygon
|
|
1592
|
-
const ts = [0];
|
|
1593
|
-
for (let i = 1; i < controls.length; i += 1) {
|
|
1594
|
-
ts.push(ts[i - 1] + Math.hypot(controls[i].x - controls[i - 1].x, controls[i].z - controls[i - 1].z));
|
|
1595
|
-
}
|
|
1596
|
-
const total = Math.max(ts[ts.length - 1], 1e-9);
|
|
1597
|
-
for (let i = 0; i < ts.length; i += 1) ts[i] /= total;
|
|
1598
|
-
// the bump reaches exactly to the neighbouring crane marks
|
|
1599
|
-
const t0 = ts[anchorIdx];
|
|
1600
|
-
const previous = markIndex > 0 ? marks[markIndex - 1].t : null;
|
|
1601
|
-
const next = markIndex < marks.length - 1 ? marks[markIndex + 1].t : null;
|
|
1602
|
-
const radius = Math.max(0.15, Math.min(previous != null ? t0 - previous : 1, next != null ? next - t0 : 1));
|
|
1603
|
-
const weights = ts.map((t, i) => {
|
|
1604
|
-
if (interior && (i === 0 || i === ts.length - 1)) return 0;
|
|
1605
|
-
const d = Math.abs(t - t0);
|
|
1606
|
-
return d >= radius ? 0 : 0.5 * (1 + Math.cos((Math.PI * d) / radius));
|
|
1607
|
-
});
|
|
1608
|
-
return { startControls: controls, weights, t0, radius };
|
|
1609
|
-
}
|
|
1610
|
-
|
|
1611
|
-
/**
|
|
1612
|
-
* A floating readout beside a drag — billboarded, sized to the screen, on
|
|
1613
|
-
* GIZMO_LAYER so it is editor furniture the recording never sees.
|
|
1614
|
-
*/
|
|
1615
|
-
export function GizmoLabel({ position, text, camRef }) {
|
|
1616
|
-
const ref = useRef(null);
|
|
1617
|
-
useFrame(() => {
|
|
1618
|
-
const cam = camRef?.current;
|
|
1619
|
-
const node = ref.current;
|
|
1620
|
-
if (!cam || !node) return;
|
|
1621
|
-
node.quaternion.copy(cam.getWorldQuaternion(new THREE.Quaternion()));
|
|
1622
|
-
const world = node.getWorldPosition(new THREE.Vector3());
|
|
1623
|
-
node.scale.setScalar(Math.max(0.4, cam.getWorldPosition(new THREE.Vector3()).distanceTo(world) * 0.11));
|
|
1624
|
-
node.traverse((child) => child.layers?.set(GIZMO_LAYER));
|
|
1625
|
-
});
|
|
1626
|
-
return (
|
|
1627
|
-
<Text
|
|
1628
|
-
ref={ref}
|
|
1629
|
-
position={position}
|
|
1630
|
-
fontSize={0.19}
|
|
1631
|
-
color="#ffd27a"
|
|
1632
|
-
outlineWidth={0.014}
|
|
1633
|
-
outlineColor="#241a05"
|
|
1634
|
-
anchorX="center"
|
|
1635
|
-
anchorY="bottom"
|
|
1636
|
-
renderOrder={1000}
|
|
1637
|
-
material-depthTest={false}
|
|
1638
|
-
material-transparent
|
|
1639
|
-
>
|
|
1640
|
-
{text}
|
|
1641
|
-
</Text>
|
|
1642
|
-
);
|
|
1643
|
-
}
|
|
1644
|
-
|
|
1645
|
-
/**
|
|
1646
|
-
* A scene object's travel path in the 3D view: the route as a line, its points
|
|
1647
|
-
* as grabbable dots, and a 3-axis gizmo on the selected dot. The grammar is
|
|
1648
|
-
* the crane's on purpose — Top-View draws the floor route, the scene lifts and
|
|
1649
|
-
* nudges the individual points, so a plane can climb along its own path.
|
|
1650
|
-
*/
|
|
1651
|
-
export function ObjectPathHandles({ path, selectedIndex, enabled, paneRef, camRef, onSelect, onChangePoints, onDragStart, onDragEnd }) {
|
|
1652
|
-
const { gl } = useThree();
|
|
1653
|
-
const invalidate = useThree((state) => state.invalidate);
|
|
1654
|
-
const groupRef = useRef(null);
|
|
1655
|
-
const gizmoRef = useRef(null);
|
|
1656
|
-
const dragRef = useRef(null);
|
|
1657
|
-
const stateRef = useRef(null);
|
|
1658
|
-
const [dragging, setDragging] = useState(false);
|
|
1659
|
-
stateRef.current = { path, selectedIndex, enabled, onSelect, onChangePoints, onDragStart, onDragEnd };
|
|
1660
|
-
useFrame(() => {
|
|
1661
|
-
const gizmo = gizmoRef.current;
|
|
1662
|
-
const camera = camRef.current;
|
|
1663
|
-
if (!gizmo || !camera) return;
|
|
1664
|
-
const world = gizmo.getWorldPosition(new THREE.Vector3());
|
|
1665
|
-
gizmo.scale.setScalar(Math.max(0.35, camera.getWorldPosition(new THREE.Vector3()).distanceTo(world) * CRANE_GIZMO_SCALE));
|
|
1666
|
-
});
|
|
1667
|
-
useEffect(() => {
|
|
1668
|
-
groupRef.current?.traverse((node) => node.layers?.set(GIZMO_LAYER));
|
|
1669
|
-
invalidate();
|
|
1670
|
-
}, [path, selectedIndex, enabled, dragging, invalidate]);
|
|
1671
|
-
useEffect(() => {
|
|
1672
|
-
if (!enabled) return undefined;
|
|
1673
|
-
const raycaster = new THREE.Raycaster();
|
|
1674
|
-
raycaster.layers.set(GIZMO_LAYER);
|
|
1675
|
-
const rayFrom = (event) => {
|
|
1676
|
-
const pane = paneRef.current;
|
|
1677
|
-
const camera = camRef.current;
|
|
1678
|
-
if (!pane || !camera) return null;
|
|
1679
|
-
const bounds = pane.getBoundingClientRect();
|
|
1680
|
-
if (bounds.width < 2 || bounds.height < 2) return null;
|
|
1681
|
-
const ndc = new THREE.Vector2(
|
|
1682
|
-
((event.clientX - bounds.left) / bounds.width) * 2 - 1,
|
|
1683
|
-
-((event.clientY - bounds.top) / bounds.height) * 2 + 1,
|
|
1684
|
-
);
|
|
1685
|
-
if (Math.abs(ndc.x) > 1 || Math.abs(ndc.y) > 1) return null;
|
|
1686
|
-
camera.updateMatrixWorld();
|
|
1687
|
-
raycaster.setFromCamera(ndc, camera);
|
|
1688
|
-
return raycaster;
|
|
1689
|
-
};
|
|
1690
|
-
const onDown = (event) => {
|
|
1691
|
-
const s = stateRef.current;
|
|
1692
|
-
if (!s.enabled || !s.path || event.button !== 0) return;
|
|
1693
|
-
if (!groupRef.current || !rayFrom(event)) return;
|
|
1694
|
-
const hits = raycaster.intersectObjects(groupRef.current.children, true);
|
|
1695
|
-
// The axis gizmo outranks the dots, exactly like the crane's.
|
|
1696
|
-
const axisHit = hits.find((entry) => entry.object.userData?.pathAxis);
|
|
1697
|
-
if (axisHit && s.selectedIndex != null && s.path.points[s.selectedIndex]) {
|
|
1698
|
-
const axis = axisHit.object.userData.pathAxis;
|
|
1699
|
-
const point = s.path.points[s.selectedIndex];
|
|
1700
|
-
const origin = new THREE.Vector3(point.x, point.y, point.z);
|
|
1701
|
-
const dir = CRANE_AXES.find((entry) => entry.axis === axis).dir;
|
|
1702
|
-
const eye = new THREE.Vector3();
|
|
1703
|
-
camRef.current.getWorldDirection(eye);
|
|
1704
|
-
const normal = dir.clone().cross(eye).cross(dir);
|
|
1705
|
-
if (normal.lengthSq() < 1e-6) return;
|
|
1706
|
-
normal.normalize();
|
|
1707
|
-
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, origin);
|
|
1708
|
-
const hitStart = new THREE.Vector3();
|
|
1709
|
-
if (!raycaster.ray.intersectPlane(plane, hitStart)) return;
|
|
1710
|
-
event.stopImmediatePropagation();
|
|
1711
|
-
event.preventDefault();
|
|
1712
|
-
dragRef.current = { axis, index: s.selectedIndex, dir: dir.clone(), plane, hitStart, start: { ...point }, recorded: false };
|
|
1713
|
-
setDragging(true);
|
|
1714
|
-
invalidate();
|
|
1715
|
-
return;
|
|
1716
|
-
}
|
|
1717
|
-
const hit = hits.find((entry) => entry.object.userData?.pathIndex !== undefined);
|
|
1718
|
-
if (!hit) return;
|
|
1719
|
-
event.stopImmediatePropagation();
|
|
1720
|
-
event.preventDefault();
|
|
1721
|
-
const index = hit.object.userData.pathIndex;
|
|
1722
|
-
s.onSelect(index);
|
|
1723
|
-
// A press on the dot itself moves it freely on the camera plane —
|
|
1724
|
-
// the sun puck's grammar, so a point can be nudged without aiming.
|
|
1725
|
-
const point = s.path.points[index];
|
|
1726
|
-
const origin = new THREE.Vector3(point.x, point.y, point.z);
|
|
1727
|
-
const facing = new THREE.Vector3();
|
|
1728
|
-
camRef.current.getWorldDirection(facing);
|
|
1729
|
-
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(facing, origin);
|
|
1730
|
-
const hitStart = new THREE.Vector3();
|
|
1731
|
-
if (!raycaster.ray.intersectPlane(plane, hitStart)) return;
|
|
1732
|
-
dragRef.current = { axis: null, index, plane, hitStart, start: { ...point }, recorded: false };
|
|
1733
|
-
setDragging(true);
|
|
1734
|
-
invalidate();
|
|
1735
|
-
};
|
|
1736
|
-
const onMove = (event) => {
|
|
1737
|
-
const s = stateRef.current;
|
|
1738
|
-
const drag = dragRef.current;
|
|
1739
|
-
if (!drag || !s.enabled || !s.path) return;
|
|
1740
|
-
if (!rayFrom(event)) return;
|
|
1741
|
-
const world = new THREE.Vector3();
|
|
1742
|
-
if (!raycaster.ray.intersectPlane(drag.plane, world)) return;
|
|
1743
|
-
if (!drag.recorded) {
|
|
1744
|
-
s.onDragStart?.();
|
|
1745
|
-
drag.recorded = true;
|
|
1746
|
-
}
|
|
1747
|
-
const points = s.path.points.map((entry) => ({ ...entry }));
|
|
1748
|
-
if (drag.axis) {
|
|
1749
|
-
const travel = world.clone().sub(drag.hitStart).dot(drag.dir);
|
|
1750
|
-
const axis = drag.axis;
|
|
1751
|
-
points[drag.index][axis] = drag.start[axis] + travel;
|
|
1752
|
-
} else {
|
|
1753
|
-
points[drag.index] = {
|
|
1754
|
-
x: drag.start.x + (world.x - drag.hitStart.x),
|
|
1755
|
-
y: drag.start.y + (world.y - drag.hitStart.y),
|
|
1756
|
-
z: drag.start.z + (world.z - drag.hitStart.z),
|
|
1757
|
-
};
|
|
1758
|
-
}
|
|
1759
|
-
if (points[drag.index].y < 0) points[drag.index].y = 0;
|
|
1760
|
-
s.onChangePoints(points, { dragging: true });
|
|
1761
|
-
invalidate();
|
|
1762
|
-
};
|
|
1763
|
-
const onUp = () => {
|
|
1764
|
-
const drag = dragRef.current;
|
|
1765
|
-
dragRef.current = null;
|
|
1766
|
-
setDragging(false);
|
|
1767
|
-
if (drag?.recorded) stateRef.current.onDragEnd?.();
|
|
1768
|
-
invalidate();
|
|
1769
|
-
};
|
|
1770
|
-
const paneScreen = (world) => {
|
|
1771
|
-
const pane = paneRef.current?.getBoundingClientRect();
|
|
1772
|
-
if (!pane) return null;
|
|
1773
|
-
const projected = world.project(camRef.current);
|
|
1774
|
-
return projected.z > 1 ? null : {
|
|
1775
|
-
x: pane.left + ((projected.x + 1) / 2) * pane.width,
|
|
1776
|
-
y: pane.top + ((1 - projected.y) / 2) * pane.height,
|
|
1777
|
-
};
|
|
1778
|
-
};
|
|
1779
|
-
// Double-click the route to drop a point mid-path — the crane curve's
|
|
1780
|
-
// gesture, so one habit covers both. The new point lands ON the line,
|
|
1781
|
-
// so adding it never changes where the object goes; it only gives the
|
|
1782
|
-
// next drag something to grab in the middle.
|
|
1783
|
-
const onDouble = (event) => {
|
|
1784
|
-
const s = stateRef.current;
|
|
1785
|
-
if (!s.enabled || !s.path || !camRef.current) return;
|
|
1786
|
-
const points = s.path.points;
|
|
1787
|
-
if (points.length >= MAX_PATH_POINTS) return;
|
|
1788
|
-
camRef.current.updateMatrixWorld();
|
|
1789
|
-
let best = null;
|
|
1790
|
-
for (let i = 0; i < points.length - 1; i += 1) {
|
|
1791
|
-
const a = paneScreen(new THREE.Vector3(points[i].x, points[i].y ?? 0, points[i].z));
|
|
1792
|
-
const b = paneScreen(new THREE.Vector3(points[i + 1].x, points[i + 1].y ?? 0, points[i + 1].z));
|
|
1793
|
-
if (!a || !b) continue;
|
|
1794
|
-
const dx = b.x - a.x;
|
|
1795
|
-
const dy = b.y - a.y;
|
|
1796
|
-
const lenSq = dx * dx + dy * dy;
|
|
1797
|
-
const t = lenSq < 1e-6 ? 0 : Math.min(1, Math.max(0, ((event.clientX - a.x) * dx + (event.clientY - a.y) * dy) / lenSq));
|
|
1798
|
-
const d = Math.hypot(a.x + dx * t - event.clientX, a.y + dy * t - event.clientY);
|
|
1799
|
-
if (!best || d < best.d) best = { d, index: i, t };
|
|
1800
|
-
}
|
|
1801
|
-
if (!best || best.d > 14) return;
|
|
1802
|
-
// Refuse a point that would sit on top of a neighbour: a zero-length
|
|
1803
|
-
// segment is not a handle, it is a duplicate the schema would drop.
|
|
1804
|
-
if (best.t < 0.02 || best.t > 0.98) return;
|
|
1805
|
-
event.stopImmediatePropagation();
|
|
1806
|
-
event.preventDefault();
|
|
1807
|
-
const a = points[best.index];
|
|
1808
|
-
const b = points[best.index + 1];
|
|
1809
|
-
const inserted = {
|
|
1810
|
-
x: a.x + (b.x - a.x) * best.t,
|
|
1811
|
-
y: (a.y ?? 0) + ((b.y ?? 0) - (a.y ?? 0)) * best.t,
|
|
1812
|
-
z: a.z + (b.z - a.z) * best.t,
|
|
1813
|
-
};
|
|
1814
|
-
const next = [...points.slice(0, best.index + 1), inserted, ...points.slice(best.index + 1)];
|
|
1815
|
-
s.onDragStart?.();
|
|
1816
|
-
s.onChangePoints(next);
|
|
1817
|
-
s.onDragEnd?.();
|
|
1818
|
-
s.onSelect(best.index + 1);
|
|
1819
|
-
invalidate();
|
|
1820
|
-
};
|
|
1821
|
-
// Delete removes the selected point, never the object: while a point is
|
|
1822
|
-
// selected this handler owns the key, and the scene-object delete
|
|
1823
|
-
// handler stands down (it checks the same selection).
|
|
1824
|
-
const onKey = (event) => {
|
|
1825
|
-
const s = stateRef.current;
|
|
1826
|
-
if (!s.enabled || !s.path || s.selectedIndex == null) return;
|
|
1827
|
-
if (event.key !== "Delete" && event.key !== "Backspace") return;
|
|
1828
|
-
if (document.activeElement && /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) return;
|
|
1829
|
-
event.preventDefault();
|
|
1830
|
-
event.stopImmediatePropagation();
|
|
1831
|
-
const remaining = s.path.points.filter((_, index) => index !== s.selectedIndex);
|
|
1832
|
-
// Two points are the least a route can be; the last removal clears
|
|
1833
|
-
// the whole path instead of leaving a stub that cannot be walked.
|
|
1834
|
-
s.onDragStart?.();
|
|
1835
|
-
s.onChangePoints(remaining.length >= 2 ? remaining : null);
|
|
1836
|
-
s.onDragEnd?.();
|
|
1837
|
-
s.onSelect(null);
|
|
1838
|
-
invalidate();
|
|
1839
|
-
};
|
|
1840
|
-
const el = gl.domElement;
|
|
1841
|
-
el.addEventListener("pointerdown", onDown);
|
|
1842
|
-
window.addEventListener("pointermove", onMove, true);
|
|
1843
|
-
window.addEventListener("pointerup", onUp, true);
|
|
1844
|
-
el.addEventListener("dblclick", onDouble, true);
|
|
1845
|
-
window.addEventListener("keydown", onKey, true);
|
|
1846
|
-
return () => {
|
|
1847
|
-
el.removeEventListener("pointerdown", onDown);
|
|
1848
|
-
window.removeEventListener("pointermove", onMove, true);
|
|
1849
|
-
window.removeEventListener("pointerup", onUp, true);
|
|
1850
|
-
el.removeEventListener("dblclick", onDouble, true);
|
|
1851
|
-
window.removeEventListener("keydown", onKey, true);
|
|
1852
|
-
};
|
|
1853
|
-
// paneRef/camRef are stable; handlers read live state through stateRef
|
|
1854
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1855
|
-
}, [enabled, gl, invalidate]);
|
|
1856
|
-
const linePoints = useMemo(
|
|
1857
|
-
() => (path?.points ?? []).map((point) => [point.x, point.y + 0.02, point.z]),
|
|
1858
|
-
[path],
|
|
1859
|
-
);
|
|
1860
|
-
if (!enabled || !path || linePoints.length < 2) return null;
|
|
1861
|
-
const selected = selectedIndex != null ? path.points[selectedIndex] : null;
|
|
1862
|
-
return (
|
|
1863
|
-
<group ref={groupRef}>
|
|
1864
|
-
<Line points={linePoints} color="#6fcf97" lineWidth={2.5} transparent opacity={0.9} />
|
|
1865
|
-
{selected && (
|
|
1866
|
-
<group ref={gizmoRef} position={[selected.x, selected.y, selected.z]} renderOrder={999}>
|
|
1867
|
-
{CRANE_AXES.map(({ axis, dir, color }) => {
|
|
1868
|
-
const quat = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
|
|
1869
|
-
return (
|
|
1870
|
-
<group key={axis} quaternion={quat}>
|
|
1871
|
-
<mesh position={[0, CRANE_ARROW_LEN / 2 + 0.08, 0]} renderOrder={999}>
|
|
1872
|
-
<cylinderGeometry args={[0.016, 0.016, CRANE_ARROW_LEN, 8]} />
|
|
1873
|
-
<meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.2} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={0.95} />
|
|
1874
|
-
</mesh>
|
|
1875
|
-
<mesh position={[0, CRANE_ARROW_LEN + 0.08 + 0.07, 0]} renderOrder={999}>
|
|
1876
|
-
<coneGeometry args={[0.05, 0.14, 14]} />
|
|
1877
|
-
<meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.2} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={0.95} />
|
|
1878
|
-
</mesh>
|
|
1879
|
-
<mesh position={[0, (CRANE_ARROW_LEN + 0.14) / 2 + 0.08, 0]} userData={{ pathAxis: axis }}>
|
|
1880
|
-
<cylinderGeometry args={[0.09, 0.09, CRANE_ARROW_LEN + 0.14, 8]} />
|
|
1881
|
-
<meshBasicMaterial visible={false} />
|
|
1882
|
-
</mesh>
|
|
1883
|
-
</group>
|
|
1884
|
-
);
|
|
1885
|
-
})}
|
|
1886
|
-
</group>
|
|
1887
|
-
)}
|
|
1888
|
-
{path.points.map((point, index) => (
|
|
1889
|
-
<group key={`${index}:${path.points.length}`} position={[point.x, point.y, point.z]}>
|
|
1890
|
-
<mesh userData={{ pathIndex: index }} renderOrder={998}>
|
|
1891
|
-
<sphereGeometry args={[0.075, 14, 10]} />
|
|
1892
|
-
<meshBasicMaterial color={index === selectedIndex ? "#ffb454" : "#6fcf97"} depthTest={false} transparent opacity={0.95} />
|
|
1893
|
-
</mesh>
|
|
1894
|
-
<mesh userData={{ pathIndex: index }}>
|
|
1895
|
-
<sphereGeometry args={[0.22, 10, 8]} />
|
|
1896
|
-
<meshBasicMaterial visible={false} />
|
|
1897
|
-
</mesh>
|
|
1898
|
-
</group>
|
|
1899
|
-
))}
|
|
1900
|
-
{dragging && selected && (
|
|
1901
|
-
<GizmoLabel
|
|
1902
|
-
position={[selected.x, selected.y + 0.42, selected.z]}
|
|
1903
|
-
text={`x ${selected.x.toFixed(1)} y ${selected.y.toFixed(1)} z ${selected.z.toFixed(1)}`}
|
|
1904
|
-
camRef={camRef}
|
|
1905
|
-
/>
|
|
1906
|
-
)}
|
|
1907
|
-
</group>
|
|
1908
|
-
);
|
|
1909
|
-
}
|
|
1910
|
-
|
|
1911
|
-
export function CraneHandles({ rail, crane, controlPoints, selectedIndex, enabled, paneRef, camRef, onSelect, onChangePoints, onChangeRail, onDragStart, onDragEnd }) {
|
|
1912
|
-
const { gl } = useThree();
|
|
1913
|
-
const invalidate = useThree((state) => state.invalidate);
|
|
1914
|
-
const groupRef = useRef(null);
|
|
1915
|
-
const gizmoRef = useRef(null);
|
|
1916
|
-
const dragRef = useRef(null);
|
|
1917
|
-
const stateRef = useRef(null);
|
|
1918
|
-
// What the pointer is doing right now, for the in-scene feedback layer:
|
|
1919
|
-
// height-ish drags float a metre readout, a bend drag lights up the arc
|
|
1920
|
-
// span the bump will actually deform.
|
|
1921
|
-
const [dragInfo, setDragInfo] = useState(null);
|
|
1922
|
-
stateRef.current = { rail, crane, controlPoints, selectedIndex, enabled, onSelect, onChangePoints, onChangeRail, onDragStart, onDragEnd };
|
|
1923
|
-
// Constant on-screen size for the axis gizmo: it is UI, not set dressing.
|
|
1924
|
-
useFrame(() => {
|
|
1925
|
-
const gizmo = gizmoRef.current;
|
|
1926
|
-
const camera = camRef.current;
|
|
1927
|
-
if (!gizmo || !camera) return;
|
|
1928
|
-
const world = gizmo.getWorldPosition(new THREE.Vector3());
|
|
1929
|
-
gizmo.scale.setScalar(Math.max(0.35, camera.getWorldPosition(new THREE.Vector3()).distanceTo(world) * CRANE_GIZMO_SCALE));
|
|
1930
|
-
});
|
|
1931
|
-
useEffect(() => {
|
|
1932
|
-
groupRef.current?.traverse((node) => node.layers?.set(GIZMO_LAYER));
|
|
1933
|
-
});
|
|
1934
|
-
useEffect(() => {
|
|
1935
|
-
if (!enabled) return undefined;
|
|
1936
|
-
const raycaster = new THREE.Raycaster();
|
|
1937
|
-
raycaster.layers.set(GIZMO_LAYER);
|
|
1938
|
-
const rayFrom = (event) => {
|
|
1939
|
-
const pane = paneRef.current;
|
|
1940
|
-
const camera = camRef.current;
|
|
1941
|
-
if (!pane || !camera) return null;
|
|
1942
|
-
const bounds = pane.getBoundingClientRect();
|
|
1943
|
-
if (bounds.width < 2 || bounds.height < 2) return null;
|
|
1944
|
-
const ndc = new THREE.Vector2(
|
|
1945
|
-
((event.clientX - bounds.left) / bounds.width) * 2 - 1,
|
|
1946
|
-
-((event.clientY - bounds.top) / bounds.height) * 2 + 1,
|
|
1947
|
-
);
|
|
1948
|
-
if (Math.abs(ndc.x) > 1 || Math.abs(ndc.y) > 1) return null;
|
|
1949
|
-
camera.updateMatrixWorld();
|
|
1950
|
-
raycaster.setFromCamera(ndc, camera);
|
|
1951
|
-
return raycaster;
|
|
1952
|
-
};
|
|
1953
|
-
const paneScreen = (world) => {
|
|
1954
|
-
const pane = paneRef.current.getBoundingClientRect();
|
|
1955
|
-
const projected = world.project(camRef.current);
|
|
1956
|
-
return projected.z > 1 ? null : {
|
|
1957
|
-
x: pane.left + ((projected.x + 1) / 2) * pane.width,
|
|
1958
|
-
y: pane.top + ((1 - projected.y) / 2) * pane.height,
|
|
1959
|
-
};
|
|
1960
|
-
};
|
|
1961
|
-
const onDown = (event) => {
|
|
1962
|
-
const s = stateRef.current;
|
|
1963
|
-
if (!s.enabled || !s.crane || event.button !== 0) return;
|
|
1964
|
-
if (!groupRef.current || !rayFrom(event)) return;
|
|
1965
|
-
const hits = raycaster.intersectObjects(groupRef.current.children, true);
|
|
1966
|
-
// The axis gizmo outranks the dots: its arrows sit on the selected
|
|
1967
|
-
// dot, and a press on an arrow is a directed move, not a reselect.
|
|
1968
|
-
const axisHit = hits.find((entry) => entry.object.userData?.craneAxis);
|
|
1969
|
-
if (axisHit && s.selectedIndex != null && s.crane.points[s.selectedIndex]) {
|
|
1970
|
-
const axis = axisHit.object.userData.craneAxis;
|
|
1971
|
-
const point = s.crane.points[s.selectedIndex];
|
|
1972
|
-
const base = railPoint(s.rail, point.t * s.rail.length);
|
|
1973
|
-
const origin = new THREE.Vector3(base.x, point.height, base.z);
|
|
1974
|
-
const dir = CRANE_AXES.find((entry) => entry.axis === axis).dir;
|
|
1975
|
-
const eye = new THREE.Vector3();
|
|
1976
|
-
camRef.current.getWorldDirection(eye);
|
|
1977
|
-
// slide plane: contains the axis and faces the camera as squarely
|
|
1978
|
-
// as it can — the same construction the object gizmo uses
|
|
1979
|
-
const normal = dir.clone().cross(eye).cross(dir);
|
|
1980
|
-
if (normal.lengthSq() < 1e-6) return;
|
|
1981
|
-
normal.normalize();
|
|
1982
|
-
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, origin);
|
|
1983
|
-
const hitStart = new THREE.Vector3();
|
|
1984
|
-
if (!raycaster.ray.intersectPlane(plane, hitStart)) return;
|
|
1985
|
-
// x/z bend the rail: a weighted bump around the mark's base, so
|
|
1986
|
-
// the path deforms locally and the marks beside it stay put.
|
|
1987
|
-
const bend = axis === "y" ? null : prepareRailBend(s.controlPoints, base, s.crane.points, s.selectedIndex);
|
|
1988
|
-
if (axis !== "y" && !bend) return;
|
|
1989
|
-
event.stopImmediatePropagation();
|
|
1990
|
-
event.preventDefault();
|
|
1991
|
-
dragRef.current = {
|
|
1992
|
-
axis,
|
|
1993
|
-
index: s.selectedIndex,
|
|
1994
|
-
dir: dir.clone(),
|
|
1995
|
-
plane,
|
|
1996
|
-
hitStart,
|
|
1997
|
-
startHeight: point.height,
|
|
1998
|
-
startControls: bend?.startControls ?? null,
|
|
1999
|
-
bendWeights: bend?.weights ?? null,
|
|
2000
|
-
recorded: false,
|
|
2001
|
-
};
|
|
2002
|
-
setDragInfo(axis === "y" ? { kind: "height" } : { kind: "bend", t0: bend.t0, radius: bend.radius });
|
|
2003
|
-
invalidate();
|
|
2004
|
-
return;
|
|
2005
|
-
}
|
|
2006
|
-
const hit = hits.find((entry) => entry.object.userData?.craneIndex !== undefined);
|
|
2007
|
-
if (!hit) return;
|
|
2008
|
-
event.stopImmediatePropagation();
|
|
2009
|
-
event.preventDefault();
|
|
2010
|
-
const index = hit.object.userData.craneIndex;
|
|
2011
|
-
s.onSelect(index);
|
|
2012
|
-
dragRef.current = { index, slide: event.shiftKey, recorded: false };
|
|
2013
|
-
setDragInfo({ kind: event.shiftKey ? "slide" : "free" });
|
|
2014
|
-
invalidate();
|
|
2015
|
-
};
|
|
2016
|
-
const onMove = (event) => {
|
|
2017
|
-
const s = stateRef.current;
|
|
2018
|
-
const drag = dragRef.current;
|
|
2019
|
-
if (!drag || !s.enabled || !s.crane || !s.rail) return;
|
|
2020
|
-
if (!rayFrom(event)) return;
|
|
2021
|
-
const points = s.crane.points;
|
|
2022
|
-
const point = points[drag.index];
|
|
2023
|
-
if (!point) return;
|
|
2024
|
-
if (!drag.recorded) {
|
|
2025
|
-
s.onDragStart?.();
|
|
2026
|
-
drag.recorded = true;
|
|
2027
|
-
}
|
|
2028
|
-
if (drag.axis) {
|
|
2029
|
-
const world = new THREE.Vector3();
|
|
2030
|
-
if (!raycaster.ray.intersectPlane(drag.plane, world)) return;
|
|
2031
|
-
const travel = world.sub(drag.hitStart).dot(drag.dir);
|
|
2032
|
-
if (drag.axis === "y") {
|
|
2033
|
-
const height = Math.max(0.1, Math.min(12, drag.startHeight + travel));
|
|
2034
|
-
s.onChangePoints(points.map((entry, i) => (i === drag.index ? { ...entry, height } : entry)), { dragging: true });
|
|
2035
|
-
} else if (drag.startControls && drag.bendWeights && s.onChangeRail) {
|
|
2036
|
-
const dx = drag.axis === "x" ? travel : 0;
|
|
2037
|
-
const dz = drag.axis === "z" ? travel : 0;
|
|
2038
|
-
const moved = drag.startControls.map((entry, i) => (drag.bendWeights[i] > 0
|
|
2039
|
-
? { ...entry, x: entry.x + dx * drag.bendWeights[i], z: entry.z + dz * drag.bendWeights[i] }
|
|
2040
|
-
: entry));
|
|
2041
|
-
s.onChangeRail(moved, { dragging: true });
|
|
2042
|
-
}
|
|
2043
|
-
invalidate();
|
|
2044
|
-
return;
|
|
2045
|
-
}
|
|
2046
|
-
if (drag.slide && drag.index > 0 && drag.index < points.length - 1) {
|
|
2047
|
-
// slide along the arc: ray onto the horizontal plane at the mark
|
|
2048
|
-
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -point.height);
|
|
2049
|
-
const world = new THREE.Vector3();
|
|
2050
|
-
if (!raycaster.ray.intersectPlane(plane, world)) return;
|
|
2051
|
-
let bestI = 0;
|
|
2052
|
-
let bestD = Infinity;
|
|
2053
|
-
for (let i = 0; i < s.rail.points.length; i += 1) {
|
|
2054
|
-
const d = Math.hypot(s.rail.points[i].x - world.x, s.rail.points[i].z - world.z);
|
|
2055
|
-
if (d < bestD) {
|
|
2056
|
-
bestD = d;
|
|
2057
|
-
bestI = i;
|
|
2058
|
-
}
|
|
2059
|
-
}
|
|
2060
|
-
const t = Math.max(
|
|
2061
|
-
points[drag.index - 1].t + 0.02,
|
|
2062
|
-
Math.min(points[drag.index + 1].t - 0.02, s.rail.cumLen[bestI] / s.rail.length),
|
|
2063
|
-
);
|
|
2064
|
-
s.onChangePoints(points.map((entry, i) => (i === drag.index ? { ...entry, t } : entry)), { dragging: true });
|
|
2065
|
-
} else {
|
|
2066
|
-
// free move: ray onto the camera-facing vertical plane at the base.
|
|
2067
|
-
// Its y sets the height; its x/z re-projects an interior mark onto
|
|
2068
|
-
// the nearest rail arc position, so one drag steers both axes — the
|
|
2069
|
-
// rail is the only x/z a crane mark can occupy.
|
|
2070
|
-
const base = railPoint(s.rail, point.t * s.rail.length);
|
|
2071
|
-
const camera = camRef.current;
|
|
2072
|
-
const facing = new THREE.Vector3();
|
|
2073
|
-
camera.getWorldDirection(facing);
|
|
2074
|
-
facing.y = 0;
|
|
2075
|
-
if (facing.lengthSq() < 1e-6) return; // looking straight down: height is unreadable
|
|
2076
|
-
facing.normalize();
|
|
2077
|
-
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(facing, new THREE.Vector3(base.x, 0, base.z));
|
|
2078
|
-
const world = new THREE.Vector3();
|
|
2079
|
-
if (!raycaster.ray.intersectPlane(plane, world)) return;
|
|
2080
|
-
const height = Math.max(0.1, Math.min(12, world.y));
|
|
2081
|
-
let t = point.t;
|
|
2082
|
-
if (drag.index > 0 && drag.index < points.length - 1) {
|
|
2083
|
-
let bestI = 0;
|
|
2084
|
-
let bestD = Infinity;
|
|
2085
|
-
for (let i = 0; i < s.rail.points.length; i += 1) {
|
|
2086
|
-
const d = Math.hypot(s.rail.points[i].x - world.x, s.rail.points[i].z - world.z);
|
|
2087
|
-
if (d < bestD) {
|
|
2088
|
-
bestD = d;
|
|
2089
|
-
bestI = i;
|
|
2090
|
-
}
|
|
2091
|
-
}
|
|
2092
|
-
t = Math.max(
|
|
2093
|
-
points[drag.index - 1].t + 0.02,
|
|
2094
|
-
Math.min(points[drag.index + 1].t - 0.02, s.rail.cumLen[bestI] / s.rail.length),
|
|
2095
|
-
);
|
|
2096
|
-
}
|
|
2097
|
-
s.onChangePoints(points.map((entry, i) => (i === drag.index ? { ...entry, t, height } : entry)), { dragging: true });
|
|
2098
|
-
}
|
|
2099
|
-
invalidate();
|
|
2100
|
-
};
|
|
2101
|
-
const onUp = () => {
|
|
2102
|
-
if (dragRef.current?.recorded) stateRef.current.onDragEnd?.();
|
|
2103
|
-
dragRef.current = null;
|
|
2104
|
-
setDragInfo(null);
|
|
2105
|
-
};
|
|
2106
|
-
const onDouble = (event) => {
|
|
2107
|
-
const s = stateRef.current;
|
|
2108
|
-
if (!s.enabled || !s.crane || !s.rail || !camRef.current) return;
|
|
2109
|
-
camRef.current.updateMatrixWorld();
|
|
2110
|
-
let best = null;
|
|
2111
|
-
for (let i = 0; i < s.rail.points.length; i += 1) {
|
|
2112
|
-
const t = s.rail.cumLen[i] / s.rail.length;
|
|
2113
|
-
const screen = paneScreen(new THREE.Vector3(s.rail.points[i].x, craneHeightAt(s.crane, t), s.rail.points[i].z));
|
|
2114
|
-
if (!screen) continue;
|
|
2115
|
-
const d = Math.hypot(screen.x - event.clientX, screen.y - event.clientY);
|
|
2116
|
-
if (!best || d < best.d) best = { d, t };
|
|
2117
|
-
}
|
|
2118
|
-
if (!best || best.d > 14) return;
|
|
2119
|
-
if (s.crane.points.length >= 8) return;
|
|
2120
|
-
if (s.crane.points.some((entry) => Math.abs(entry.t - best.t) < 0.03)) return;
|
|
2121
|
-
event.stopImmediatePropagation();
|
|
2122
|
-
event.preventDefault();
|
|
2123
|
-
const added = [...s.crane.points, { t: best.t, height: craneHeightAt(s.crane, best.t) }].sort((a, b) => a.t - b.t);
|
|
2124
|
-
s.onChangePoints(added);
|
|
2125
|
-
s.onSelect(added.findIndex((entry) => entry.t === best.t));
|
|
2126
|
-
invalidate();
|
|
2127
|
-
};
|
|
2128
|
-
const onKey = (event) => {
|
|
2129
|
-
const s = stateRef.current;
|
|
2130
|
-
if (!s.enabled || !s.crane) return;
|
|
2131
|
-
if (event.key !== "Delete" && event.key !== "Backspace") return;
|
|
2132
|
-
if (document.activeElement && /INPUT|TEXTAREA/.test(document.activeElement.tagName)) return;
|
|
2133
|
-
const index = s.selectedIndex;
|
|
2134
|
-
if (index == null || index <= 0 || index >= s.crane.points.length - 1) return;
|
|
2135
|
-
event.preventDefault();
|
|
2136
|
-
s.onChangePoints(s.crane.points.filter((_, i) => i !== index));
|
|
2137
|
-
s.onSelect(null);
|
|
2138
|
-
invalidate();
|
|
2139
|
-
};
|
|
2140
|
-
const el = gl.domElement;
|
|
2141
|
-
el.addEventListener("pointerdown", onDown, true);
|
|
2142
|
-
window.addEventListener("pointermove", onMove, true);
|
|
2143
|
-
window.addEventListener("pointerup", onUp, true);
|
|
2144
|
-
el.addEventListener("dblclick", onDouble, true);
|
|
2145
|
-
window.addEventListener("keydown", onKey);
|
|
2146
|
-
return () => {
|
|
2147
|
-
el.removeEventListener("pointerdown", onDown, true);
|
|
2148
|
-
window.removeEventListener("pointermove", onMove, true);
|
|
2149
|
-
window.removeEventListener("pointerup", onUp, true);
|
|
2150
|
-
el.removeEventListener("dblclick", onDouble, true);
|
|
2151
|
-
window.removeEventListener("keydown", onKey);
|
|
2152
|
-
};
|
|
2153
|
-
// paneRef/camRef are stable refs; handlers read live state through stateRef
|
|
2154
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2155
|
-
}, [enabled, gl, invalidate]);
|
|
2156
|
-
if (!enabled || !crane || !rail) return null;
|
|
2157
|
-
const selectedPoint = selectedIndex != null ? crane.points[selectedIndex] : null;
|
|
2158
|
-
const selectedBase = selectedPoint ? railPoint(rail, selectedPoint.t * rail.length) : null;
|
|
2159
|
-
return (
|
|
2160
|
-
<group ref={groupRef}>
|
|
2161
|
-
{selectedPoint && (
|
|
2162
|
-
<group ref={gizmoRef} position={[selectedBase.x, selectedPoint.height, selectedBase.z]} renderOrder={999}>
|
|
2163
|
-
{CRANE_AXES.map(({ axis, dir, color }) => {
|
|
2164
|
-
const quat = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
|
|
2165
|
-
return (
|
|
2166
|
-
<group key={axis} quaternion={quat}>
|
|
2167
|
-
<mesh position={[0, CRANE_ARROW_LEN / 2 + 0.08, 0]} renderOrder={999}>
|
|
2168
|
-
<cylinderGeometry args={[0.016, 0.016, CRANE_ARROW_LEN, 8]} />
|
|
2169
|
-
<meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.2} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={0.95} />
|
|
2170
|
-
</mesh>
|
|
2171
|
-
<mesh position={[0, CRANE_ARROW_LEN + 0.08 + 0.07, 0]} renderOrder={999}>
|
|
2172
|
-
<coneGeometry args={[0.05, 0.14, 14]} />
|
|
2173
|
-
<meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.2} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={0.95} />
|
|
2174
|
-
</mesh>
|
|
2175
|
-
<mesh position={[0, (CRANE_ARROW_LEN + 0.14) / 2 + 0.08, 0]} userData={{ craneAxis: axis }}>
|
|
2176
|
-
<cylinderGeometry args={[0.09, 0.09, CRANE_ARROW_LEN + 0.14, 8]} />
|
|
2177
|
-
<meshBasicMaterial visible={false} />
|
|
2178
|
-
</mesh>
|
|
2179
|
-
</group>
|
|
2180
|
-
);
|
|
2181
|
-
})}
|
|
2182
|
-
</group>
|
|
2183
|
-
)}
|
|
2184
|
-
{crane.points.map((point, index) => {
|
|
2185
|
-
const base = railPoint(rail, point.t * rail.length);
|
|
2186
|
-
return (
|
|
2187
|
-
<group key={`${index}:${crane.points.length}`} position={[base.x, point.height, base.z]}>
|
|
2188
|
-
<mesh userData={{ craneIndex: index }} renderOrder={998}>
|
|
2189
|
-
<sphereGeometry args={[0.085, 16, 12]} />
|
|
2190
|
-
<meshBasicMaterial color={index === selectedIndex ? "#ffb454" : "#a78bfa"} depthTest={false} transparent opacity={0.95} />
|
|
2191
|
-
</mesh>
|
|
2192
|
-
{/* the real click target: a dot is a dozen pixels from a few
|
|
2193
|
-
metres back, so an invisible halo carries the press */}
|
|
2194
|
-
<mesh userData={{ craneIndex: index }}>
|
|
2195
|
-
<sphereGeometry args={[0.24, 10, 8]} />
|
|
2196
|
-
<meshBasicMaterial visible={false} />
|
|
2197
|
-
</mesh>
|
|
2198
|
-
</group>
|
|
2199
|
-
);
|
|
2200
|
-
})}
|
|
2201
|
-
{/* live readout while a drag changes the mark's height */}
|
|
2202
|
-
{dragInfo && dragInfo.kind !== "bend" && selectedPoint && (
|
|
2203
|
-
<GizmoLabel
|
|
2204
|
-
position={[selectedBase.x, selectedPoint.height + 0.28, selectedBase.z]}
|
|
2205
|
-
text={dragInfo.kind === "slide" ? `${Math.round(selectedPoint.t * 100)}%` : `${selectedPoint.height.toFixed(1)} m`}
|
|
2206
|
-
camRef={camRef}
|
|
2207
|
-
/>
|
|
2208
|
-
)}
|
|
2209
|
-
{/* the span a bend drag will actually deform, lit on the floor path */}
|
|
2210
|
-
{dragInfo?.kind === "bend" && (() => {
|
|
2211
|
-
const span = [];
|
|
2212
|
-
for (let i = 0; i < rail.points.length; i += 1) {
|
|
2213
|
-
const t = rail.cumLen[i] / rail.length;
|
|
2214
|
-
if (t >= dragInfo.t0 - dragInfo.radius && t <= dragInfo.t0 + dragInfo.radius) {
|
|
2215
|
-
span.push([rail.points[i].x, 0.035, rail.points[i].z]);
|
|
2216
|
-
}
|
|
2217
|
-
}
|
|
2218
|
-
if (span.length < 2) return null;
|
|
2219
|
-
return <Line points={span} color="#ffb454" lineWidth={4} transparent opacity={0.9} />;
|
|
2220
|
-
})()}
|
|
2221
|
-
</group>
|
|
2222
|
-
);
|
|
2223
|
-
}
|
|
2224
|
-
|
|
2225
|
-
export function ShotPathPreview({ waypoints, start, activeWaypointId }) {
|
|
2226
|
-
const rootRef = useRef(null);
|
|
2227
|
-
const line = useMemo(() => {
|
|
2228
|
-
if (!waypoints.length) return null;
|
|
2229
|
-
const points = [{ x: start.x, z: start.z }, ...waypoints];
|
|
2230
|
-
const positions = new Float32Array(points.length * 3);
|
|
2231
|
-
points.forEach((point, i) => {
|
|
2232
|
-
positions[i * 3] = point.x;
|
|
2233
|
-
positions[i * 3 + 1] = 0.03;
|
|
2234
|
-
positions[i * 3 + 2] = point.z;
|
|
2235
|
-
});
|
|
2236
|
-
const geometry = new THREE.BufferGeometry();
|
|
2237
|
-
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
|
2238
|
-
return geometry;
|
|
2239
|
-
}, [start.x, start.z, waypoints]);
|
|
2240
|
-
useEffect(() => {
|
|
2241
|
-
// Every render: drei's Text meshes appear asynchronously, and each new
|
|
2242
|
-
// node must land on the gizmo layer before the next capture.
|
|
2243
|
-
rootRef.current?.traverse((node) => node.layers.set(GIZMO_LAYER));
|
|
2244
|
-
});
|
|
2245
|
-
if (!waypoints.length) return null;
|
|
2246
|
-
return (
|
|
2247
|
-
<group ref={rootRef}>
|
|
2248
|
-
{line && (
|
|
2249
|
-
<line geometry={line}>
|
|
2250
|
-
<lineBasicMaterial color="#4e9fb3" transparent opacity={0.8} depthWrite={false} />
|
|
2251
|
-
</line>
|
|
2252
|
-
)}
|
|
2253
|
-
{waypoints.map((waypoint, i) => {
|
|
2254
|
-
const active = waypoint.id === activeWaypointId;
|
|
2255
|
-
const color = active ? "#ffd76c" : "#4e9fb3";
|
|
2256
|
-
return (
|
|
2257
|
-
<group key={waypoint.id} position={[waypoint.x, 0.03, waypoint.z]}>
|
|
2258
|
-
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
|
2259
|
-
<ringGeometry args={[0.13, 0.19, 28]} />
|
|
2260
|
-
<meshBasicMaterial color={color} depthWrite={false} />
|
|
2261
|
-
</mesh>
|
|
2262
|
-
<Text
|
|
2263
|
-
position={[0, 0.02, 0.34]}
|
|
2264
|
-
rotation={[-Math.PI / 2, 0, 0]}
|
|
2265
|
-
fontSize={0.24}
|
|
2266
|
-
color={color}
|
|
2267
|
-
anchorX="center"
|
|
2268
|
-
anchorY="middle"
|
|
2269
|
-
outlineWidth={0.03}
|
|
2270
|
-
outlineColor="#1c1a17"
|
|
2271
|
-
outlineOpacity={0.85}
|
|
2272
|
-
>
|
|
2273
|
-
{String(i + 1)}
|
|
2274
|
-
</Text>
|
|
2275
|
-
</group>
|
|
2276
|
-
);
|
|
2277
|
-
})}
|
|
2278
|
-
</group>
|
|
2279
|
-
);
|
|
2280
|
-
}
|
|
2281
|
-
|
|
2282
|
-
// How far the deck runs in an EXPORTED frame. The viewport fades it out at
|
|
2283
|
-
// 18..54 m to keep the working view clean; a blocking frame needs the far edge
|
|
2284
|
-
// to survive, so the falloff starts later and ends further away, letting the
|
|
2285
|
-
// floor resolve into a horizon instead of dissolving into the background.
|
|
2286
|
-
//
|
|
2287
|
-
// Both values must stay inside the shot camera's far plane (100 m) — geometry
|
|
2288
|
-
// past it is clipped outright, so a fog range that ended beyond it would cut
|
|
2289
|
-
// the deck off mid-fade and take the horizon with it.
|
|
2290
|
-
export const CAPTURE_FOG_NEAR = 55;
|
|
2291
|
-
export const CAPTURE_FOG_FAR = 95;
|
|
2292
|
-
|
|
2293
|
-
/** Offscreen aspect-aware read-back, always from the shot camera. */
|
|
2294
|
-
export function CaptureRig({ apiRef, camRef, width = CAPTURE_W, height = CAPTURE_H }) {
|
|
2295
|
-
const { gl, scene } = useThree();
|
|
2296
|
-
useEffect(() => {
|
|
2297
|
-
// Each attempt owns its target independently of the live output size.
|
|
2298
|
-
// Resizing the editor cannot dispose an in-flight export's resources.
|
|
2299
|
-
function createExportCapture({ width, height }) {
|
|
2300
|
-
const target = new THREE.WebGLRenderTarget(width, height, {
|
|
2301
|
-
colorSpace: THREE.SRGBColorSpace,
|
|
2302
|
-
samples: 4,
|
|
2303
|
-
});
|
|
2304
|
-
const buffer = new Uint8Array(width * height * 4);
|
|
2305
|
-
return {
|
|
2306
|
-
scene,
|
|
2307
|
-
dispose: () => target.dispose(),
|
|
2308
|
-
render() {
|
|
2309
|
-
const source = camRef.current;
|
|
2310
|
-
if (!source) return null;
|
|
2311
|
-
const cam = source.clone();
|
|
2312
|
-
// the transform gizmo is UI: it never reaches an exported frame
|
|
2313
|
-
cam.layers.disable(GIZMO_LAYER);
|
|
2314
|
-
// QA hook: the layer mask the export camera actually renders
|
|
2315
|
-
// with — the browser suite asserts GIZMO_LAYER (the gizmo AND
|
|
2316
|
-
// the selection cage) is never in it.
|
|
2317
|
-
window.__captureCameraMask = cam.layers.mask;
|
|
2318
|
-
cam.aspect = width / height;
|
|
2319
|
-
cam.updateProjectionMatrix();
|
|
2320
|
-
const previous = gl.getRenderTarget();
|
|
2321
|
-
// The viewport's fog dissolves the deck into the background by ~54 m
|
|
2322
|
-
// so the working view has no horizon to distract from blocking. An
|
|
2323
|
-
// exported frame wants the opposite: the horizon IS the vanishing
|
|
2324
|
-
// point, and without it the floor has no far edge to read depth
|
|
2325
|
-
// against. Push the falloff back for this draw only, then restore
|
|
2326
|
-
// it so the viewport is untouched.
|
|
2327
|
-
const fog = scene.fog;
|
|
2328
|
-
const fogNear = fog?.near;
|
|
2329
|
-
const fogFar = fog?.far;
|
|
2330
|
-
if (fog) {
|
|
2331
|
-
fog.near = CAPTURE_FOG_NEAR;
|
|
2332
|
-
fog.far = CAPTURE_FOG_FAR;
|
|
2333
|
-
}
|
|
2334
|
-
try {
|
|
2335
|
-
gl.setRenderTarget(target);
|
|
2336
|
-
gl.render(scene, cam);
|
|
2337
|
-
gl.readRenderTargetPixels(target, 0, 0, width, height, buffer);
|
|
2338
|
-
} finally {
|
|
2339
|
-
gl.setRenderTarget(previous);
|
|
2340
|
-
if (fog) {
|
|
2341
|
-
fog.near = fogNear;
|
|
2342
|
-
fog.far = fogFar;
|
|
2343
|
-
}
|
|
2344
|
-
}
|
|
2345
|
-
return buffer;
|
|
2346
|
-
},
|
|
2347
|
-
};
|
|
2348
|
-
}
|
|
2349
|
-
const api = { ...createExportCapture({ width, height }), createExportCapture };
|
|
2350
|
-
apiRef.current = api;
|
|
2351
|
-
if (width === MCP_CAPTURE_W && height === MCP_CAPTURE_H) {
|
|
2352
|
-
window.__cozyclayMcpCaptureReady = true;
|
|
2353
|
-
window.dispatchEvent(new Event("cozyclay:mcp-capture-ready"));
|
|
2354
|
-
}
|
|
2355
|
-
// QA hook: run one real export render and report what the capture
|
|
2356
|
-
// camera saw — the layer mask plus an amber scan of the output
|
|
2357
|
-
// frame (the selection cage's warm tone). Lets the suite prove the
|
|
2358
|
-
// deliverable frame stays free of editor furniture without driving
|
|
2359
|
-
// the whole generation form.
|
|
2360
|
-
window.__captureFrame = () => {
|
|
2361
|
-
const buffer = apiRef.current?.render() ?? null;
|
|
2362
|
-
if (!buffer) return null;
|
|
2363
|
-
let amber = 0;
|
|
2364
|
-
for (let i = 0; i < buffer.length; i += 4) {
|
|
2365
|
-
const r = buffer[i];
|
|
2366
|
-
const g = buffer[i + 1];
|
|
2367
|
-
const b = buffer[i + 2];
|
|
2368
|
-
if (r >= 180 && g >= 165 && g - b >= 35) amber++;
|
|
2369
|
-
}
|
|
2370
|
-
return { layersMask: window.__captureCameraMask ?? 0, amber, pixels: buffer.length / 4 };
|
|
2371
|
-
};
|
|
2372
|
-
return () => {
|
|
2373
|
-
if (apiRef.current === api) apiRef.current = null;
|
|
2374
|
-
if (width === MCP_CAPTURE_W && height === MCP_CAPTURE_H) window.__cozyclayMcpCaptureReady = false;
|
|
2375
|
-
api.dispose();
|
|
2376
|
-
};
|
|
2377
|
-
}, [gl, scene, camRef, apiRef, width, height]);
|
|
2378
|
-
return null;
|
|
2379
|
-
}
|
|
2380
|
-
|
|
2381
|
-
export async function captureMcpFrame({ capture, camera, characters, activeCharacterId, objects, rigs, readAuthoredState, partColours = null }) {
|
|
2382
|
-
if (!capture || !camera) throw new Error("No renderable shot camera is available for capture_frame.");
|
|
2383
|
-
const authoredStateBefore = JSON.stringify(readAuthoredState());
|
|
2384
|
-
const buffer = capture.render();
|
|
2385
|
-
if (!buffer) throw new Error("No renderable shot camera is available for capture_frame.");
|
|
2386
|
-
let nonBlackPixels = 0;
|
|
2387
|
-
for (let offset = 0; offset < buffer.length; offset += 4) {
|
|
2388
|
-
if (buffer[offset] > 5 || buffer[offset + 1] > 5 || buffer[offset + 2] > 5) nonBlackPixels += 1;
|
|
2389
|
-
}
|
|
2390
|
-
if (nonBlackPixels === 0) throw new Error("capture_frame rejected a black rendered frame.");
|
|
2391
|
-
camera.updateMatrixWorld();
|
|
2392
|
-
const raycaster = new THREE.Raycaster();
|
|
2393
|
-
const roots = [];
|
|
2394
|
-
capture.scene.traverse((node) => {
|
|
2395
|
-
const id = node.userData?.sceneObjectId;
|
|
2396
|
-
if (typeof id === "string" && objects.some((object) => object.id === id)) roots.push({ id, root: node });
|
|
2397
|
-
});
|
|
2398
|
-
const assertions = characters.map((character) => {
|
|
2399
|
-
const rig = rigs[character.id];
|
|
2400
|
-
if (!rig || character.hidden) return { id: character.id, renderable: false, behindCameraPlane: null, fartherAlongCameraForward: null, distanceToFloor: null, occludedBy: null, visiblePixelCount: 0 };
|
|
2401
|
-
rig.updateWorldMatrix(true, true);
|
|
2402
|
-
const bounds = new THREE.Box3().setFromObject(rig);
|
|
2403
|
-
const center = bounds.getCenter(new THREE.Vector3());
|
|
2404
|
-
const cameraSpace = camera.worldToLocal(center.clone());
|
|
2405
|
-
const behindCameraPlane = cameraSpace.z >= 0;
|
|
2406
|
-
const distanceToFloor = Math.max(0, bounds.min.y);
|
|
2407
|
-
if (behindCameraPlane || bounds.isEmpty()) {
|
|
2408
|
-
return { id: character.id, renderable: true, behindCameraPlane, fartherAlongCameraForward: -cameraSpace.z, distanceToFloor, occludedBy: null, visiblePixelCount: 0 };
|
|
2409
|
-
}
|
|
2410
|
-
const corners = [
|
|
2411
|
-
new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.min.z), new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.max.z),
|
|
2412
|
-
new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.min.z), new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.max.z),
|
|
2413
|
-
new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.min.z), new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.max.z),
|
|
2414
|
-
new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.min.z), new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.max.z),
|
|
2415
|
-
].map((corner) => corner.project(camera));
|
|
2416
|
-
const minX = Math.max(0, Math.floor(((Math.min(...corners.map((corner) => corner.x)) + 1) * 0.5) * MCP_CAPTURE_W));
|
|
2417
|
-
const maxX = Math.min(MCP_CAPTURE_W - 1, Math.ceil(((Math.max(...corners.map((corner) => corner.x)) + 1) * 0.5) * MCP_CAPTURE_W));
|
|
2418
|
-
const minY = Math.max(0, Math.floor(((1 - Math.max(...corners.map((corner) => corner.y))) * 0.5) * MCP_CAPTURE_H));
|
|
2419
|
-
const maxY = Math.min(MCP_CAPTURE_H - 1, Math.ceil(((1 - Math.min(...corners.map((corner) => corner.y))) * 0.5) * MCP_CAPTURE_H));
|
|
2420
|
-
const occluders = new Map();
|
|
2421
|
-
let visiblePixelCount = 0;
|
|
2422
|
-
for (let y = minY; y <= maxY; y += MCP_CAPTURE_SAMPLE_STEP) {
|
|
2423
|
-
for (let x = minX; x <= maxX; x += MCP_CAPTURE_SAMPLE_STEP) {
|
|
2424
|
-
raycaster.setFromCamera({ x: (x / MCP_CAPTURE_W) * 2 - 1, y: 1 - (y / MCP_CAPTURE_H) * 2 }, camera);
|
|
2425
|
-
const characterHit = raycaster.intersectObject(rig, true)[0];
|
|
2426
|
-
if (!characterHit) continue;
|
|
2427
|
-
const objectHit = raycaster.intersectObjects(roots.map((entry) => entry.root), true)[0];
|
|
2428
|
-
const pixels = MCP_CAPTURE_SAMPLE_STEP * MCP_CAPTURE_SAMPLE_STEP;
|
|
2429
|
-
if (!objectHit || objectHit.distance >= characterHit.distance - 1e-4) {
|
|
2430
|
-
visiblePixelCount += pixels;
|
|
2431
|
-
continue;
|
|
2432
|
-
}
|
|
2433
|
-
const occluder = roots.find((entry) => {
|
|
2434
|
-
let node = objectHit.object;
|
|
2435
|
-
while (node) {
|
|
2436
|
-
if (node === entry.root) return true;
|
|
2437
|
-
node = node.parent;
|
|
2438
|
-
}
|
|
2439
|
-
return false;
|
|
2440
|
-
});
|
|
2441
|
-
if (occluder) occluders.set(occluder.id, (occluders.get(occluder.id) ?? 0) + pixels);
|
|
2442
|
-
}
|
|
2443
|
-
}
|
|
2444
|
-
const occludedBy = [...occluders.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null;
|
|
2445
|
-
return { id: character.id, renderable: true, behindCameraPlane, fartherAlongCameraForward: -cameraSpace.z, distanceToFloor, occludedBy, visiblePixelCount };
|
|
2446
|
-
});
|
|
2447
|
-
const active = assertions.find((entry) => entry.id === activeCharacterId) ?? assertions[0];
|
|
2448
|
-
const authoredStateAfter = JSON.stringify(readAuthoredState());
|
|
2449
|
-
const canvas = document.createElement("canvas");
|
|
2450
|
-
canvas.width = MCP_CAPTURE_W;
|
|
2451
|
-
canvas.height = MCP_CAPTURE_H;
|
|
2452
|
-
const context = canvas.getContext("2d");
|
|
2453
|
-
if (!context) throw new Error("capture_frame could not encode the rendered image.");
|
|
2454
|
-
const image = context.createImageData(MCP_CAPTURE_W, MCP_CAPTURE_H);
|
|
2455
|
-
for (let row = 0; row < MCP_CAPTURE_H; row += 1) {
|
|
2456
|
-
image.data.set(buffer.subarray((MCP_CAPTURE_H - 1 - row) * MCP_CAPTURE_W * 4, (MCP_CAPTURE_H - row) * MCP_CAPTURE_W * 4), row * MCP_CAPTURE_W * 4);
|
|
2457
|
-
}
|
|
2458
|
-
context.putImageData(image, 0, 0);
|
|
2459
|
-
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
|
2460
|
-
if (!blob) throw new Error("capture_frame could not compress the rendered image.");
|
|
2461
|
-
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
2462
|
-
let binary = "";
|
|
2463
|
-
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
2464
|
-
return {
|
|
2465
|
-
width: MCP_CAPTURE_W,
|
|
2466
|
-
height: MCP_CAPTURE_H,
|
|
2467
|
-
mimeType: "image/png",
|
|
2468
|
-
encoding: "base64",
|
|
2469
|
-
byteSize: bytes.length,
|
|
2470
|
-
data: btoa(binary),
|
|
2471
|
-
partColours,
|
|
2472
|
-
authoredStateBefore,
|
|
2473
|
-
authoredStateAfter,
|
|
2474
|
-
assertions: {
|
|
2475
|
-
renderable: true,
|
|
2476
|
-
blackFrame: false,
|
|
2477
|
-
nonBlackPixels,
|
|
2478
|
-
behindCameraPlane: active?.behindCameraPlane ?? null,
|
|
2479
|
-
fartherAlongCameraForward: active?.fartherAlongCameraForward ?? null,
|
|
2480
|
-
distanceToFloor: active?.distanceToFloor ?? null,
|
|
2481
|
-
occludedBy: active?.occludedBy ?? null,
|
|
2482
|
-
visiblePixelCount: active?.visiblePixelCount ?? 0,
|
|
2483
|
-
characters: assertions,
|
|
2484
|
-
},
|
|
2485
|
-
};
|
|
2486
|
-
}
|
|
2487
|
-
|
|
2488
145
|
/**
|
|
2489
146
|
* Anything that accepts a dropped picture. Returns the handlers to spread onto
|
|
2490
147
|
* an element and whether a drag is currently over it, so the target can say so
|
|
@@ -2587,276 +244,3 @@ export function useStageFilesDrop({ onImages, onMeshes, onRejected } = {}) {
|
|
|
2587
244
|
},
|
|
2588
245
|
};
|
|
2589
246
|
}
|
|
2590
|
-
|
|
2591
|
-
/* ----------------------------------------------------------------- app --- */
|
|
2592
|
-
|
|
2593
|
-
// Unity's tool keys. They are free because camera movement now lives behind a
|
|
2594
|
-
// held right button. (docs/unity-reference.md §9.2)
|
|
2595
|
-
export const GIZMO_HOTKEYS = { KeyW: "move", KeyE: "rotate", KeyR: "scale" };
|
|
2596
|
-
|
|
2597
|
-
export const WORKSPACE_LAYOUT_KEY = "cozyclay.workspace-layout.v1";
|
|
2598
|
-
export const DEFAULT_WORKSPACE_LAYOUT = Object.freeze({
|
|
2599
|
-
hierarchyWidth: 300,
|
|
2600
|
-
sidebarWidth: 380,
|
|
2601
|
-
timelineHeight: 224,
|
|
2602
|
-
insetWidth: 310,
|
|
2603
|
-
insetHeight: 310,
|
|
2604
|
-
insetCollapsed: false,
|
|
2605
|
-
planZoom: 1,
|
|
2606
|
-
});
|
|
2607
|
-
|
|
2608
|
-
export function loadWorkspaceLayout() {
|
|
2609
|
-
// The landing-page playground is a fixed, chrome-less layout: the plan
|
|
2610
|
-
// inset starts folded and nothing the visitor drags is written back.
|
|
2611
|
-
if (isPlaygroundEmbed(globalThis.location?.search)) return { ...DEFAULT_WORKSPACE_LAYOUT, insetCollapsed: true };
|
|
2612
|
-
try {
|
|
2613
|
-
const saved = JSON.parse(localStorage.getItem(WORKSPACE_LAYOUT_KEY) || "null");
|
|
2614
|
-
return saved ? { ...DEFAULT_WORKSPACE_LAYOUT, ...saved } : { ...DEFAULT_WORKSPACE_LAYOUT };
|
|
2615
|
-
} catch {
|
|
2616
|
-
return { ...DEFAULT_WORKSPACE_LAYOUT };
|
|
2617
|
-
}
|
|
2618
|
-
}
|
|
2619
|
-
|
|
2620
|
-
/** Load the Scene envelope once. Legacy single-scene objects are migrated by
|
|
2621
|
-
* scenes.js; App only supplies the familiar starter set for a truly new room. */
|
|
2622
|
-
export function loadSceneStartup() {
|
|
2623
|
-
const defaults = () => DEFAULT_SCENE_OBJECTS.map((object) => ({ ...object, footprint: { ...object.footprint } }));
|
|
2624
|
-
// The landing-page playground never reads or writes the visitor's saved
|
|
2625
|
-
// scenes: it opens the preset the page fetched (or an empty room if that
|
|
2626
|
-
// fetch failed) and saving stays off for the whole session.
|
|
2627
|
-
if (isPlaygroundEmbed(globalThis.location?.search)) {
|
|
2628
|
-
const preset = takePlaygroundProject();
|
|
2629
|
-
const document = preset?.document ?? createSceneDocument();
|
|
2630
|
-
if (!preset?.document) document.scenes[0].objects = defaults();
|
|
2631
|
-
return { document, saveBlocked: true, error: null, startupCreatedScene: false, toast: null };
|
|
2632
|
-
}
|
|
2633
|
-
try {
|
|
2634
|
-
const result = loadSceneDocumentFromStorage(localStorage);
|
|
2635
|
-
if (result.status === "future") {
|
|
2636
|
-
const document = createSceneDocument();
|
|
2637
|
-
document.scenes[0].objects = defaults();
|
|
2638
|
-
return {
|
|
2639
|
-
document,
|
|
2640
|
-
saveBlocked: true,
|
|
2641
|
-
error: null,
|
|
2642
|
-
toast: ko("Saved scenes were written by a newer CozyClay — they have been left untouched and this session will not save", "저장된 장면은 더 최신 CozyClay에서 만들어졌어요. 이 세션에서는 건드리지 않고 저장도 하지 않습니다."),
|
|
2643
|
-
};
|
|
2644
|
-
}
|
|
2645
|
-
const document = result.document;
|
|
2646
|
-
if ((result.status === "absent" || result.status === "corrupt") && document.scenes[0].objects.length === 0) {
|
|
2647
|
-
document.scenes[0].objects = defaults();
|
|
2648
|
-
}
|
|
2649
|
-
return {
|
|
2650
|
-
document,
|
|
2651
|
-
saveBlocked: false,
|
|
2652
|
-
error: null,
|
|
2653
|
-
// The startup scene is authored silently; without this flag the
|
|
2654
|
-
// funnel's "scene created" step never fires for a brand-new room.
|
|
2655
|
-
startupCreatedScene: result.status === "absent" || result.status === "corrupt",
|
|
2656
|
-
toast: result.status === "corrupt"
|
|
2657
|
-
? ko(`Saved scenes were unreadable — starting fresh; the old data is kept under ${SCENES_QUARANTINE_KEY}`, `저장된 장면을 읽을 수 없어 새로 시작합니다. 기존 데이터는 ${SCENES_QUARANTINE_KEY}에 보관했어요.`)
|
|
2658
|
-
: result.dropped > 0
|
|
2659
|
-
? (isKo ? `저장된 장면 ${result.dropped}개를 복원하지 못했어요` : `${result.dropped} saved scene(s) could not be restored`)
|
|
2660
|
-
: null,
|
|
2661
|
-
};
|
|
2662
|
-
} catch {
|
|
2663
|
-
const document = createSceneDocument();
|
|
2664
|
-
document.scenes[0].objects = defaults();
|
|
2665
|
-
return { document, saveBlocked: false, error: null, startupCreatedScene: true, toast: null };
|
|
2666
|
-
}
|
|
2667
|
-
}
|
|
2668
|
-
|
|
2669
|
-
/* ------------------------- IK-mode motion trails --------------------------
|
|
2670
|
-
* World-space trajectory polylines of the loaded take: the root (hips) path
|
|
2671
|
-
* always, plus the focused IK effector's end-point trail. The whole clip is
|
|
2672
|
-
* drawn as a faint line; while a grab is active the falloff window is
|
|
2673
|
-
* re-drawn on top as a bright highlight. Grabbing any point of a line starts
|
|
2674
|
-
* a drag on a camera-facing plane through the grab point; the caller deforms
|
|
2675
|
-
* the take (motion-trail.js falloff math) so the preview updates live. */
|
|
2676
|
-
export const MotionTrails = memo(function MotionTrails({ motion, rig = null, baseY, charScale, ikFocus, falloffFrames, playheadFrame, pendingEdit, enabled, visible = true, onDragStart, onDragPreview, onDragEnd }) {
|
|
2677
|
-
const { camera, gl, invalidate } = useThree();
|
|
2678
|
-
const [drag, setDrag] = useState(null);
|
|
2679
|
-
const callbacksRef = useRef({ onDragStart, onDragPreview, onDragEnd });
|
|
2680
|
-
callbacksRef.current = { onDragStart, onDragPreview, onDragEnd };
|
|
2681
|
-
const toTriples = (flat) => {
|
|
2682
|
-
if (!flat) return null;
|
|
2683
|
-
const out = [];
|
|
2684
|
-
for (let index = 0; index + 2 < flat.length; index += 3) out.push([flat[index], flat[index + 1], flat[index + 2]]);
|
|
2685
|
-
return out;
|
|
2686
|
-
};
|
|
2687
|
-
// Every trail track (root + IK endpoints + head) in its handle colour,
|
|
2688
|
-
// sampled where playback renders each bone on this rig (not the clip's
|
|
2689
|
-
// own joints, which sit 4-15 cm off the body), so draw and pick agree.
|
|
2690
|
-
const tracks = useMemo(
|
|
2691
|
-
() => TRAIL_TRACKS.map((track) => {
|
|
2692
|
-
const flat = jointTrailPoints(motion, track.joint, { baseY, scale: charScale, rig });
|
|
2693
|
-
return flat ? { ...track, flat, points: toTriples(flat) } : null;
|
|
2694
|
-
}).filter(Boolean),
|
|
2695
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2696
|
-
[motion, rig, baseY, charScale],
|
|
2697
|
-
);
|
|
2698
|
-
const trackById = (id) => tracks.find((track) => track.id === id) ?? null;
|
|
2699
|
-
// The falloff window rides whichever line is being (or was last) grabbed.
|
|
2700
|
-
const highlight = drag ?? pendingEdit;
|
|
2701
|
-
const highlightPoints = useMemo(() => {
|
|
2702
|
-
if (!highlight || !motion?.frames) return null;
|
|
2703
|
-
const flat = trackById(highlight.track)?.flat ?? trackById("hips")?.flat;
|
|
2704
|
-
if (!flat) return null;
|
|
2705
|
-
const { startFrame, endFrame } = trailEditRange(motion.frames, highlight.grabFrame, falloffFrames);
|
|
2706
|
-
const out = [];
|
|
2707
|
-
for (let frame = startFrame; frame < endFrame; frame += 1) {
|
|
2708
|
-
out.push([flat[frame * 3], flat[frame * 3 + 1], flat[frame * 3 + 2]]);
|
|
2709
|
-
}
|
|
2710
|
-
return out.length > 1 ? out : null;
|
|
2711
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2712
|
-
}, [highlight, motion, tracks, falloffFrames]);
|
|
2713
|
-
|
|
2714
|
-
// Manual capture-phase picking instead of r3f pointer handlers: handlers on
|
|
2715
|
-
// a drei Line make react-three-fiber raycast the fat-line shader geometry on
|
|
2716
|
-
// EVERY pointermove for hover bookkeeping, which is exactly the IK-mode lag.
|
|
2717
|
-
// A single pointerdown listener that measures point-to-ray distance against
|
|
2718
|
-
// the cached trail arrays costs nothing while the mouse merely moves.
|
|
2719
|
-
const pickRef = useRef(null);
|
|
2720
|
-
pickRef.current = { tracks, enabled, falloffFrames, playheadFrame };
|
|
2721
|
-
// Line2 instances for in-place geometry rewrites during a drag.
|
|
2722
|
-
const lineRefs = useRef({});
|
|
2723
|
-
const highlightRef = useRef(null);
|
|
2724
|
-
useEffect(() => {
|
|
2725
|
-
const dom = gl.domElement;
|
|
2726
|
-
const raycaster = new THREE.Raycaster();
|
|
2727
|
-
const hit = new THREE.Vector3();
|
|
2728
|
-
const down = (event) => {
|
|
2729
|
-
const pick = pickRef.current;
|
|
2730
|
-
if (!pick?.enabled || event.button !== 0) return;
|
|
2731
|
-
const rect = dom.getBoundingClientRect();
|
|
2732
|
-
const ndc = new THREE.Vector2(
|
|
2733
|
-
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
|
2734
|
-
-((event.clientY - rect.top) / rect.height) * 2 + 1,
|
|
2735
|
-
);
|
|
2736
|
-
raycaster.setFromCamera(ndc, camera);
|
|
2737
|
-
const { origin, direction } = raycaster.ray;
|
|
2738
|
-
const picked = pickTrailPoint({
|
|
2739
|
-
tracks: pick.tracks,
|
|
2740
|
-
playheadFrame: pick.playheadFrame,
|
|
2741
|
-
falloffFrames: pick.falloffFrames,
|
|
2742
|
-
rayOrigin: origin,
|
|
2743
|
-
rayDirection: direction,
|
|
2744
|
-
maxDistance: 0.2,
|
|
2745
|
-
});
|
|
2746
|
-
if (!picked) return;
|
|
2747
|
-
const { track, grabFrame } = picked;
|
|
2748
|
-
// The grab wins over the camera controls listening in the bubble phase.
|
|
2749
|
-
event.stopPropagation();
|
|
2750
|
-
event.preventDefault();
|
|
2751
|
-
const flat = pick.tracks.find((candidate) => candidate.id === track)?.flat;
|
|
2752
|
-
if (!flat) return;
|
|
2753
|
-
const start = new THREE.Vector3(flat[grabFrame * 3], flat[grabFrame * 3 + 1], flat[grabFrame * 3 + 2]);
|
|
2754
|
-
const normal = camera.getWorldDirection(new THREE.Vector3()).negate();
|
|
2755
|
-
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, start);
|
|
2756
|
-
setDrag({ track, grabFrame });
|
|
2757
|
-
callbacksRef.current.onDragStart?.({ track, grabFrame });
|
|
2758
|
-
// The drag never touches React state: per rAF the grabbed line and the
|
|
2759
|
-
// falloff highlight are rewritten in place on their Line2 geometries,
|
|
2760
|
-
// and onDragPreview poses the rig imperatively. Re-rendering the whole
|
|
2761
|
-
// app per pointermove is exactly the drag lag this avoids; the real
|
|
2762
|
-
// commit (setMotion + pending edit) happens once, on pointerup.
|
|
2763
|
-
const frames = flat.length / 3;
|
|
2764
|
-
const { startFrame, endFrame } = trailEditRange(frames, grabFrame, pickRef.current.falloffFrames);
|
|
2765
|
-
const deformedFlat = new Float32Array(flat);
|
|
2766
|
-
const windowFlat = new Float32Array((endFrame - startFrame) * 3);
|
|
2767
|
-
let lastDelta = null;
|
|
2768
|
-
let queued = null;
|
|
2769
|
-
let rafId = 0;
|
|
2770
|
-
const applyPreview = (delta) => {
|
|
2771
|
-
for (let frame = startFrame; frame < endFrame; frame += 1) {
|
|
2772
|
-
const weight = falloffWeight(frame - grabFrame, pickRef.current.falloffFrames);
|
|
2773
|
-
deformedFlat[frame * 3] = flat[frame * 3] + delta.x * weight;
|
|
2774
|
-
deformedFlat[frame * 3 + 1] = flat[frame * 3 + 1] + delta.y * weight;
|
|
2775
|
-
deformedFlat[frame * 3 + 2] = flat[frame * 3 + 2] + delta.z * weight;
|
|
2776
|
-
}
|
|
2777
|
-
windowFlat.set(deformedFlat.subarray(startFrame * 3, endFrame * 3));
|
|
2778
|
-
lineRefs.current[track]?.geometry?.setPositions(deformedFlat);
|
|
2779
|
-
highlightRef.current?.geometry?.setPositions(windowFlat);
|
|
2780
|
-
callbacksRef.current.onDragPreview?.({ track, grabFrame, delta });
|
|
2781
|
-
invalidate();
|
|
2782
|
-
};
|
|
2783
|
-
const flush = () => {
|
|
2784
|
-
rafId = 0;
|
|
2785
|
-
if (queued) applyPreview(queued);
|
|
2786
|
-
queued = null;
|
|
2787
|
-
};
|
|
2788
|
-
const move = (pointerEvent) => {
|
|
2789
|
-
const moveRect = dom.getBoundingClientRect();
|
|
2790
|
-
const moveNdc = new THREE.Vector2(
|
|
2791
|
-
((pointerEvent.clientX - moveRect.left) / moveRect.width) * 2 - 1,
|
|
2792
|
-
-((pointerEvent.clientY - moveRect.top) / moveRect.height) * 2 + 1,
|
|
2793
|
-
);
|
|
2794
|
-
raycaster.setFromCamera(moveNdc, camera);
|
|
2795
|
-
if (!raycaster.ray.intersectPlane(plane, hit)) return;
|
|
2796
|
-
lastDelta = { x: hit.x - start.x, y: hit.y - start.y, z: hit.z - start.z };
|
|
2797
|
-
queued = lastDelta;
|
|
2798
|
-
if (!rafId) rafId = requestAnimationFrame(flush);
|
|
2799
|
-
};
|
|
2800
|
-
const up = () => {
|
|
2801
|
-
window.removeEventListener("pointermove", move);
|
|
2802
|
-
window.removeEventListener("pointerup", up);
|
|
2803
|
-
if (rafId) cancelAnimationFrame(rafId);
|
|
2804
|
-
if (queued) applyPreview(queued);
|
|
2805
|
-
setDrag(null);
|
|
2806
|
-
callbacksRef.current.onDragEnd?.({ track, grabFrame, delta: lastDelta });
|
|
2807
|
-
};
|
|
2808
|
-
window.addEventListener("pointermove", move);
|
|
2809
|
-
window.addEventListener("pointerup", up);
|
|
2810
|
-
};
|
|
2811
|
-
dom.addEventListener("pointerdown", down, true);
|
|
2812
|
-
return () => dom.removeEventListener("pointerdown", down, true);
|
|
2813
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2814
|
-
}, [gl, camera]);
|
|
2815
|
-
|
|
2816
|
-
if (!tracks.length) return null;
|
|
2817
|
-
const focusTrack = TRAIL_EFFECTOR_JOINTS[ikFocus] ? tracks.find((track) => track.joint === TRAIL_EFFECTOR_JOINTS[ikFocus])?.id : null;
|
|
2818
|
-
// depthTest off + high renderOrder: the trails read through the character
|
|
2819
|
-
// and the floor instead of vanishing into them. Every part rides its own
|
|
2820
|
-
// IK-handle colour; the focused part draws thicker.
|
|
2821
|
-
return (
|
|
2822
|
-
<group
|
|
2823
|
-
visible={visible}
|
|
2824
|
-
renderOrder={900}
|
|
2825
|
-
ref={(group) => {
|
|
2826
|
-
// QA-only escape hatch (same spirit as window.__cozyclay): lets
|
|
2827
|
-
// headless perf probes toggle the trails without a rebuild.
|
|
2828
|
-
if (typeof window !== "undefined") window.__cozyclayTrails = group;
|
|
2829
|
-
}}
|
|
2830
|
-
>
|
|
2831
|
-
{tracks.map((track) => track.points.length > 1 && (
|
|
2832
|
-
<Line
|
|
2833
|
-
key={track.id}
|
|
2834
|
-
ref={(line) => {
|
|
2835
|
-
if (line) lineRefs.current[track.id] = line;
|
|
2836
|
-
else delete lineRefs.current[track.id];
|
|
2837
|
-
}}
|
|
2838
|
-
points={track.points}
|
|
2839
|
-
color={track.color}
|
|
2840
|
-
lineWidth={track.id === focusTrack ? 4 : 2.5}
|
|
2841
|
-
depthTest={false}
|
|
2842
|
-
transparent
|
|
2843
|
-
opacity={track.id === focusTrack ? 1 : 0.85}
|
|
2844
|
-
/>
|
|
2845
|
-
))}
|
|
2846
|
-
{highlightPoints && (
|
|
2847
|
-
<Line ref={highlightRef} points={highlightPoints} color={drag ? "#3dff7a" : "#ff8c42"} lineWidth={5} depthTest={false} />
|
|
2848
|
-
)}
|
|
2849
|
-
</group>
|
|
2850
|
-
);
|
|
2851
|
-
}, (previous, next) => (
|
|
2852
|
-
previous.motion === next.motion &&
|
|
2853
|
-
previous.rig === next.rig &&
|
|
2854
|
-
previous.baseY === next.baseY &&
|
|
2855
|
-
previous.charScale === next.charScale &&
|
|
2856
|
-
previous.ikFocus === next.ikFocus &&
|
|
2857
|
-
previous.falloffFrames === next.falloffFrames &&
|
|
2858
|
-
previous.playheadFrame === next.playheadFrame &&
|
|
2859
|
-
previous.pendingEdit === next.pendingEdit &&
|
|
2860
|
-
previous.enabled === next.enabled &&
|
|
2861
|
-
previous.visible === next.visible
|
|
2862
|
-
));
|