@zkmake/three-cameras 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -16
- package/dist/channels-Dp6gFE5b.js +1324 -0
- package/dist/channels-Dp6gFE5b.js.map +1 -0
- package/dist/index.d.ts +17 -2
- package/dist/index.js +2 -2
- package/dist/{lab-AaEFBp_R.d.ts → lab-DuEV-i3c.d.ts} +165 -3
- package/dist/mount-BAkbs7K2.js +1974 -0
- package/dist/mount-BAkbs7K2.js.map +1 -0
- package/dist/{mount-Gf7I61p2.d.ts → mount-DPlwtFv9.d.ts} +7 -5
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/ui.d.ts +40 -5
- package/dist/ui.js +2 -2
- package/package.json +2 -2
- package/dist/lab-D4PnOVwG.js +0 -605
- package/dist/lab-D4PnOVwG.js.map +0 -1
- package/dist/mount-DBjcu7if.js +0 -770
- package/dist/mount-DBjcu7if.js.map +0 -1
|
@@ -0,0 +1,1974 @@
|
|
|
1
|
+
import { _ as poseToCode, a as writeChannel, c as PRESET_BEZIERS, h as capturePose, i as sampleChannel, l as applyEase, n as CHANNEL_LABELS, o as CameraLab, r as readChannel, s as EASES, t as CHANNELS } from "./channels-Dp6gFE5b.js";
|
|
2
|
+
import { Matrix4, Plane, Ray, Vector3 } from "three";
|
|
3
|
+
import { HudTheme, mountDevPanel } from "@zkmake/three-meter/ui";
|
|
4
|
+
//#region src/ui/styles.ts
|
|
5
|
+
/** The panel's stylesheet, injected once. Palettes by `data-theme` on `.tcm`. */
|
|
6
|
+
const CAMERA_PANEL_STYLES = `
|
|
7
|
+
/* Inside a dev-panel card the tokens come from three-meter's (\`--perf-*\`), so the panels match;
|
|
8
|
+
on its own (in a host's tab) the same palette is the fallback. */
|
|
9
|
+
.tcm {
|
|
10
|
+
--tcm-bg: var(--perf-bg, rgba(22, 24, 29, 0.92));
|
|
11
|
+
--tcm-fg: var(--perf-fg, #e6e8eb);
|
|
12
|
+
--tcm-dim: var(--perf-muted, #8b909a);
|
|
13
|
+
--tcm-line: var(--perf-border, rgba(255, 255, 255, 0.12));
|
|
14
|
+
--tcm-field: var(--perf-row, rgba(255, 255, 255, 0.04));
|
|
15
|
+
--tcm-hover: var(--perf-row, rgba(255, 255, 255, 0.04));
|
|
16
|
+
--tcm-accent: var(--perf-accent, #60a5fa);
|
|
17
|
+
--tcm-accent-soft: rgba(96, 165, 250, 0.14);
|
|
18
|
+
--tcm-live: #34d399;
|
|
19
|
+
box-sizing: border-box;
|
|
20
|
+
color: var(--tcm-fg);
|
|
21
|
+
font: 11px/1.35 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
22
|
+
}
|
|
23
|
+
.tcm[data-theme="light"] {
|
|
24
|
+
--tcm-bg: rgba(250, 250, 252, 0.94);
|
|
25
|
+
--tcm-fg: #1a1c21;
|
|
26
|
+
--tcm-dim: #6b7079;
|
|
27
|
+
--tcm-line: rgba(0, 0, 0, 0.12);
|
|
28
|
+
--tcm-field: rgba(0, 0, 0, 0.05);
|
|
29
|
+
--tcm-hover: rgba(0, 0, 0, 0.05);
|
|
30
|
+
--tcm-accent: #2563eb;
|
|
31
|
+
--tcm-accent-soft: rgba(37, 99, 235, 0.1);
|
|
32
|
+
--tcm-live: #047857;
|
|
33
|
+
}
|
|
34
|
+
.tcm *, .tcm *::before, .tcm *::after { box-sizing: inherit; }
|
|
35
|
+
.tcm button { font: inherit; color: inherit; }
|
|
36
|
+
|
|
37
|
+
.tcm-panel { display: flex; flex-direction: column; gap: 6px; min-height: 0; padding: 8px; }
|
|
38
|
+
/* In the dev-panel card: a fixed width (the frame's cap raised to fit), the list scrolling
|
|
39
|
+
inside the card's height. */
|
|
40
|
+
.perf-hud.tcm-frame { max-width: min(23rem, calc(100vw - 48px)); }
|
|
41
|
+
/* With the timeline: one panel across the bottom of the screen, anchored (no drag grip), the
|
|
42
|
+
camera list as the timeline's sidebar. Compact, the list alone. */
|
|
43
|
+
.perf-hud.tcm-combined-frame .perf-hud__drag { display: none; }
|
|
44
|
+
/* Full width: the control discs go in a row on the open side, above the panel on the bottom
|
|
45
|
+
edge and below it on the top, so the panel can reach both sides of the screen. */
|
|
46
|
+
.perf-hud.tcm-combined-frame:not(.tcm-compact) { width: calc(100vw - 20px); max-width: none; }
|
|
47
|
+
.perf-hud.tcm-combined-frame:not(.tcm-compact) .perf-hud__tools { left: auto; right: 4px; flex-direction: row; }
|
|
48
|
+
.perf-hud.tcm-combined-frame:not(.tcm-compact)[data-edge="bottom"] .perf-hud__tools { top: auto; bottom: calc(100% + 6px); }
|
|
49
|
+
.perf-hud.tcm-combined-frame:not(.tcm-compact)[data-edge="top"] .perf-hud__tools { top: calc(100% + 6px); }
|
|
50
|
+
.perf-hud.tcm-combined-frame:not(.tcm-compact) .perf-hud__hotspot { right: -10px; left: -10px; }
|
|
51
|
+
.perf-hud.tcm-combined-frame:not(.tcm-compact)[data-edge="bottom"] .perf-hud__hotspot { top: -40px; bottom: -10px; }
|
|
52
|
+
.perf-hud.tcm-combined-frame:not(.tcm-compact)[data-edge="top"] .perf-hud__hotspot { top: -10px; bottom: -40px; }
|
|
53
|
+
.tcm-shell { display: flex; flex-direction: column; min-height: 0; }
|
|
54
|
+
.tcm-shell > .tcm-timeline.is-combined { width: 100%; max-height: min(28rem, 40vh); }
|
|
55
|
+
.tcm-shell > .tcm-panel { width: 22rem; max-width: 100%; }
|
|
56
|
+
.tcm-timeline.is-combined { min-height: 0; padding-bottom: 6px; }
|
|
57
|
+
.tcm-timeline.is-combined .tcm-tl { flex: 1; min-height: 0; grid-template-columns: 21rem minmax(0, 1fr); overflow-y: auto; }
|
|
58
|
+
.tcm-timeline.is-combined .tcm-tl-names { display: block; border-right: 1px solid var(--tcm-line); }
|
|
59
|
+
.tcm-timeline.is-combined .tcm-tl-names > .tcm-panel { padding: 0 6px 0 0; }
|
|
60
|
+
.tcm-timeline.is-combined .tcm-panel .tcm-list { overflow: visible; }
|
|
61
|
+
.tcm-timeline.is-combined .tcm-panel .tcm-toolbar,
|
|
62
|
+
.tcm-timeline.is-combined .tcm-ruler { position: sticky; top: 0; z-index: 2; height: 30px; background: var(--tcm-bg); }
|
|
63
|
+
.tcm-timeline.is-combined .tcm-panel .tcm-toolbar { align-items: center; }
|
|
64
|
+
.tcm-timeline.is-combined .tcm-tl-area { margin-left: 0; }
|
|
65
|
+
.tcm-timeline.is-combined .tcm-lanes { position: relative; display: block; }
|
|
66
|
+
.tcm-timeline.is-combined .tcm-lane { position: absolute; left: 0; right: 0; border-bottom: 0; border-top: 1px solid var(--tcm-line); }
|
|
67
|
+
.tcm-timeline.is-combined .tcm-lane[hidden] { display: none; }
|
|
68
|
+
.tcm-timeline.is-combined .tcm-lane.is-inert { cursor: default; background: repeating-linear-gradient(135deg, transparent 0 6px, var(--tcm-field) 6px 7px); }
|
|
69
|
+
.tcm-timeline.is-combined .tcm-key { top: calc(var(--tcm-head, 24px) / 2); }
|
|
70
|
+
.tcm-timeline.is-combined .tcm-graph-side { padding: 6px 0 4px; }
|
|
71
|
+
|
|
72
|
+
/* Compact, the list alone: the brand row, with the count. */
|
|
73
|
+
/* Collapsed: a small widget with the picked camera, and nothing else. */
|
|
74
|
+
.perf-hud.tcm-compact .tcm-shell > :not(.tcm-mini) { display: none; }
|
|
75
|
+
.perf-hud.tcm-frame.tcm-compact .perf-hud__brand-detail { display: none; }
|
|
76
|
+
.tcm-mini { display: none; }
|
|
77
|
+
.perf-hud.tcm-compact .tcm-mini {
|
|
78
|
+
display: flex;
|
|
79
|
+
align-items: center;
|
|
80
|
+
gap: 6px;
|
|
81
|
+
min-width: 10rem;
|
|
82
|
+
max-width: 18rem;
|
|
83
|
+
margin: 0 6px 6px;
|
|
84
|
+
padding: 3px 8px 3px 4px;
|
|
85
|
+
border: 1px solid var(--tcm-line);
|
|
86
|
+
border-radius: 5px;
|
|
87
|
+
background: var(--tcm-field);
|
|
88
|
+
text-align: left;
|
|
89
|
+
cursor: pointer;
|
|
90
|
+
}
|
|
91
|
+
.tcm-mini:hover { border-color: var(--tcm-accent); }
|
|
92
|
+
.tcm-mini:focus-visible { outline: 1px solid var(--tcm-accent); }
|
|
93
|
+
.tcm-mini-kind { display: grid; place-items: center; color: var(--tcm-dim); }
|
|
94
|
+
.tcm-mini.is-live .tcm-mini-kind { color: var(--tcm-live); }
|
|
95
|
+
.tcm-mini.is-viewing .tcm-mini-kind { color: var(--tcm-accent); }
|
|
96
|
+
.tcm-mini-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
|
|
97
|
+
.tcm-mini-clock { margin-left: auto; color: var(--tcm-dim); font-variant-numeric: tabular-nums; }
|
|
98
|
+
.perf-hud.tcm-compact .perf-hud__brand { padding-bottom: 6px; }
|
|
99
|
+
|
|
100
|
+
.tcm-toolbar { display: flex; gap: 6px; }
|
|
101
|
+
.tcm-search {
|
|
102
|
+
flex: 1;
|
|
103
|
+
min-width: 0;
|
|
104
|
+
border: 1px solid var(--tcm-line);
|
|
105
|
+
border-radius: 4px;
|
|
106
|
+
background: var(--tcm-field);
|
|
107
|
+
padding: 4px 6px;
|
|
108
|
+
color: inherit;
|
|
109
|
+
font: inherit;
|
|
110
|
+
}
|
|
111
|
+
.tcm-search::placeholder { color: var(--tcm-dim); }
|
|
112
|
+
.tcm-search:focus { outline: 1px solid var(--tcm-accent); outline-offset: 0; }
|
|
113
|
+
|
|
114
|
+
.tcm-icon {
|
|
115
|
+
display: inline-grid;
|
|
116
|
+
place-items: center;
|
|
117
|
+
width: 26px;
|
|
118
|
+
height: 26px;
|
|
119
|
+
flex: none;
|
|
120
|
+
padding: 0;
|
|
121
|
+
border: 1px solid transparent;
|
|
122
|
+
border-radius: 5px;
|
|
123
|
+
background: transparent;
|
|
124
|
+
color: var(--tcm-dim);
|
|
125
|
+
cursor: pointer;
|
|
126
|
+
}
|
|
127
|
+
.tcm-icon:hover:not(:disabled) { background: var(--tcm-hover); color: var(--tcm-fg); }
|
|
128
|
+
.tcm-icon:focus-visible { outline: 1px solid var(--tcm-accent); }
|
|
129
|
+
.tcm-icon:disabled { visibility: hidden; }
|
|
130
|
+
.tcm-icon[aria-pressed="true"] { color: var(--tcm-accent); background: var(--tcm-accent-soft); }
|
|
131
|
+
/* Line icons; the graph and the ease editor draw their own. */
|
|
132
|
+
.tcm svg:not(.tcm-graph-svg, .tcm-ease svg) {
|
|
133
|
+
width: 16px;
|
|
134
|
+
height: 16px;
|
|
135
|
+
fill: none;
|
|
136
|
+
stroke: currentColor;
|
|
137
|
+
stroke-width: 2;
|
|
138
|
+
stroke-linecap: round;
|
|
139
|
+
stroke-linejoin: round;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.tcm-list { margin: 0; padding: 0; list-style: none; overflow-y: auto; min-height: 0; }
|
|
143
|
+
.tcm-row { border-radius: 5px; border: 1px solid transparent; }
|
|
144
|
+
.tcm-row.is-hidden { display: none; }
|
|
145
|
+
.tcm-row.is-open { border-color: var(--tcm-line); background: var(--tcm-field); }
|
|
146
|
+
.tcm-head {
|
|
147
|
+
display: grid;
|
|
148
|
+
grid-template-columns: 32px 1fr auto;
|
|
149
|
+
align-items: center;
|
|
150
|
+
gap: 8px;
|
|
151
|
+
padding: 4px;
|
|
152
|
+
border-radius: 5px;
|
|
153
|
+
}
|
|
154
|
+
.tcm-row:not(.is-open) .tcm-head:hover { background: var(--tcm-hover); }
|
|
155
|
+
.tcm-kind {
|
|
156
|
+
display: grid;
|
|
157
|
+
place-items: center;
|
|
158
|
+
width: 32px;
|
|
159
|
+
height: 32px;
|
|
160
|
+
border: 1px solid var(--tcm-line);
|
|
161
|
+
border-radius: 5px;
|
|
162
|
+
color: var(--tcm-dim);
|
|
163
|
+
}
|
|
164
|
+
.tcm-row.is-live .tcm-kind { color: var(--tcm-live); border-color: color-mix(in srgb, var(--tcm-live) 45%, transparent); }
|
|
165
|
+
.tcm-text {
|
|
166
|
+
min-width: 0;
|
|
167
|
+
padding: 0;
|
|
168
|
+
border: 0;
|
|
169
|
+
background: none;
|
|
170
|
+
text-align: left;
|
|
171
|
+
cursor: pointer;
|
|
172
|
+
}
|
|
173
|
+
.tcm-name-line { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
|
|
174
|
+
.tcm-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
|
|
175
|
+
.tcm-badge { flex: none; color: var(--tcm-dim); }
|
|
176
|
+
.tcm-badge:empty { display: none; }
|
|
177
|
+
.tcm-badge.is-live, .tcm-badge.is-viewing { display: inline-flex; align-items: center; gap: 4px; }
|
|
178
|
+
.tcm-badge.is-live { color: var(--tcm-live); }
|
|
179
|
+
.tcm-badge.is-viewing { color: var(--tcm-accent); }
|
|
180
|
+
.tcm-badge.is-live::before {
|
|
181
|
+
content: "";
|
|
182
|
+
width: 6px;
|
|
183
|
+
height: 6px;
|
|
184
|
+
border-radius: 50%;
|
|
185
|
+
background: currentColor;
|
|
186
|
+
animation: tcm-pulse 1.6s ease-in-out infinite;
|
|
187
|
+
}
|
|
188
|
+
@keyframes tcm-pulse { 50% { opacity: 0.25; } }
|
|
189
|
+
@media (prefers-reduced-motion: reduce) { .tcm-badge.is-live::before { animation: none; } }
|
|
190
|
+
.tcm-row.is-viewing .tcm-kind { color: var(--tcm-accent); border-color: color-mix(in srgb, var(--tcm-accent) 50%, transparent); }
|
|
191
|
+
.tcm-meta { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--tcm-dim); }
|
|
192
|
+
.tcm-actions { display: flex; gap: 2px; }
|
|
193
|
+
.tcm-chevron svg { transition: rotate 120ms; }
|
|
194
|
+
.tcm-row.is-open .tcm-chevron svg { rotate: 180deg; }
|
|
195
|
+
|
|
196
|
+
.tcm-details {
|
|
197
|
+
display: none;
|
|
198
|
+
gap: 8px;
|
|
199
|
+
padding: 2px 8px 8px;
|
|
200
|
+
}
|
|
201
|
+
.tcm-row.is-open .tcm-details { display: grid; }
|
|
202
|
+
.tcm-group { display: grid; gap: 3px; }
|
|
203
|
+
.tcm-term { color: var(--tcm-dim); }
|
|
204
|
+
.tcm-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; }
|
|
205
|
+
.tcm-fields--lens { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
206
|
+
.tcm-num {
|
|
207
|
+
display: flex;
|
|
208
|
+
align-items: center;
|
|
209
|
+
min-width: 0;
|
|
210
|
+
border: 1px solid var(--tcm-line);
|
|
211
|
+
border-radius: 4px;
|
|
212
|
+
background: var(--tcm-field);
|
|
213
|
+
}
|
|
214
|
+
.tcm-num:focus-within { border-color: var(--tcm-accent); }
|
|
215
|
+
.tcm-scrub {
|
|
216
|
+
flex: none;
|
|
217
|
+
padding: 0 5px;
|
|
218
|
+
color: var(--tcm-dim);
|
|
219
|
+
cursor: ew-resize;
|
|
220
|
+
user-select: none;
|
|
221
|
+
touch-action: none;
|
|
222
|
+
}
|
|
223
|
+
.tcm-scrub:hover { color: var(--tcm-fg); }
|
|
224
|
+
.tcm-num input {
|
|
225
|
+
width: 100%;
|
|
226
|
+
min-width: 0;
|
|
227
|
+
padding: 3px 5px 3px 0;
|
|
228
|
+
border: 0;
|
|
229
|
+
background: none;
|
|
230
|
+
color: inherit;
|
|
231
|
+
font: inherit;
|
|
232
|
+
font-variant-numeric: tabular-nums;
|
|
233
|
+
text-align: right;
|
|
234
|
+
-moz-appearance: textfield;
|
|
235
|
+
appearance: textfield;
|
|
236
|
+
}
|
|
237
|
+
.tcm-num input::-webkit-inner-spin-button, .tcm-num input::-webkit-outer-spin-button { appearance: none; margin: 0; }
|
|
238
|
+
.tcm-num input:focus { outline: none; }
|
|
239
|
+
.tcm-readout { display: grid; grid-template-columns: auto 1fr; gap: 2px 10px; margin: 0; }
|
|
240
|
+
.tcm-readout dt { color: var(--tcm-dim); }
|
|
241
|
+
.tcm-readout dd { margin: 0; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
|
242
|
+
.tcm-buttons { display: flex; gap: 6px; }
|
|
243
|
+
.tcm-text-button {
|
|
244
|
+
border: 1px solid var(--tcm-line);
|
|
245
|
+
border-radius: 4px;
|
|
246
|
+
background: var(--tcm-field);
|
|
247
|
+
padding: 3px 8px;
|
|
248
|
+
cursor: pointer;
|
|
249
|
+
white-space: nowrap;
|
|
250
|
+
}
|
|
251
|
+
.tcm-text-button:hover { border-color: var(--tcm-accent); }
|
|
252
|
+
.tcm-text-button:focus-visible { outline: 1px solid var(--tcm-accent); }
|
|
253
|
+
.tcm-views { display: flex; flex-wrap: wrap; gap: 4px; margin: 0; padding: 0; list-style: none; }
|
|
254
|
+
.tcm-views:empty { display: none; }
|
|
255
|
+
.tcm-view { display: flex; align-items: center; border: 1px solid var(--tcm-line); border-radius: 4px; }
|
|
256
|
+
.tcm-view .tcm-icon { width: 20px; height: 20px; }
|
|
257
|
+
.tcm-view .tcm-icon svg { width: 12px; height: 12px; }
|
|
258
|
+
.tcm-view-go {
|
|
259
|
+
display: inline-flex;
|
|
260
|
+
align-items: center;
|
|
261
|
+
gap: 4px;
|
|
262
|
+
padding: 2px 4px 2px 6px;
|
|
263
|
+
border: 0;
|
|
264
|
+
background: none;
|
|
265
|
+
cursor: pointer;
|
|
266
|
+
}
|
|
267
|
+
.tcm-view-go svg { width: 12px; height: 12px; }
|
|
268
|
+
.tcm-view-go:hover { color: var(--tcm-accent); }
|
|
269
|
+
.tcm-banner {
|
|
270
|
+
display: flex;
|
|
271
|
+
align-items: center;
|
|
272
|
+
justify-content: space-between;
|
|
273
|
+
gap: 8px;
|
|
274
|
+
padding: 4px 4px 4px 8px;
|
|
275
|
+
border-radius: 5px;
|
|
276
|
+
background: var(--tcm-accent-soft);
|
|
277
|
+
color: var(--tcm-accent);
|
|
278
|
+
}
|
|
279
|
+
.tcm-banner[hidden] { display: none; }
|
|
280
|
+
.tcm-status { color: var(--tcm-dim); padding: 0 4px; }
|
|
281
|
+
.tcm-status:empty { display: none; }
|
|
282
|
+
.tcm-empty { color: var(--tcm-dim); padding: 6px 4px; }
|
|
283
|
+
.tcm-row.is-selected:not(.is-open) .tcm-head { background: var(--tcm-hover); }
|
|
284
|
+
.tcm-keys { color: var(--tcm-accent); }
|
|
285
|
+
|
|
286
|
+
/* Timeline */
|
|
287
|
+
.tcm-timeline { display: flex; flex-direction: column; gap: 6px; padding: 8px; outline: none; }
|
|
288
|
+
.tcm-transport, .tcm-inspector { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
|
289
|
+
.tcm-spacer { flex: 1; }
|
|
290
|
+
.tcm-clock { min-width: 9.5em; padding: 0 6px; font-variant-numeric: tabular-nums; }
|
|
291
|
+
.tcm-timeline.is-engaged .tcm-clock { color: var(--tcm-accent); }
|
|
292
|
+
.tcm-length { display: inline-flex; align-items: center; gap: 4px; color: var(--tcm-dim); }
|
|
293
|
+
.tcm-length input, .tcm-inspector select {
|
|
294
|
+
width: 4.5em;
|
|
295
|
+
padding: 2px 4px;
|
|
296
|
+
border: 1px solid var(--tcm-line);
|
|
297
|
+
border-radius: 4px;
|
|
298
|
+
background: var(--tcm-field);
|
|
299
|
+
color: var(--tcm-fg);
|
|
300
|
+
font: inherit;
|
|
301
|
+
font-variant-numeric: tabular-nums;
|
|
302
|
+
}
|
|
303
|
+
.tcm-inspector select { width: auto; }
|
|
304
|
+
.tcm-length input:focus, .tcm-inspector select:focus { outline: 1px solid var(--tcm-accent); }
|
|
305
|
+
.tcm-add-key { display: inline-flex; align-items: center; gap: 5px; max-width: 14em; }
|
|
306
|
+
.tcm-add-key span { overflow: hidden; text-overflow: ellipsis; }
|
|
307
|
+
.tcm-add-key svg { width: 12px; height: 12px; color: var(--tcm-accent); }
|
|
308
|
+
.tcm-add-key:disabled { opacity: 0.4; cursor: default; }
|
|
309
|
+
.tcm-inspector { padding: 4px 6px; border-radius: 5px; background: var(--tcm-field); }
|
|
310
|
+
.tcm-inspector[hidden] { display: none; }
|
|
311
|
+
.tcm-inspector-title { font-weight: 600; margin-right: 4px; }
|
|
312
|
+
|
|
313
|
+
.tcm-tl { display: grid; grid-template-columns: 7.5rem minmax(0, 1fr); }
|
|
314
|
+
.tcm-tl-names { display: grid; grid-auto-rows: 24px; grid-template-rows: 20px; }
|
|
315
|
+
.tcm-tl-corner { border-bottom: 1px solid var(--tcm-line); }
|
|
316
|
+
.tcm-tl-name {
|
|
317
|
+
overflow: hidden;
|
|
318
|
+
padding: 0 8px 0 2px;
|
|
319
|
+
border: 0;
|
|
320
|
+
border-bottom: 1px solid var(--tcm-line);
|
|
321
|
+
background: none;
|
|
322
|
+
color: var(--tcm-dim);
|
|
323
|
+
text-align: left;
|
|
324
|
+
text-overflow: ellipsis;
|
|
325
|
+
white-space: nowrap;
|
|
326
|
+
cursor: pointer;
|
|
327
|
+
}
|
|
328
|
+
.tcm-tl-name:hover, .tcm-tl-name.is-selected { color: var(--tcm-fg); }
|
|
329
|
+
.tcm-tl-name.is-selected { font-weight: 600; }
|
|
330
|
+
.tcm-tl-area { position: relative; min-width: 0; margin-right: 8px; }
|
|
331
|
+
.tcm-ruler {
|
|
332
|
+
position: relative;
|
|
333
|
+
height: 20px;
|
|
334
|
+
border-bottom: 1px solid var(--tcm-line);
|
|
335
|
+
cursor: ew-resize;
|
|
336
|
+
touch-action: none;
|
|
337
|
+
}
|
|
338
|
+
.tcm-tick { position: absolute; bottom: 0; width: 1px; height: 4px; background: var(--tcm-line); }
|
|
339
|
+
.tcm-tick.is-major { height: 8px; background: var(--tcm-dim); }
|
|
340
|
+
.tcm-tick b {
|
|
341
|
+
position: absolute;
|
|
342
|
+
bottom: 9px;
|
|
343
|
+
left: 0;
|
|
344
|
+
transform: translateX(-50%);
|
|
345
|
+
color: var(--tcm-dim);
|
|
346
|
+
font-weight: 400;
|
|
347
|
+
font-size: 10px;
|
|
348
|
+
}
|
|
349
|
+
.tcm-lanes { display: grid; grid-auto-rows: 24px; }
|
|
350
|
+
.tcm-lane { position: relative; border-bottom: 1px solid var(--tcm-line); cursor: crosshair; touch-action: none; }
|
|
351
|
+
.tcm-lane.is-selected { background: var(--tcm-hover); }
|
|
352
|
+
.tcm-key {
|
|
353
|
+
position: absolute;
|
|
354
|
+
top: 50%;
|
|
355
|
+
width: 11px;
|
|
356
|
+
height: 11px;
|
|
357
|
+
padding: 0;
|
|
358
|
+
border: 1px solid var(--tcm-bg);
|
|
359
|
+
border-radius: 2px;
|
|
360
|
+
background: var(--tcm-accent);
|
|
361
|
+
transform: translate(-50%, -50%) rotate(45deg);
|
|
362
|
+
cursor: grab;
|
|
363
|
+
touch-action: none;
|
|
364
|
+
}
|
|
365
|
+
.tcm-key.is-hold { border-radius: 0; background: var(--tcm-dim); }
|
|
366
|
+
.tcm-key.is-picked { outline: 2px solid var(--tcm-fg); outline-offset: 1px; }
|
|
367
|
+
.tcm-key:active { cursor: grabbing; }
|
|
368
|
+
.tcm-key:focus-visible { outline: 2px solid var(--tcm-accent); outline-offset: 2px; }
|
|
369
|
+
.tcm-playhead {
|
|
370
|
+
position: absolute;
|
|
371
|
+
top: 0;
|
|
372
|
+
bottom: 0;
|
|
373
|
+
width: 1px;
|
|
374
|
+
margin-left: -0.5px;
|
|
375
|
+
background: #f87171;
|
|
376
|
+
pointer-events: none;
|
|
377
|
+
}
|
|
378
|
+
.tcm-playhead::before {
|
|
379
|
+
content: "";
|
|
380
|
+
position: absolute;
|
|
381
|
+
top: 0;
|
|
382
|
+
left: -4px;
|
|
383
|
+
border: 4.5px solid transparent;
|
|
384
|
+
border-top-color: #f87171;
|
|
385
|
+
}
|
|
386
|
+
.tcm-tl-empty { padding: 2px 4px; }
|
|
387
|
+
|
|
388
|
+
/* Keys | Graph */
|
|
389
|
+
.tcm-modes { display: inline-flex; margin-left: 4px; border: 1px solid var(--tcm-line); border-radius: 5px; overflow: hidden; }
|
|
390
|
+
.tcm-modes button { padding: 2px 8px; border: 0; background: none; color: var(--tcm-dim); cursor: pointer; }
|
|
391
|
+
.tcm-modes button[aria-checked="true"] { background: var(--tcm-accent-soft); color: var(--tcm-accent); }
|
|
392
|
+
.tcm-modes button:focus-visible { outline: 1px solid var(--tcm-accent); outline-offset: -2px; }
|
|
393
|
+
|
|
394
|
+
/* Lane names with the motion-path toggle */
|
|
395
|
+
.tcm-tl-row { display: flex; align-items: center; min-width: 0; border-bottom: 1px solid var(--tcm-line); }
|
|
396
|
+
.tcm-tl-row .tcm-tl-name { flex: 1; min-width: 0; height: 100%; border-bottom: 0; }
|
|
397
|
+
.tcm-trail { width: 20px; height: 20px; margin-right: 4px; }
|
|
398
|
+
.tcm-trail svg { width: 13px; height: 13px; }
|
|
399
|
+
.tcm-trail:disabled { visibility: hidden; }
|
|
400
|
+
|
|
401
|
+
/* Graph view: the channel list in the names column, the plot under the ruler. */
|
|
402
|
+
.tcm-graph, .tcm-graph-side { display: none; }
|
|
403
|
+
.tcm-timeline.is-graph .tcm-lanes, .tcm-timeline.is-graph .tcm-tl-row { display: none; }
|
|
404
|
+
.tcm-timeline.is-graph .tcm-tl-names { grid-auto-rows: auto; }
|
|
405
|
+
.tcm-timeline.is-graph .tcm-graph { display: block; }
|
|
406
|
+
.tcm-timeline.is-graph .tcm-graph-side { display: flex; flex-wrap: wrap; align-content: flex-start; gap: 3px; padding: 6px 6px 0 0; }
|
|
407
|
+
.tcm-graph-svg { display: block; touch-action: none; overflow: visible; }
|
|
408
|
+
.tcm-grid { stroke: var(--tcm-line); stroke-width: 1; }
|
|
409
|
+
.tcm-key-line { stroke: var(--tcm-line); stroke-dasharray: 2 3; }
|
|
410
|
+
.tcm-curve { fill: none; stroke-width: 1.6; stroke-linejoin: round; }
|
|
411
|
+
.tcm-dot { stroke: var(--tcm-bg); stroke-width: 1.5; cursor: grab; }
|
|
412
|
+
.tcm-dot:hover { stroke: var(--tcm-fg); }
|
|
413
|
+
.tcm-dot.is-picked { stroke: var(--tcm-fg); stroke-width: 2; }
|
|
414
|
+
.tcm-graph-empty { fill: var(--tcm-dim); font: inherit; }
|
|
415
|
+
.tcm-graph-axis { fill: var(--tcm-dim); font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
416
|
+
.tcm-chan {
|
|
417
|
+
display: inline-flex;
|
|
418
|
+
align-items: center;
|
|
419
|
+
gap: 4px;
|
|
420
|
+
padding: 1px 6px;
|
|
421
|
+
border: 1px solid var(--tcm-line);
|
|
422
|
+
border-radius: 4px;
|
|
423
|
+
background: none;
|
|
424
|
+
color: var(--tcm-dim);
|
|
425
|
+
cursor: pointer;
|
|
426
|
+
}
|
|
427
|
+
.tcm-chan::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--tcm-chan, currentColor); opacity: 0.35; }
|
|
428
|
+
.tcm-chan[aria-pressed="true"] { color: var(--tcm-fg); border-color: color-mix(in srgb, var(--tcm-chan, var(--tcm-accent)) 55%, transparent); }
|
|
429
|
+
.tcm-chan[aria-pressed="true"]::before { opacity: 1; }
|
|
430
|
+
.tcm-chan--mode::before { display: none; }
|
|
431
|
+
.tcm-chan:focus-visible { outline: 1px solid var(--tcm-accent); }
|
|
432
|
+
|
|
433
|
+
/* Ease curve editor, in the key inspector */
|
|
434
|
+
.tcm-ease { display: flex; }
|
|
435
|
+
.tcm-ease svg { display: block; overflow: visible; touch-action: none; }
|
|
436
|
+
.tcm-ease:has(svg:empty) { display: none; }
|
|
437
|
+
.tcm-ease-box { fill: none; stroke: var(--tcm-line); }
|
|
438
|
+
.tcm-ease-curve { fill: none; stroke: var(--tcm-accent); stroke-width: 2; }
|
|
439
|
+
.tcm-ease-arm { stroke: var(--tcm-dim); stroke-width: 1; }
|
|
440
|
+
.tcm-ease-handle { fill: var(--tcm-accent); stroke: var(--tcm-bg); stroke-width: 1.5; cursor: grab; }
|
|
441
|
+
.tcm-ease-handle:hover { stroke: var(--tcm-fg); }
|
|
442
|
+
`;
|
|
443
|
+
const STYLE_ID = "zkmake-three-cameras-styles";
|
|
444
|
+
/** Add the stylesheet to `document.head` once. */
|
|
445
|
+
const injectStyles = () => {
|
|
446
|
+
if (typeof document === "undefined" || document.getElementById(STYLE_ID)) return;
|
|
447
|
+
const style = document.createElement("style");
|
|
448
|
+
style.id = STYLE_ID;
|
|
449
|
+
style.textContent = CAMERA_PANEL_STYLES;
|
|
450
|
+
document.head.append(style);
|
|
451
|
+
};
|
|
452
|
+
//#endregion
|
|
453
|
+
//#region src/ui/panel.ts
|
|
454
|
+
/** Live badges and open rows update this often, ms. */
|
|
455
|
+
const TICK_MS = 250;
|
|
456
|
+
/** Per field: the step a typed arrow key takes and what a pixel of scrubbing adds. */
|
|
457
|
+
const FIELDS = {
|
|
458
|
+
px: {
|
|
459
|
+
label: "x",
|
|
460
|
+
step: .01,
|
|
461
|
+
perPixel: .02
|
|
462
|
+
},
|
|
463
|
+
py: {
|
|
464
|
+
label: "y",
|
|
465
|
+
step: .01,
|
|
466
|
+
perPixel: .02
|
|
467
|
+
},
|
|
468
|
+
pz: {
|
|
469
|
+
label: "z",
|
|
470
|
+
step: .01,
|
|
471
|
+
perPixel: .02
|
|
472
|
+
},
|
|
473
|
+
rx: {
|
|
474
|
+
label: "x",
|
|
475
|
+
step: .1,
|
|
476
|
+
perPixel: .5
|
|
477
|
+
},
|
|
478
|
+
ry: {
|
|
479
|
+
label: "y",
|
|
480
|
+
step: .1,
|
|
481
|
+
perPixel: .5
|
|
482
|
+
},
|
|
483
|
+
rz: {
|
|
484
|
+
label: "z",
|
|
485
|
+
step: .1,
|
|
486
|
+
perPixel: .5
|
|
487
|
+
},
|
|
488
|
+
fov: {
|
|
489
|
+
label: "fov",
|
|
490
|
+
step: .1,
|
|
491
|
+
perPixel: .2,
|
|
492
|
+
min: 1
|
|
493
|
+
},
|
|
494
|
+
near: {
|
|
495
|
+
label: "near",
|
|
496
|
+
step: .01,
|
|
497
|
+
perPixel: .005,
|
|
498
|
+
min: 1e-4
|
|
499
|
+
},
|
|
500
|
+
far: {
|
|
501
|
+
label: "far",
|
|
502
|
+
step: 1,
|
|
503
|
+
perPixel: .5,
|
|
504
|
+
min: .001
|
|
505
|
+
},
|
|
506
|
+
zoom: {
|
|
507
|
+
label: "zoom",
|
|
508
|
+
step: .01,
|
|
509
|
+
perPixel: .005,
|
|
510
|
+
min: .01
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
/** Line icons on a 24 grid, stroked in the text colour (styles.ts). */
|
|
514
|
+
const KIND_ICONS = {
|
|
515
|
+
perspective: "<rect x=\"2\" y=\"7\" width=\"12\" height=\"10\" rx=\"2\"/><path d=\"m14 10.5 7-4v11l-7-4\"/>",
|
|
516
|
+
orthographic: "<rect x=\"3\" y=\"5\" width=\"18\" height=\"14\" rx=\"2\"/><path d=\"M3 12h18\"/>",
|
|
517
|
+
array: "<rect x=\"3\" y=\"3\" width=\"8\" height=\"8\" rx=\"1.5\"/><rect x=\"13\" y=\"3\" width=\"8\" height=\"8\" rx=\"1.5\"/><rect x=\"3\" y=\"13\" width=\"8\" height=\"8\" rx=\"1.5\"/><rect x=\"13\" y=\"13\" width=\"8\" height=\"8\" rx=\"1.5\"/>",
|
|
518
|
+
cube: "<path d=\"M12 3 20 7.5v9L12 21l-8-4.5v-9Z\"/><path d=\"m4 7.5 8 4.5 8-4.5M12 12v9\"/>",
|
|
519
|
+
other: "<circle cx=\"12\" cy=\"12\" r=\"8\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>"
|
|
520
|
+
};
|
|
521
|
+
const ICONS$1 = {
|
|
522
|
+
look: "<path d=\"M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7Z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>",
|
|
523
|
+
frustum: "<path d=\"M3 12 20 5v14Z\"/><path d=\"M20 5v14\"/><circle cx=\"3\" cy=\"12\" r=\"1\"/>",
|
|
524
|
+
chevron: "<path d=\"m6 9 6 6 6-6\"/>",
|
|
525
|
+
refresh: "<path d=\"M21 12a9 9 0 1 1-3-6.7L21 8\"/><path d=\"M21 3v5h-5\"/>",
|
|
526
|
+
go: "<path d=\"M5 12h14\"/><path d=\"m13 6 6 6-6 6\"/>",
|
|
527
|
+
path: "<path d=\"M3 18c3-8 7-1 10-7s5-5 8-5\"/><circle cx=\"3\" cy=\"18\" r=\"1.5\"/><circle cx=\"21\" cy=\"6\" r=\"1.5\"/>",
|
|
528
|
+
remove: "<path d=\"M6 6l12 12M18 6 6 18\"/>"
|
|
529
|
+
};
|
|
530
|
+
const svg$1 = (paths) => `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`;
|
|
531
|
+
const icon$1 = (name, label, extra = "") => `<button type="button" class="tcm-icon ${extra}" data-action="${name}" title="${label}" aria-label="${label}">${svg$1(ICONS$1[name])}</button>`;
|
|
532
|
+
const trim$1 = (value, digits = 2) => {
|
|
533
|
+
const fixed = value.toFixed(digits);
|
|
534
|
+
return fixed.includes(".") ? fixed.replace(/\.?0+$/, "") : fixed;
|
|
535
|
+
};
|
|
536
|
+
const range = (near, far) => `${trim$1(near)}–${trim$1(far)}`;
|
|
537
|
+
const triple = (values, digits = 2) => values.map((value) => trim$1(value, digits)).join(" ");
|
|
538
|
+
const escape$1 = (text) => text.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
539
|
+
/** A camera's name as a JS identifier, for copied code: `orbit view` → `orbitView`. */
|
|
540
|
+
const identifier = (name) => {
|
|
541
|
+
const joined = name.split(/[^A-Za-z0-9]+/).filter(Boolean).map((word, index) => index === 0 ? word.toLowerCase() : word[0].toUpperCase() + word.slice(1)).join("");
|
|
542
|
+
return /^[A-Za-z_]/.test(joined) ? joined : `camera${joined}`;
|
|
543
|
+
};
|
|
544
|
+
/** The row's second line: how it projects, at a glance. */
|
|
545
|
+
const describe = (entry, details) => {
|
|
546
|
+
const projection = details?.projection;
|
|
547
|
+
const parts = [];
|
|
548
|
+
if (entry.kind === "cube") parts.push("cube · 6 faces");
|
|
549
|
+
else if (projection?.fov !== void 0) {
|
|
550
|
+
parts.push(`fov ${trim$1(projection.fov, 1)}°`, range(projection.near, projection.far));
|
|
551
|
+
if (projection.aspect !== void 0) parts.push(trim$1(projection.aspect));
|
|
552
|
+
} else if (projection) parts.push("ortho", `zoom ${trim$1(projection.zoom)}`, range(projection.near, projection.far));
|
|
553
|
+
else parts.push(entry.kind);
|
|
554
|
+
if (entry.keys > 0) parts.push(`${entry.keys} key${entry.keys === 1 ? "" : "s"}`);
|
|
555
|
+
else if (entry.moving && entry.inScene) parts.push("moving");
|
|
556
|
+
if (!entry.inScene) parts.push("not in scene");
|
|
557
|
+
return parts.join(" · ");
|
|
558
|
+
};
|
|
559
|
+
/** Facts that aren't edited here, under the fields. */
|
|
560
|
+
const readoutOf = (entry, details) => {
|
|
561
|
+
const { projection } = details;
|
|
562
|
+
const rows = [["world", triple(details.position)]];
|
|
563
|
+
if (projection?.aspect !== void 0) rows.push(["aspect", trim$1(projection.aspect, 3)]);
|
|
564
|
+
if (projection?.left !== void 0) rows.push(["frustum", `l ${trim$1(projection.left)} r ${trim$1(projection.right)} t ${trim$1(projection.top)} b ${trim$1(projection.bottom)}`]);
|
|
565
|
+
rows.push(["drawing", entry.live ? `${entry.fps} fps` : "no"]);
|
|
566
|
+
rows.push(["in", entry.inScene ? entry.path.join(" › ") || "scene" : "the renderer only (not in the scene)"]);
|
|
567
|
+
return rows.map(([term, value]) => `<dt>${term}</dt><dd>${escape$1(value)}</dd>`).join("");
|
|
568
|
+
};
|
|
569
|
+
const numberField = (key) => {
|
|
570
|
+
const { label, step, min } = FIELDS[key];
|
|
571
|
+
return `<label class="tcm-num"><span class="tcm-scrub" data-scrub="${key}" title="Drag to change: Shift for fine, Alt for coarse">${label}</span><input type="number" data-field="${key}" step="${step}"${min === void 0 ? "" : ` min="${min}"`} inputmode="decimal" /></label>`;
|
|
572
|
+
};
|
|
573
|
+
/** The values a field shows, from the camera now. */
|
|
574
|
+
const valuesOf = (details) => {
|
|
575
|
+
const [px, py, pz] = details.local.position;
|
|
576
|
+
const [rx, ry, rz] = details.local.rotation;
|
|
577
|
+
const projection = details.projection;
|
|
578
|
+
return {
|
|
579
|
+
px,
|
|
580
|
+
py,
|
|
581
|
+
pz,
|
|
582
|
+
rx,
|
|
583
|
+
ry,
|
|
584
|
+
rz,
|
|
585
|
+
...projection ? {
|
|
586
|
+
near: projection.near,
|
|
587
|
+
far: projection.far,
|
|
588
|
+
zoom: projection.zoom,
|
|
589
|
+
fov: projection.fov
|
|
590
|
+
} : {}
|
|
591
|
+
};
|
|
592
|
+
};
|
|
593
|
+
const formatField = (key, value) => trim$1(value, key.startsWith("r") || key === "fov" ? 2 : 4);
|
|
594
|
+
const createCameraPanel = (lab, { paths = false } = {}) => {
|
|
595
|
+
injectStyles();
|
|
596
|
+
const element = document.createElement("div");
|
|
597
|
+
element.className = "tcm tcm-panel";
|
|
598
|
+
element.innerHTML = `
|
|
599
|
+
<div class="tcm-toolbar">
|
|
600
|
+
<input type="search" class="tcm-search" placeholder="Filter by name…" autocomplete="off" spellcheck="false" aria-label="Filter cameras" />
|
|
601
|
+
${icon$1("refresh", "Look for new cameras")}
|
|
602
|
+
</div>
|
|
603
|
+
<div class="tcm-banner" hidden><span></span><button type="button" class="tcm-text-button" data-action="look-back">Back</button></div>
|
|
604
|
+
<ul class="tcm-list"></ul>
|
|
605
|
+
<div class="tcm-empty" hidden>No cameras yet. The one the renderer draws with shows up on its first frame.</div>
|
|
606
|
+
<div class="tcm-status" role="status"></div>
|
|
607
|
+
`;
|
|
608
|
+
const search = element.querySelector(".tcm-search");
|
|
609
|
+
const list = element.querySelector(".tcm-list");
|
|
610
|
+
const empty = element.querySelector(".tcm-empty");
|
|
611
|
+
const status = element.querySelector(".tcm-status");
|
|
612
|
+
const banner = element.querySelector(".tcm-banner");
|
|
613
|
+
const bannerText = banner.querySelector("span");
|
|
614
|
+
const rows = /* @__PURE__ */ new Map();
|
|
615
|
+
const renderListeners = /* @__PURE__ */ new Set();
|
|
616
|
+
let timer = null;
|
|
617
|
+
/** The field being scrubbed: the tick leaves it alone. */
|
|
618
|
+
let scrubbing = null;
|
|
619
|
+
const say = (message) => {
|
|
620
|
+
status.textContent = message;
|
|
621
|
+
};
|
|
622
|
+
const attempt = (work) => {
|
|
623
|
+
try {
|
|
624
|
+
say("");
|
|
625
|
+
work();
|
|
626
|
+
} catch (error) {
|
|
627
|
+
say(error.message.replace(/^three-cameras: /, ""));
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
/** Read one field (or its x/y/z group) into a patch and set it. */
|
|
631
|
+
const apply = (id, controls, key) => {
|
|
632
|
+
const read = (field) => Number(controls.fields.get(field)?.value);
|
|
633
|
+
const patch = {};
|
|
634
|
+
if (key.startsWith("p")) {
|
|
635
|
+
const values = [
|
|
636
|
+
read("px"),
|
|
637
|
+
read("py"),
|
|
638
|
+
read("pz")
|
|
639
|
+
];
|
|
640
|
+
if (values.every(Number.isFinite)) patch.position = values;
|
|
641
|
+
} else if (key.startsWith("r")) {
|
|
642
|
+
const values = [
|
|
643
|
+
read("rx"),
|
|
644
|
+
read("ry"),
|
|
645
|
+
read("rz")
|
|
646
|
+
];
|
|
647
|
+
if (values.every(Number.isFinite)) patch.rotation = values;
|
|
648
|
+
} else {
|
|
649
|
+
const value = read(key);
|
|
650
|
+
const min = FIELDS[key].min;
|
|
651
|
+
if (Number.isFinite(value) && (min === void 0 || value >= min)) patch[key] = value;
|
|
652
|
+
}
|
|
653
|
+
attempt(() => lab.set(id, patch));
|
|
654
|
+
};
|
|
655
|
+
const buildControls = (row, entry) => {
|
|
656
|
+
const lens = entry.kind === "perspective" ? [
|
|
657
|
+
"fov",
|
|
658
|
+
"near",
|
|
659
|
+
"far",
|
|
660
|
+
"zoom"
|
|
661
|
+
] : entry.kind === "orthographic" ? [
|
|
662
|
+
"zoom",
|
|
663
|
+
"near",
|
|
664
|
+
"far"
|
|
665
|
+
] : [];
|
|
666
|
+
row.details.innerHTML = `
|
|
667
|
+
<div class="tcm-group"><span class="tcm-term">position</span><div class="tcm-fields">${numberField("px")}${numberField("py")}${numberField("pz")}</div></div>
|
|
668
|
+
<div class="tcm-group"><span class="tcm-term">rotation °</span><div class="tcm-fields">${numberField("rx")}${numberField("ry")}${numberField("rz")}</div></div>
|
|
669
|
+
${lens.length > 0 ? `<div class="tcm-group"><span class="tcm-term">lens</span><div class="tcm-fields tcm-fields--lens">${lens.map(numberField).join("")}</div></div>` : ""}
|
|
670
|
+
<dl class="tcm-readout"></dl>
|
|
671
|
+
${entry.projects ? `<div class="tcm-buttons">
|
|
672
|
+
<button type="button" class="tcm-text-button" data-action="copy" title="Copy three.js that sets this camera up as it is now">Copy as code</button>
|
|
673
|
+
<button type="button" class="tcm-text-button" data-action="save" title="Save where it is and how it projects, to fly back to">Save view</button>
|
|
674
|
+
<button type="button" class="tcm-text-button" data-action="bake" title="Turn where it's moved lately into keys on its track, to edit the path" hidden>Bake to keys</button>
|
|
675
|
+
</div>
|
|
676
|
+
<ul class="tcm-views" aria-label="Saved views"></ul>` : ""}
|
|
677
|
+
`;
|
|
678
|
+
const fields = /* @__PURE__ */ new Map();
|
|
679
|
+
for (const input of row.details.querySelectorAll("input[data-field]")) fields.set(input.dataset.field, input);
|
|
680
|
+
const controls = {
|
|
681
|
+
fields,
|
|
682
|
+
readout: row.details.querySelector(".tcm-readout"),
|
|
683
|
+
views: row.details.querySelector(".tcm-views") ?? document.createElement("ul"),
|
|
684
|
+
copy: row.details.querySelector("[data-action=\"copy\"]") ?? document.createElement("button"),
|
|
685
|
+
viewsDrawn: ""
|
|
686
|
+
};
|
|
687
|
+
const id = entry.id;
|
|
688
|
+
for (const [key, input] of fields) {
|
|
689
|
+
input.addEventListener("input", () => apply(id, controls, key));
|
|
690
|
+
input.addEventListener("blur", () => {
|
|
691
|
+
const details = lab.details(id);
|
|
692
|
+
const value = details ? valuesOf(details)[key] : void 0;
|
|
693
|
+
if (value !== void 0) input.value = formatField(key, value);
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
for (const handle of row.details.querySelectorAll("[data-scrub]")) {
|
|
697
|
+
const key = handle.dataset.scrub;
|
|
698
|
+
const input = fields.get(key);
|
|
699
|
+
handle.addEventListener("pointerdown", (event) => {
|
|
700
|
+
event.preventDefault();
|
|
701
|
+
handle.setPointerCapture(event.pointerId);
|
|
702
|
+
scrubbing = input;
|
|
703
|
+
const startX = event.clientX;
|
|
704
|
+
const startValue = Number(input.value) || 0;
|
|
705
|
+
const { perPixel, min } = FIELDS[key];
|
|
706
|
+
const onMove = (move) => {
|
|
707
|
+
const scale = move.shiftKey ? .1 : move.altKey ? 10 : 1;
|
|
708
|
+
let value = startValue + (move.clientX - startX) * perPixel * scale;
|
|
709
|
+
if (min !== void 0) value = Math.max(min, value);
|
|
710
|
+
input.value = formatField(key, value);
|
|
711
|
+
apply(id, controls, key);
|
|
712
|
+
};
|
|
713
|
+
const onUp = () => {
|
|
714
|
+
scrubbing = null;
|
|
715
|
+
handle.removeEventListener("pointermove", onMove);
|
|
716
|
+
handle.removeEventListener("pointerup", onUp);
|
|
717
|
+
handle.removeEventListener("pointercancel", onUp);
|
|
718
|
+
};
|
|
719
|
+
handle.addEventListener("pointermove", onMove);
|
|
720
|
+
handle.addEventListener("pointerup", onUp);
|
|
721
|
+
handle.addEventListener("pointercancel", onUp);
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
return controls;
|
|
725
|
+
};
|
|
726
|
+
const drawViews = (row, entry) => {
|
|
727
|
+
const controls = row.controls;
|
|
728
|
+
const views = lab.views(entry.id);
|
|
729
|
+
const drawn = views.map((view) => `${view.id}:${view.name}`).join("|");
|
|
730
|
+
if (drawn === controls.viewsDrawn) return;
|
|
731
|
+
controls.viewsDrawn = drawn;
|
|
732
|
+
controls.views.innerHTML = views.map((view) => `<li class="tcm-view" data-view="${escape$1(view.id)}">
|
|
733
|
+
<button type="button" class="tcm-view-go" data-action="go" title="Fly back to ${escape$1(view.name)}">${svg$1(ICONS$1.go)}<span>${escape$1(view.name)}</span></button>
|
|
734
|
+
${icon$1("remove", `Forget ${escape$1(view.name)}`)}
|
|
735
|
+
</li>`).join("");
|
|
736
|
+
};
|
|
737
|
+
const makeRow = (entry) => {
|
|
738
|
+
const li = document.createElement("li");
|
|
739
|
+
const id = entry.id;
|
|
740
|
+
li.className = "tcm-row";
|
|
741
|
+
li.innerHTML = `
|
|
742
|
+
<div class="tcm-head">
|
|
743
|
+
<span class="tcm-kind" title="${entry.kind} camera">${svg$1(KIND_ICONS[entry.kind])}</span>
|
|
744
|
+
<button type="button" class="tcm-text" data-action="open" aria-expanded="false" title="Pick to edit">
|
|
745
|
+
<span class="tcm-name-line"><span class="tcm-name"></span><span class="tcm-badge"></span></span>
|
|
746
|
+
<span class="tcm-meta"></span>
|
|
747
|
+
</button>
|
|
748
|
+
<div class="tcm-actions">
|
|
749
|
+
${paths ? icon$1("path", "Show its motion path in the scene") : ""}
|
|
750
|
+
${icon$1("look", "Look through it")}
|
|
751
|
+
${icon$1("frustum", "Show its frustum in the scene")}
|
|
752
|
+
${icon$1("chevron", "Edit", "tcm-chevron")}
|
|
753
|
+
</div>
|
|
754
|
+
</div>
|
|
755
|
+
<div class="tcm-details"></div>
|
|
756
|
+
`;
|
|
757
|
+
const row = {
|
|
758
|
+
li,
|
|
759
|
+
name: li.querySelector(".tcm-name"),
|
|
760
|
+
badge: li.querySelector(".tcm-badge"),
|
|
761
|
+
meta: li.querySelector(".tcm-meta"),
|
|
762
|
+
look: li.querySelector("[data-action=\"look\"]"),
|
|
763
|
+
helper: li.querySelector("[data-action=\"frustum\"]"),
|
|
764
|
+
path: li.querySelector("[data-action=\"path\"]"),
|
|
765
|
+
details: li.querySelector(".tcm-details"),
|
|
766
|
+
controls: null,
|
|
767
|
+
open: false
|
|
768
|
+
};
|
|
769
|
+
row.name.textContent = id;
|
|
770
|
+
row.name.title = id;
|
|
771
|
+
li.addEventListener("click", (event) => {
|
|
772
|
+
const target = event.target.closest("[data-action]");
|
|
773
|
+
const action = target?.dataset.action;
|
|
774
|
+
const current = lab.entry(id);
|
|
775
|
+
if (!current || !action) return;
|
|
776
|
+
if (action === "open" || action === "chevron") {
|
|
777
|
+
row.open = !row.open;
|
|
778
|
+
if (row.open) lab.select(id);
|
|
779
|
+
if (row.open && !row.controls) row.controls = buildControls(row, current);
|
|
780
|
+
update(row, current);
|
|
781
|
+
} else if (action === "look") attempt(() => lab.lookThrough(current.viewing ? null : id));
|
|
782
|
+
else if (action === "path") attempt(() => lab.setTrail(id, !current.trail));
|
|
783
|
+
else if (action === "frustum") attempt(() => lab.setHelper(id, !current.helper));
|
|
784
|
+
else if (action === "copy") copyCode(row, current);
|
|
785
|
+
else if (action === "bake") {
|
|
786
|
+
if (current.keys === 0 || window.confirm(`Replace ${id}'s ${current.keys} keys with its recorded move?`)) attempt(() => {
|
|
787
|
+
const keys = lab.bakeMotion(id);
|
|
788
|
+
say(`Baked ${keys.length} keys from ${id}'s last few seconds: drag their dots in the scene.`);
|
|
789
|
+
});
|
|
790
|
+
} else if (action === "save") attempt(() => lab.saveView(id));
|
|
791
|
+
else if (action === "go" || action === "remove") {
|
|
792
|
+
const viewId = target.closest("[data-view]")?.dataset.view;
|
|
793
|
+
if (viewId) attempt(() => action === "go" ? lab.goToView(id, viewId) : lab.deleteView(id, viewId));
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
return row;
|
|
797
|
+
};
|
|
798
|
+
const copyCode = async (row, entry) => {
|
|
799
|
+
const code = poseToCode(capturePose(entry.camera), identifier(entry.id));
|
|
800
|
+
const button = row.controls.copy;
|
|
801
|
+
try {
|
|
802
|
+
await navigator.clipboard.writeText(code);
|
|
803
|
+
button.textContent = "Copied";
|
|
804
|
+
} catch {
|
|
805
|
+
say("The clipboard is blocked here. The code is logged to the console instead.");
|
|
806
|
+
console.log(code);
|
|
807
|
+
}
|
|
808
|
+
setTimeout(() => {
|
|
809
|
+
button.textContent = "Copy as code";
|
|
810
|
+
}, 1200);
|
|
811
|
+
};
|
|
812
|
+
const update = (row, entry) => {
|
|
813
|
+
const open = row.open;
|
|
814
|
+
const details = lab.details(entry.id);
|
|
815
|
+
row.li.classList.toggle("is-live", entry.live);
|
|
816
|
+
row.li.classList.toggle("is-open", open);
|
|
817
|
+
row.li.classList.toggle("is-viewing", entry.viewing);
|
|
818
|
+
row.li.classList.toggle("is-selected", entry.selected);
|
|
819
|
+
row.badge.className = `tcm-badge${entry.viewing ? " is-viewing" : entry.live ? " is-live" : ""}`;
|
|
820
|
+
row.badge.textContent = entry.viewing ? "viewing" : entry.standingIn ? "replaced" : entry.live ? `live · ${entry.fps} fps` : "";
|
|
821
|
+
row.meta.textContent = describe(entry, details);
|
|
822
|
+
row.meta.title = row.meta.textContent;
|
|
823
|
+
row.look.disabled = !entry.projects || !lab.watching;
|
|
824
|
+
row.look.setAttribute("aria-pressed", String(entry.viewing));
|
|
825
|
+
row.look.title = entry.viewing ? "Stop looking through it" : "Look through it";
|
|
826
|
+
row.look.setAttribute("aria-label", row.look.title);
|
|
827
|
+
row.helper.disabled = !entry.canHelp;
|
|
828
|
+
row.helper.setAttribute("aria-pressed", String(entry.helper));
|
|
829
|
+
row.helper.title = entry.helper ? "Hide its frustum" : "Show its frustum in the scene";
|
|
830
|
+
row.helper.setAttribute("aria-label", row.helper.title);
|
|
831
|
+
if (row.path) {
|
|
832
|
+
row.path.disabled = entry.keys === 0;
|
|
833
|
+
row.path.setAttribute("aria-pressed", String(entry.trail));
|
|
834
|
+
row.path.title = entry.trail ? "Hide its motion path" : "Show its motion path in the scene";
|
|
835
|
+
row.path.setAttribute("aria-label", row.path.title);
|
|
836
|
+
}
|
|
837
|
+
for (const toggle of row.li.querySelectorAll("[data-action=\"open\"], [data-action=\"chevron\"]")) toggle.setAttribute("aria-expanded", String(open));
|
|
838
|
+
if (!open || !details || !row.controls) return;
|
|
839
|
+
const values = valuesOf(details);
|
|
840
|
+
for (const [key, input] of row.controls.fields) {
|
|
841
|
+
const value = values[key];
|
|
842
|
+
if (value !== void 0 && input !== document.activeElement && input !== scrubbing) input.value = formatField(key, value);
|
|
843
|
+
}
|
|
844
|
+
row.controls.readout.innerHTML = readoutOf(entry, details);
|
|
845
|
+
const bake = row.details.querySelector("[data-action=\"bake\"]");
|
|
846
|
+
if (bake) bake.hidden = !entry.recorded;
|
|
847
|
+
drawViews(row, entry);
|
|
848
|
+
};
|
|
849
|
+
const filter = () => {
|
|
850
|
+
const query = search.value.trim().toLowerCase();
|
|
851
|
+
for (const [id, row] of rows) row.li.classList.toggle("is-hidden", query.length > 0 && !id.toLowerCase().includes(query));
|
|
852
|
+
};
|
|
853
|
+
/** Rows in, rows out, then every row's live state. */
|
|
854
|
+
const render = () => {
|
|
855
|
+
const entries = lab.entries();
|
|
856
|
+
const ids = new Set(entries.map((entry) => entry.id));
|
|
857
|
+
for (const [id, row] of rows) if (!ids.has(id)) {
|
|
858
|
+
row.li.remove();
|
|
859
|
+
rows.delete(id);
|
|
860
|
+
}
|
|
861
|
+
for (const entry of entries) {
|
|
862
|
+
let row = rows.get(entry.id);
|
|
863
|
+
if (!row) {
|
|
864
|
+
row = makeRow(entry);
|
|
865
|
+
rows.set(entry.id, row);
|
|
866
|
+
list.append(row.li);
|
|
867
|
+
}
|
|
868
|
+
update(row, entry);
|
|
869
|
+
}
|
|
870
|
+
const viewing = entries.find((entry) => entry.viewing);
|
|
871
|
+
const standIn = entries.find((entry) => entry.standingIn);
|
|
872
|
+
banner.hidden = !viewing;
|
|
873
|
+
bannerText.textContent = viewing ? `Looking through ${viewing.id}${standIn ? ` in place of ${standIn.id}` : ""}` : "";
|
|
874
|
+
empty.hidden = entries.length > 0;
|
|
875
|
+
filter();
|
|
876
|
+
element.dataset.count = String(entries.length);
|
|
877
|
+
element.dispatchEvent(new CustomEvent("tcm-count", { detail: entries.length }));
|
|
878
|
+
for (const listener of renderListeners) listener();
|
|
879
|
+
};
|
|
880
|
+
element.querySelector("[data-action=\"refresh\"]").addEventListener("click", () => lab.refresh());
|
|
881
|
+
banner.querySelector("button").addEventListener("click", () => attempt(() => lab.lookThrough(null)));
|
|
882
|
+
search.addEventListener("input", filter);
|
|
883
|
+
const unsubscribe = lab.subscribe(render);
|
|
884
|
+
const setActive = (active) => {
|
|
885
|
+
if (active && !timer) {
|
|
886
|
+
lab.refresh();
|
|
887
|
+
render();
|
|
888
|
+
timer = setInterval(() => {
|
|
889
|
+
lab.refresh();
|
|
890
|
+
render();
|
|
891
|
+
}, TICK_MS);
|
|
892
|
+
} else if (!active && timer) {
|
|
893
|
+
clearInterval(timer);
|
|
894
|
+
timer = null;
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
render();
|
|
898
|
+
setActive(true);
|
|
899
|
+
return {
|
|
900
|
+
element,
|
|
901
|
+
setTheme: (theme) => {
|
|
902
|
+
if (element.closest(".perf-hud__card")) delete element.dataset.theme;
|
|
903
|
+
else element.dataset.theme = theme;
|
|
904
|
+
},
|
|
905
|
+
refresh: () => lab.refresh(),
|
|
906
|
+
setActive,
|
|
907
|
+
row: (id) => rows.get(id)?.li ?? null,
|
|
908
|
+
onRender: (listener) => {
|
|
909
|
+
renderListeners.add(listener);
|
|
910
|
+
return () => {
|
|
911
|
+
renderListeners.delete(listener);
|
|
912
|
+
};
|
|
913
|
+
},
|
|
914
|
+
dispose: () => {
|
|
915
|
+
setActive(false);
|
|
916
|
+
unsubscribe();
|
|
917
|
+
renderListeners.clear();
|
|
918
|
+
element.remove();
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
};
|
|
922
|
+
//#endregion
|
|
923
|
+
//#region src/ui/path-editor.ts
|
|
924
|
+
/**
|
|
925
|
+
* Editing motion paths in the scene: the key dots on a camera's path are handles. Hover one and
|
|
926
|
+
* the cursor becomes a hand; drag it and the key moves, on a plane facing the view through where it
|
|
927
|
+
* was (Shift: straight up or down), so the path and the camera's move reshape as you go.
|
|
928
|
+
*
|
|
929
|
+
* It listens on the renderer's canvas in the capture phase and takes only presses that land on a
|
|
930
|
+
* dot, so orbit controls and the app's own handlers get every other press.
|
|
931
|
+
*/
|
|
932
|
+
/** A dot within this many CSS pixels of the pointer is under it. */
|
|
933
|
+
const HIT_PX = 10;
|
|
934
|
+
const createPathEditor = (lab, canvas, { onPick } = {}) => {
|
|
935
|
+
const inverseProjection = new Matrix4();
|
|
936
|
+
const point = new Vector3();
|
|
937
|
+
let restoreCursor = null;
|
|
938
|
+
let dragging = false;
|
|
939
|
+
/** The main view's rectangle on the canvas, in CSS pixels from its top left. */
|
|
940
|
+
const viewRect = (view) => {
|
|
941
|
+
const box = canvas.getBoundingClientRect();
|
|
942
|
+
const scale = canvas.width > 0 ? box.width / canvas.width : 1;
|
|
943
|
+
const { x, y, z: width, w: height } = view.viewport;
|
|
944
|
+
return {
|
|
945
|
+
left: box.left + x * scale,
|
|
946
|
+
top: box.top + box.height - (y + height) * scale,
|
|
947
|
+
width: width * scale,
|
|
948
|
+
height: height * scale
|
|
949
|
+
};
|
|
950
|
+
};
|
|
951
|
+
/** The dot under the pointer, nearest first. */
|
|
952
|
+
const hitAt = (clientX, clientY) => {
|
|
953
|
+
const view = lab.viewTransform();
|
|
954
|
+
if (!view) return null;
|
|
955
|
+
const rect = viewRect(view);
|
|
956
|
+
const toView = view.world.clone().invert();
|
|
957
|
+
let best = null;
|
|
958
|
+
let bestDistance = HIT_PX;
|
|
959
|
+
for (const handle of lab.keyHandles()) {
|
|
960
|
+
point.copy(handle.position).applyMatrix4(toView).applyMatrix4(view.projection);
|
|
961
|
+
if (point.z < -1 || point.z > 1) continue;
|
|
962
|
+
const x = rect.left + (point.x + 1) / 2 * rect.width;
|
|
963
|
+
const y = rect.top + (1 - point.y) / 2 * rect.height;
|
|
964
|
+
const distance = Math.hypot(x - clientX, y - clientY);
|
|
965
|
+
if (distance < bestDistance) {
|
|
966
|
+
bestDistance = distance;
|
|
967
|
+
best = handle;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return best;
|
|
971
|
+
};
|
|
972
|
+
/** The ray from the view through a screen point. */
|
|
973
|
+
const rayAt = (clientX, clientY) => {
|
|
974
|
+
const view = lab.viewTransform();
|
|
975
|
+
if (!view) return null;
|
|
976
|
+
const rect = viewRect(view);
|
|
977
|
+
const ndcX = (clientX - rect.left) / rect.width * 2 - 1;
|
|
978
|
+
const ndcY = -((clientY - rect.top) / rect.height * 2 - 1);
|
|
979
|
+
inverseProjection.copy(view.projection).invert();
|
|
980
|
+
const near = new Vector3(ndcX, ndcY, -1).applyMatrix4(inverseProjection).applyMatrix4(view.world);
|
|
981
|
+
const far = new Vector3(ndcX, ndcY, 1).applyMatrix4(inverseProjection).applyMatrix4(view.world);
|
|
982
|
+
return {
|
|
983
|
+
ray: new Ray(near, far.sub(near).normalize()),
|
|
984
|
+
view
|
|
985
|
+
};
|
|
986
|
+
};
|
|
987
|
+
const setCursor = (cursor) => {
|
|
988
|
+
if (cursor) {
|
|
989
|
+
restoreCursor ??= canvas.style.cursor;
|
|
990
|
+
canvas.style.cursor = cursor;
|
|
991
|
+
} else if (restoreCursor !== null) {
|
|
992
|
+
canvas.style.cursor = restoreCursor;
|
|
993
|
+
restoreCursor = null;
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
const onHover = (event) => {
|
|
997
|
+
if (!dragging) setCursor(hitAt(event.clientX, event.clientY) ? "grab" : null);
|
|
998
|
+
};
|
|
999
|
+
const onPointerDown = (event) => {
|
|
1000
|
+
if (event.button !== 0) return;
|
|
1001
|
+
const hit = hitAt(event.clientX, event.clientY);
|
|
1002
|
+
const start = hit ? rayAt(event.clientX, event.clientY) : null;
|
|
1003
|
+
if (!hit || !start) return;
|
|
1004
|
+
event.stopImmediatePropagation();
|
|
1005
|
+
event.preventDefault();
|
|
1006
|
+
canvas.setPointerCapture(event.pointerId);
|
|
1007
|
+
dragging = true;
|
|
1008
|
+
setCursor("grabbing");
|
|
1009
|
+
onPick?.(hit.camera, hit.key);
|
|
1010
|
+
lab.select(hit.camera);
|
|
1011
|
+
const key = lab.keys(hit.camera).find((other) => other.id === hit.key);
|
|
1012
|
+
if (key) lab.seek(key.time);
|
|
1013
|
+
const origin = hit.position.clone();
|
|
1014
|
+
const facing = new Vector3(0, 0, -1).transformDirection(start.view.world);
|
|
1015
|
+
const plane = new Plane().setFromNormalAndCoplanarPoint(facing, origin);
|
|
1016
|
+
const level = facing.clone().setY(0);
|
|
1017
|
+
const upright = new Plane().setFromNormalAndCoplanarPoint(level.lengthSq() > 1e-6 ? level.normalize() : new Vector3(0, 0, 1), origin);
|
|
1018
|
+
const grabbed = start.ray.intersectPlane(plane, new Vector3()) ?? origin.clone();
|
|
1019
|
+
const grabbedUpright = start.ray.intersectPlane(upright, new Vector3()) ?? origin.clone();
|
|
1020
|
+
const onMove = (move) => {
|
|
1021
|
+
const next = rayAt(move.clientX, move.clientY);
|
|
1022
|
+
if (!next) return;
|
|
1023
|
+
let target;
|
|
1024
|
+
if (move.shiftKey) {
|
|
1025
|
+
const hitUpright = next.ray.intersectPlane(upright, new Vector3());
|
|
1026
|
+
target = hitUpright ? origin.clone().setY(origin.y + hitUpright.y - grabbedUpright.y) : null;
|
|
1027
|
+
} else {
|
|
1028
|
+
const hitPlane = next.ray.intersectPlane(plane, new Vector3());
|
|
1029
|
+
target = hitPlane ? origin.clone().add(hitPlane.sub(grabbed)) : null;
|
|
1030
|
+
}
|
|
1031
|
+
if (target) try {
|
|
1032
|
+
lab.moveKeyTo(hit.camera, hit.key, target);
|
|
1033
|
+
} catch {}
|
|
1034
|
+
};
|
|
1035
|
+
const onUp = () => {
|
|
1036
|
+
canvas.removeEventListener("pointermove", onMove);
|
|
1037
|
+
canvas.removeEventListener("pointerup", onUp);
|
|
1038
|
+
canvas.removeEventListener("pointercancel", onUp);
|
|
1039
|
+
dragging = false;
|
|
1040
|
+
setCursor(null);
|
|
1041
|
+
};
|
|
1042
|
+
canvas.addEventListener("pointermove", onMove);
|
|
1043
|
+
canvas.addEventListener("pointerup", onUp);
|
|
1044
|
+
canvas.addEventListener("pointercancel", onUp);
|
|
1045
|
+
};
|
|
1046
|
+
canvas.addEventListener("pointermove", onHover);
|
|
1047
|
+
canvas.addEventListener("pointerdown", onPointerDown, { capture: true });
|
|
1048
|
+
return {
|
|
1049
|
+
/** For tests and hosts: the dot under a screen point, if any. */
|
|
1050
|
+
hitAt,
|
|
1051
|
+
dispose: () => {
|
|
1052
|
+
canvas.removeEventListener("pointermove", onHover);
|
|
1053
|
+
canvas.removeEventListener("pointerdown", onPointerDown, { capture: true });
|
|
1054
|
+
setCursor(null);
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
};
|
|
1058
|
+
//#endregion
|
|
1059
|
+
//#region src/ui/ease-editor.ts
|
|
1060
|
+
/**
|
|
1061
|
+
* The ease curve editor: the move from the picked key to the next as a curve of progress over
|
|
1062
|
+
* time, CSS `cubic-bezier` style, with its two handles to drag. Reshaping a preset turns it into
|
|
1063
|
+
* a custom curve starting from the preset's shape. Handles go outside 0–1 upward and downward for
|
|
1064
|
+
* overshoot and anticipation; time stays in order (0–1).
|
|
1065
|
+
*/
|
|
1066
|
+
const W = 132;
|
|
1067
|
+
const H = 104;
|
|
1068
|
+
/** The unit square's corners in the editor. */
|
|
1069
|
+
const LEFT = 22;
|
|
1070
|
+
const TOP = 22;
|
|
1071
|
+
const BOTTOM = 82;
|
|
1072
|
+
const px = (x) => LEFT + x * 98;
|
|
1073
|
+
const py = (y) => BOTTOM - y * 60;
|
|
1074
|
+
const unx = (value) => (value - LEFT) / 98;
|
|
1075
|
+
const uny = (value) => (BOTTOM - value) / 60;
|
|
1076
|
+
const handlesOf = (key) => {
|
|
1077
|
+
if (key.ease === "hold") return null;
|
|
1078
|
+
return key.ease === "custom" ? key.bezier ?? PRESET_BEZIERS.linear : PRESET_BEZIERS[key.ease];
|
|
1079
|
+
};
|
|
1080
|
+
const createEaseEditor = ({ onCommit }) => {
|
|
1081
|
+
const element = document.createElement("div");
|
|
1082
|
+
element.className = "tcm-ease";
|
|
1083
|
+
element.innerHTML = `<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="Ease into the next key"></svg>`;
|
|
1084
|
+
const svg = element.querySelector("svg");
|
|
1085
|
+
let key = null;
|
|
1086
|
+
let hasNext = false;
|
|
1087
|
+
/** Handles being dragged, before they're committed. */
|
|
1088
|
+
let draft = null;
|
|
1089
|
+
const paint = () => {
|
|
1090
|
+
if (!key) {
|
|
1091
|
+
svg.innerHTML = "";
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
if (!hasNext) {
|
|
1095
|
+
svg.innerHTML = `<text x="${W / 2}" y="${H / 2}" text-anchor="middle" class="tcm-graph-empty">last key: no move after it</text>`;
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
const handles = draft ?? handlesOf(key);
|
|
1099
|
+
const ease = draft ? "custom" : key.ease;
|
|
1100
|
+
const bezier = draft ?? key.bezier;
|
|
1101
|
+
const curve = [];
|
|
1102
|
+
for (let i = 0; i <= 48; i += 1) {
|
|
1103
|
+
const t = i / 48;
|
|
1104
|
+
curve.push(`${i === 0 ? "M" : "L"}${px(t).toFixed(1)} ${py(applyEase(ease, t, bezier)).toFixed(1)}`);
|
|
1105
|
+
}
|
|
1106
|
+
if (ease === "hold") curve.push(`L${px(1)} ${py(1)}`);
|
|
1107
|
+
const box = `<rect class="tcm-ease-box" x="${LEFT}" y="${TOP}" width="98" height="60"/><line class="tcm-grid" x1="${px(0)}" y1="${py(0)}" x2="${px(1)}" y2="${py(1)}"/>`;
|
|
1108
|
+
const labels = `<text class="tcm-graph-axis" x="${LEFT}" y="96">time</text><text class="tcm-graph-axis" x="4" y="14">progress</text>`;
|
|
1109
|
+
const arms = handles ? `<line class="tcm-ease-arm" x1="${px(0)}" y1="${py(0)}" x2="${px(handles[0])}" y2="${py(handles[1])}"/><line class="tcm-ease-arm" x1="${px(1)}" y1="${py(1)}" x2="${px(handles[2])}" y2="${py(handles[3])}"/><circle class="tcm-ease-handle" data-handle="0" cx="${px(handles[0])}" cy="${py(handles[1])}" r="5"><title>Drag to reshape</title></circle><circle class="tcm-ease-handle" data-handle="1" cx="${px(handles[2])}" cy="${py(handles[3])}" r="5"><title>Drag to reshape</title></circle>` : "";
|
|
1110
|
+
svg.innerHTML = `${box}${labels}<path class="tcm-ease-curve" d="${curve.join("")}"/>${arms}`;
|
|
1111
|
+
};
|
|
1112
|
+
svg.addEventListener("pointerdown", (event) => {
|
|
1113
|
+
const handle = event.target.dataset?.handle;
|
|
1114
|
+
if (!key || handle === void 0) return;
|
|
1115
|
+
const start = handlesOf(key);
|
|
1116
|
+
if (!start) return;
|
|
1117
|
+
event.preventDefault();
|
|
1118
|
+
svg.setPointerCapture(event.pointerId);
|
|
1119
|
+
draft = [...start];
|
|
1120
|
+
const box = svg.getBoundingClientRect();
|
|
1121
|
+
const scaleX = W / Math.max(box.width, 1);
|
|
1122
|
+
const scaleY = H / Math.max(box.height, 1);
|
|
1123
|
+
const index = handle === "0" ? 0 : 2;
|
|
1124
|
+
const onMove = (move) => {
|
|
1125
|
+
const hx = unx((move.clientX - box.left) * scaleX);
|
|
1126
|
+
const hy = uny((move.clientY - box.top) * scaleY);
|
|
1127
|
+
draft[index] = Math.min(1, Math.max(0, hx));
|
|
1128
|
+
draft[index + 1] = Math.min(2, Math.max(-1, hy));
|
|
1129
|
+
paint();
|
|
1130
|
+
};
|
|
1131
|
+
const onUp = () => {
|
|
1132
|
+
svg.removeEventListener("pointermove", onMove);
|
|
1133
|
+
svg.removeEventListener("pointerup", onUp);
|
|
1134
|
+
svg.removeEventListener("pointercancel", onUp);
|
|
1135
|
+
const committed = draft.map((value) => Math.round(value * 1e3) / 1e3);
|
|
1136
|
+
draft = null;
|
|
1137
|
+
onCommit(committed);
|
|
1138
|
+
};
|
|
1139
|
+
svg.addEventListener("pointermove", onMove);
|
|
1140
|
+
svg.addEventListener("pointerup", onUp);
|
|
1141
|
+
svg.addEventListener("pointercancel", onUp);
|
|
1142
|
+
});
|
|
1143
|
+
return {
|
|
1144
|
+
element,
|
|
1145
|
+
/** Whether a handle is being dragged: the timeline holds its rebuilds meanwhile. */
|
|
1146
|
+
get dragging() {
|
|
1147
|
+
return draft !== null;
|
|
1148
|
+
},
|
|
1149
|
+
set: (next, nextExists) => {
|
|
1150
|
+
key = next;
|
|
1151
|
+
hasNext = nextExists;
|
|
1152
|
+
if (!draft) paint();
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
};
|
|
1156
|
+
//#endregion
|
|
1157
|
+
//#region src/ui/graph.ts
|
|
1158
|
+
/**
|
|
1159
|
+
* The graph editor: the picked camera's channels over time, drawn from the track as it plays (so
|
|
1160
|
+
* the path's bends and the eases show), with a dot per key per channel. Drag a dot up or down to
|
|
1161
|
+
* change that value in the key, or sideways to retime the key (the first few pixels pick which).
|
|
1162
|
+
* Click between keys to pick the segment there, double-click to add a key.
|
|
1163
|
+
*
|
|
1164
|
+
* Channels are stretched to their own ranges by default, so metres and degrees share one plot;
|
|
1165
|
+
* "values" draws them on one shared scale instead.
|
|
1166
|
+
*/
|
|
1167
|
+
const HEIGHT = 150;
|
|
1168
|
+
const PAD = 12;
|
|
1169
|
+
const SNAP$1 = .1;
|
|
1170
|
+
const DRAG_PX$1 = 4;
|
|
1171
|
+
const COLORS = {
|
|
1172
|
+
px: "#f87171",
|
|
1173
|
+
py: "#4ade80",
|
|
1174
|
+
pz: "#60a5fa",
|
|
1175
|
+
rx: "#fca5a5",
|
|
1176
|
+
ry: "#86efac",
|
|
1177
|
+
rz: "#93c5fd",
|
|
1178
|
+
fov: "#fbbf24",
|
|
1179
|
+
zoom: "#fbbf24",
|
|
1180
|
+
near: "#c4b5fd",
|
|
1181
|
+
far: "#f0abfc"
|
|
1182
|
+
};
|
|
1183
|
+
const SHORT = {
|
|
1184
|
+
px: "x",
|
|
1185
|
+
py: "y",
|
|
1186
|
+
pz: "z",
|
|
1187
|
+
rx: "rx",
|
|
1188
|
+
ry: "ry",
|
|
1189
|
+
rz: "rz",
|
|
1190
|
+
fov: "fov",
|
|
1191
|
+
zoom: "zoom",
|
|
1192
|
+
near: "near",
|
|
1193
|
+
far: "far"
|
|
1194
|
+
};
|
|
1195
|
+
const trim = (value) => {
|
|
1196
|
+
const text = value.toFixed(Math.abs(value) >= 100 ? 0 : 2);
|
|
1197
|
+
return text.includes(".") ? text.replace(/\.?0+$/, "") : text;
|
|
1198
|
+
};
|
|
1199
|
+
/** An angle moved by whole turns to sit nearest `near`, so a key dot lands on its unwrapped curve. */
|
|
1200
|
+
const nearestTurn = (value, near) => value + Math.round((near - value) / 360) * 360;
|
|
1201
|
+
const createGraph = (lab, options) => {
|
|
1202
|
+
const side = document.createElement("div");
|
|
1203
|
+
const plot = document.createElement("div");
|
|
1204
|
+
side.className = "tcm-graph-side";
|
|
1205
|
+
plot.className = "tcm-graph";
|
|
1206
|
+
plot.innerHTML = `<svg class="tcm-graph-svg" height="${HEIGHT}" role="img" aria-label="Camera channels over time"></svg>`;
|
|
1207
|
+
const svg = plot.querySelector("svg");
|
|
1208
|
+
const visible = /* @__PURE__ */ new Set([
|
|
1209
|
+
"px",
|
|
1210
|
+
"py",
|
|
1211
|
+
"pz",
|
|
1212
|
+
"fov"
|
|
1213
|
+
]);
|
|
1214
|
+
let normalize = true;
|
|
1215
|
+
/** Scales held still during a drag, so the value under the pointer doesn't slide. */
|
|
1216
|
+
let frozen = null;
|
|
1217
|
+
let lastScales = /* @__PURE__ */ new Map();
|
|
1218
|
+
let width = 600;
|
|
1219
|
+
const keysOf = () => {
|
|
1220
|
+
const camera = options.camera();
|
|
1221
|
+
return camera ? lab.keys(camera) : [];
|
|
1222
|
+
};
|
|
1223
|
+
/** Channels this track has, fov only for a perspective camera. */
|
|
1224
|
+
const available = (keys) => CHANNELS.filter((channel) => keys.some((key) => readChannel(key.pose, channel) !== void 0));
|
|
1225
|
+
const shown = (keys) => {
|
|
1226
|
+
const channels = available(keys).filter((channel) => visible.has(channel));
|
|
1227
|
+
return channels.length > 0 ? channels : available(keys).filter((channel) => channel === "zoom");
|
|
1228
|
+
};
|
|
1229
|
+
const x = (time) => time / Math.max(lab.timeline().duration, .001) * width;
|
|
1230
|
+
const y = (value, scale) => 138 - (value - scale.min) / Math.max(scale.max - scale.min, 1e-9) * 126;
|
|
1231
|
+
const rangeOf = (values) => {
|
|
1232
|
+
let min = Math.min(...values);
|
|
1233
|
+
let max = Math.max(...values);
|
|
1234
|
+
if (max - min < 1e-6) {
|
|
1235
|
+
min -= 1;
|
|
1236
|
+
max += 1;
|
|
1237
|
+
}
|
|
1238
|
+
const pad = (max - min) * .08;
|
|
1239
|
+
return {
|
|
1240
|
+
min: min - pad,
|
|
1241
|
+
max: max + pad
|
|
1242
|
+
};
|
|
1243
|
+
};
|
|
1244
|
+
const paintSide = (keys) => {
|
|
1245
|
+
const channels = available(keys);
|
|
1246
|
+
side.innerHTML = `${channels.map((channel) => `<button type="button" class="tcm-chan" data-channel="${channel}" aria-pressed="${visible.has(channel)}" style="--tcm-chan:${COLORS[channel]}" title="${CHANNEL_LABELS[channel]}">${SHORT[channel]}</button>`).join("")}<button type="button" class="tcm-chan tcm-chan--mode" data-graph-mode aria-pressed="${!normalize}" title="${normalize ? "Each channel stretched to its own range. Click for true values on one scale." : "True values on one scale. Click to stretch each channel to its own range."}">values</button>`;
|
|
1247
|
+
};
|
|
1248
|
+
const paint = () => {
|
|
1249
|
+
const keys = keysOf();
|
|
1250
|
+
const duration = lab.timeline().duration;
|
|
1251
|
+
width = Math.max(plot.clientWidth, 100);
|
|
1252
|
+
svg.setAttribute("width", String(width));
|
|
1253
|
+
svg.setAttribute("viewBox", `0 0 ${width} ${HEIGHT}`);
|
|
1254
|
+
paintSide(keys);
|
|
1255
|
+
if (keys.length === 0) {
|
|
1256
|
+
svg.innerHTML = `<text x="${width / 2}" y="${HEIGHT / 2}" text-anchor="middle" class="tcm-graph-empty">${options.camera() ? "No keys on this camera yet: press Key, or double-click here" : "Pick a camera to see its track"}</text>`;
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
const channels = shown(keys);
|
|
1260
|
+
const samples = Math.min(400, Math.max(40, Math.round(width / 3)));
|
|
1261
|
+
const curves = new Map(channels.map((channel) => [channel, sampleChannel(keys, channel, 0, duration, samples)]));
|
|
1262
|
+
const scales = /* @__PURE__ */ new Map();
|
|
1263
|
+
if (frozen) for (const [channel, scale] of frozen) scales.set(channel, scale);
|
|
1264
|
+
else if (normalize) for (const [channel, points] of curves) scales.set(channel, rangeOf(points.map(([, value]) => value)));
|
|
1265
|
+
else {
|
|
1266
|
+
const all = rangeOf([...curves.values()].flatMap((points) => points.map(([, value]) => value)));
|
|
1267
|
+
for (const channel of channels) scales.set(channel, all);
|
|
1268
|
+
}
|
|
1269
|
+
lastScales = scales;
|
|
1270
|
+
const picked = options.picked();
|
|
1271
|
+
const grid = [
|
|
1272
|
+
.25,
|
|
1273
|
+
.5,
|
|
1274
|
+
.75
|
|
1275
|
+
].map((share) => {
|
|
1276
|
+
const at = PAD + share * 126;
|
|
1277
|
+
return `<line class="tcm-grid" x1="0" x2="${width}" y1="${at}" y2="${at}"/>`;
|
|
1278
|
+
}).join("");
|
|
1279
|
+
const shared = !normalize && channels[0] ? scales.get(channels[0]) : null;
|
|
1280
|
+
const axis = shared ? `<text class="tcm-graph-axis" x="4" y="21">${trim(shared.max)}</text><text class="tcm-graph-axis" x="4" y="135">${trim(shared.min)}</text>` : "";
|
|
1281
|
+
const keyLines = keys.map((key) => `<line class="tcm-key-line" x1="${x(key.time)}" x2="${x(key.time)}" y1="0" y2="${HEIGHT}"/>`).join("");
|
|
1282
|
+
const lines = channels.map((channel) => {
|
|
1283
|
+
const scale = scales.get(channel);
|
|
1284
|
+
return `<path class="tcm-curve" d="${curves.get(channel).map(([time, value], index) => `${index === 0 ? "M" : "L"}${x(time).toFixed(1)} ${y(value, scale).toFixed(1)}`).join("")}" stroke="${COLORS[channel]}"/>`;
|
|
1285
|
+
}).join("");
|
|
1286
|
+
const dots = channels.flatMap((channel) => {
|
|
1287
|
+
const scale = scales.get(channel);
|
|
1288
|
+
const points = curves.get(channel);
|
|
1289
|
+
return keys.map((key) => {
|
|
1290
|
+
let value = readChannel(key.pose, channel);
|
|
1291
|
+
if (channel.startsWith("r")) {
|
|
1292
|
+
const near = points.reduce((best, point) => Math.abs(point[0] - key.time) < Math.abs(best[0] - key.time) ? point : best);
|
|
1293
|
+
value = nearestTurn(value, near[1]);
|
|
1294
|
+
}
|
|
1295
|
+
const isPicked = picked?.key === key.id;
|
|
1296
|
+
return `<circle class="tcm-dot${isPicked ? " is-picked" : ""}" cx="${x(key.time)}" cy="${y(value, scale)}" r="${isPicked ? 5 : 4}" fill="${COLORS[channel]}" data-key="${key.id}" data-channel="${channel}" data-value="${value}"><title>${CHANNEL_LABELS[channel]} ${trim(value)} at ${key.time.toFixed(2)} s</title></circle>`;
|
|
1297
|
+
});
|
|
1298
|
+
}).join("");
|
|
1299
|
+
svg.innerHTML = grid + axis + keyLines + lines + dots;
|
|
1300
|
+
};
|
|
1301
|
+
side.addEventListener("click", (event) => {
|
|
1302
|
+
const button = event.target.closest("button");
|
|
1303
|
+
if (!button) return;
|
|
1304
|
+
if (button.dataset.graphMode !== void 0) normalize = !normalize;
|
|
1305
|
+
else {
|
|
1306
|
+
const channel = button.dataset.channel;
|
|
1307
|
+
if (visible.has(channel)) visible.delete(channel);
|
|
1308
|
+
else visible.add(channel);
|
|
1309
|
+
}
|
|
1310
|
+
paint();
|
|
1311
|
+
});
|
|
1312
|
+
const timeAt = (clientX) => {
|
|
1313
|
+
const box = svg.getBoundingClientRect();
|
|
1314
|
+
return Math.min(1, Math.max(0, (clientX - box.left) / Math.max(box.width, 1))) * lab.timeline().duration;
|
|
1315
|
+
};
|
|
1316
|
+
svg.addEventListener("pointerdown", (event) => {
|
|
1317
|
+
const camera = options.camera();
|
|
1318
|
+
const target = event.target;
|
|
1319
|
+
if (!camera) return;
|
|
1320
|
+
const keys = lab.keys(camera);
|
|
1321
|
+
if (!target.classList.contains("tcm-dot")) {
|
|
1322
|
+
const time = timeAt(event.clientX);
|
|
1323
|
+
const segment = [...keys].reverse().find((key) => key.time <= time) ?? null;
|
|
1324
|
+
options.onPick(segment ? {
|
|
1325
|
+
camera,
|
|
1326
|
+
key: segment.id
|
|
1327
|
+
} : null);
|
|
1328
|
+
lab.seek(time);
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
const keyId = target.dataset.key;
|
|
1332
|
+
const channel = target.dataset.channel;
|
|
1333
|
+
const startValue = Number(target.dataset.value);
|
|
1334
|
+
const key = keys.find((other) => other.id === keyId);
|
|
1335
|
+
if (!key) return;
|
|
1336
|
+
event.preventDefault();
|
|
1337
|
+
svg.setPointerCapture(event.pointerId);
|
|
1338
|
+
options.onPick({
|
|
1339
|
+
camera,
|
|
1340
|
+
key: keyId
|
|
1341
|
+
});
|
|
1342
|
+
options.onDrag(true);
|
|
1343
|
+
frozen = new Map(lastScales);
|
|
1344
|
+
lab.seek(key.time);
|
|
1345
|
+
const startX = event.clientX;
|
|
1346
|
+
const startY = event.clientY;
|
|
1347
|
+
const startPose = key.pose;
|
|
1348
|
+
const duration = lab.timeline().duration;
|
|
1349
|
+
const scale = frozen.get(channel) ?? {
|
|
1350
|
+
min: 0,
|
|
1351
|
+
max: 1
|
|
1352
|
+
};
|
|
1353
|
+
let axis = null;
|
|
1354
|
+
const onMove = (move) => {
|
|
1355
|
+
const dx = move.clientX - startX;
|
|
1356
|
+
const dy = move.clientY - startY;
|
|
1357
|
+
if (!axis) {
|
|
1358
|
+
if (Math.hypot(dx, dy) < DRAG_PX$1) return;
|
|
1359
|
+
axis = Math.abs(dx) > Math.abs(dy) ? "time" : "value";
|
|
1360
|
+
}
|
|
1361
|
+
try {
|
|
1362
|
+
if (axis === "time") {
|
|
1363
|
+
const raw = key.time + dx / width * duration;
|
|
1364
|
+
const time = Math.min(duration, Math.max(0, move.shiftKey ? raw : Math.round(raw / SNAP$1) * SNAP$1));
|
|
1365
|
+
lab.updateKey(camera, keyId, { time });
|
|
1366
|
+
lab.seek(time);
|
|
1367
|
+
} else {
|
|
1368
|
+
const perPixel = (scale.max - scale.min) / 126;
|
|
1369
|
+
const value = startValue - dy * perPixel * (move.shiftKey ? .1 : 1);
|
|
1370
|
+
lab.updateKey(camera, keyId, { pose: writeChannel(startPose, channel, value) });
|
|
1371
|
+
}
|
|
1372
|
+
paint();
|
|
1373
|
+
} catch (error) {
|
|
1374
|
+
options.say(error.message.replace(/^three-cameras: /, ""));
|
|
1375
|
+
}
|
|
1376
|
+
};
|
|
1377
|
+
const onUp = () => {
|
|
1378
|
+
svg.removeEventListener("pointermove", onMove);
|
|
1379
|
+
svg.removeEventListener("pointerup", onUp);
|
|
1380
|
+
svg.removeEventListener("pointercancel", onUp);
|
|
1381
|
+
frozen = null;
|
|
1382
|
+
options.onDrag(false);
|
|
1383
|
+
paint();
|
|
1384
|
+
};
|
|
1385
|
+
svg.addEventListener("pointermove", onMove);
|
|
1386
|
+
svg.addEventListener("pointerup", onUp);
|
|
1387
|
+
svg.addEventListener("pointercancel", onUp);
|
|
1388
|
+
});
|
|
1389
|
+
svg.addEventListener("dblclick", (event) => {
|
|
1390
|
+
const camera = options.camera();
|
|
1391
|
+
if (camera && !event.target.classList.contains("tcm-dot")) try {
|
|
1392
|
+
const key = lab.addKey(camera, { time: Math.round(timeAt(event.clientX) / SNAP$1) * SNAP$1 });
|
|
1393
|
+
options.onPick({
|
|
1394
|
+
camera,
|
|
1395
|
+
key: key.id
|
|
1396
|
+
});
|
|
1397
|
+
} catch (error) {
|
|
1398
|
+
options.say(error.message.replace(/^three-cameras: /, ""));
|
|
1399
|
+
}
|
|
1400
|
+
});
|
|
1401
|
+
const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => paint());
|
|
1402
|
+
resize?.observe(plot);
|
|
1403
|
+
return {
|
|
1404
|
+
side,
|
|
1405
|
+
plot,
|
|
1406
|
+
paint,
|
|
1407
|
+
dispose: () => {
|
|
1408
|
+
resize?.disconnect();
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
};
|
|
1412
|
+
//#endregion
|
|
1413
|
+
//#region src/ui/timeline.ts
|
|
1414
|
+
/** Keys snap to this, s, unless Shift is held. */
|
|
1415
|
+
const SNAP = .1;
|
|
1416
|
+
/** A press that moves less than this, px, is a click. */
|
|
1417
|
+
const DRAG_PX = 3;
|
|
1418
|
+
const ICONS = {
|
|
1419
|
+
start: "<path d=\"M6 5v14\"/><path d=\"M18 5 9 12l9 7Z\"/>",
|
|
1420
|
+
play: "<path d=\"M7 4v16l13-8Z\"/>",
|
|
1421
|
+
pause: "<path d=\"M8 5v14M16 5v14\"/>",
|
|
1422
|
+
stop: "<rect x=\"6\" y=\"6\" width=\"12\" height=\"12\" rx=\"1.5\"/>",
|
|
1423
|
+
loop: "<path d=\"M17 2l4 4-4 4\"/><path d=\"M3 11V9a3 3 0 0 1 3-3h15\"/><path d=\"m7 22-4-4 4-4\"/><path d=\"M21 13v2a3 3 0 0 1-3 3H3\"/>",
|
|
1424
|
+
key: "<path d=\"M12 3 21 12 12 21 3 12Z\"/>",
|
|
1425
|
+
trail: "<path d=\"M3 18c3-8 7-1 10-7s5-5 8-5\"/><circle cx=\"3\" cy=\"18\" r=\"1.5\"/><circle cx=\"21\" cy=\"6\" r=\"1.5\"/>"
|
|
1426
|
+
};
|
|
1427
|
+
const EASE_LABELS = {
|
|
1428
|
+
"ease-in-out": "ease in-out",
|
|
1429
|
+
linear: "linear",
|
|
1430
|
+
"ease-in": "ease in",
|
|
1431
|
+
"ease-out": "ease out",
|
|
1432
|
+
hold: "hold",
|
|
1433
|
+
custom: "custom curve"
|
|
1434
|
+
};
|
|
1435
|
+
const svg = (paths) => `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`;
|
|
1436
|
+
const icon = (action, paths, label) => `<button type="button" class="tcm-icon" data-action="${action}" title="${label}" aria-label="${label}">${svg(paths)}</button>`;
|
|
1437
|
+
const escape = (text) => text.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
1438
|
+
const seconds = (value) => value.toFixed(2);
|
|
1439
|
+
/** Ruler steps: labelled every `major` s, a tick every `minor`. */
|
|
1440
|
+
const rulerSteps = (duration) => {
|
|
1441
|
+
const major = duration <= 12 ? 1 : duration <= 30 ? 5 : duration <= 120 ? 10 : 30;
|
|
1442
|
+
return {
|
|
1443
|
+
major,
|
|
1444
|
+
minor: major / (major === 1 ? 4 : 5)
|
|
1445
|
+
};
|
|
1446
|
+
};
|
|
1447
|
+
const createTimelinePanel = (lab, { sidebar } = {}) => {
|
|
1448
|
+
injectStyles();
|
|
1449
|
+
const element = document.createElement("div");
|
|
1450
|
+
element.className = "tcm tcm-timeline";
|
|
1451
|
+
element.tabIndex = -1;
|
|
1452
|
+
element.innerHTML = `
|
|
1453
|
+
<div class="tcm-transport">
|
|
1454
|
+
${icon("start", ICONS.start, "To the start")}
|
|
1455
|
+
<button type="button" class="tcm-icon tcm-play" data-action="play" title="Play (Space)" aria-label="Play">${svg(ICONS.play)}</button>
|
|
1456
|
+
${icon("stop", ICONS.stop, "Stop, and let the cameras go")}
|
|
1457
|
+
${icon("loop", ICONS.loop, "Loop")}
|
|
1458
|
+
<div class="tcm-modes" role="radiogroup" aria-label="View">
|
|
1459
|
+
<button type="button" role="radio" aria-checked="true" data-action="mode" data-mode="keys" title="Keys: a lane per camera">Keys</button>
|
|
1460
|
+
<button type="button" role="radio" aria-checked="false" data-action="mode" data-mode="graph" title="Graph: the picked camera's channels over time">Graph</button>
|
|
1461
|
+
</div>
|
|
1462
|
+
<span class="tcm-clock"></span>
|
|
1463
|
+
<label class="tcm-length" title="The timeline's length">length <input type="number" min="1" step="1" inputmode="decimal" /> s</label>
|
|
1464
|
+
<span class="tcm-spacer"></span>
|
|
1465
|
+
<button type="button" class="tcm-text-button tcm-add-key" data-action="add-key">${svg(ICONS.key)}<span>Key</span></button>
|
|
1466
|
+
</div>
|
|
1467
|
+
<div class="tcm-tl">
|
|
1468
|
+
<div class="tcm-tl-names"><div class="tcm-tl-corner"></div></div>
|
|
1469
|
+
<div class="tcm-tl-area">
|
|
1470
|
+
<div class="tcm-ruler" title="Drag to scrub"></div>
|
|
1471
|
+
<div class="tcm-lanes"></div>
|
|
1472
|
+
<div class="tcm-playhead"></div>
|
|
1473
|
+
</div>
|
|
1474
|
+
</div>
|
|
1475
|
+
<div class="tcm-inspector" hidden>
|
|
1476
|
+
<span class="tcm-inspector-title"></span>
|
|
1477
|
+
<label class="tcm-length">at <input type="number" min="0" step="0.1" data-key-field="time" inputmode="decimal" /> s</label>
|
|
1478
|
+
<span class="tcm-ease-slot"></span>
|
|
1479
|
+
<select data-key-field="ease" aria-label="Ease into the next key">${EASES.map((ease) => `<option value="${ease}">${EASE_LABELS[ease]}</option>`).join("")}</select>
|
|
1480
|
+
<span class="tcm-spacer"></span>
|
|
1481
|
+
<button type="button" class="tcm-text-button" data-action="key-update" title="Re-key it from the camera as it is now">Update</button>
|
|
1482
|
+
<button type="button" class="tcm-text-button" data-action="key-delete" title="Delete the key (Delete)">Delete</button>
|
|
1483
|
+
</div>
|
|
1484
|
+
<div class="tcm-empty tcm-tl-empty" hidden>Pick a camera and press Key, or double-click its lane, to start a track.</div>
|
|
1485
|
+
<div class="tcm-status" role="status"></div>
|
|
1486
|
+
`;
|
|
1487
|
+
const $ = (selector) => element.querySelector(selector);
|
|
1488
|
+
const playButton = $("[data-action=\"play\"]");
|
|
1489
|
+
const loopButton = $("[data-action=\"loop\"]");
|
|
1490
|
+
const clock = $(".tcm-clock");
|
|
1491
|
+
const lengthInput = $(".tcm-transport .tcm-length input");
|
|
1492
|
+
const addKey = $("[data-action=\"add-key\"]");
|
|
1493
|
+
const addKeyLabel = addKey.querySelector("span");
|
|
1494
|
+
const names = $(".tcm-tl-names");
|
|
1495
|
+
const area = $(".tcm-tl-area");
|
|
1496
|
+
const ruler = $(".tcm-ruler");
|
|
1497
|
+
const lanes = $(".tcm-lanes");
|
|
1498
|
+
const playhead = $(".tcm-playhead");
|
|
1499
|
+
const inspector = $(".tcm-inspector");
|
|
1500
|
+
const inspectorTitle = $(".tcm-inspector-title");
|
|
1501
|
+
const keyTime = $("[data-key-field=\"time\"]");
|
|
1502
|
+
const keyEase = $("[data-key-field=\"ease\"]");
|
|
1503
|
+
const empty = $(".tcm-tl-empty");
|
|
1504
|
+
const status = $(".tcm-status");
|
|
1505
|
+
let picked = null;
|
|
1506
|
+
let dragging = false;
|
|
1507
|
+
let frame = 0;
|
|
1508
|
+
let rulerFor = -1;
|
|
1509
|
+
let mode = "keys";
|
|
1510
|
+
const say = (message) => {
|
|
1511
|
+
status.textContent = message;
|
|
1512
|
+
};
|
|
1513
|
+
const attempt = (work) => {
|
|
1514
|
+
try {
|
|
1515
|
+
say("");
|
|
1516
|
+
work();
|
|
1517
|
+
} catch (error) {
|
|
1518
|
+
say(error.message.replace(/^three-cameras: /, ""));
|
|
1519
|
+
}
|
|
1520
|
+
};
|
|
1521
|
+
const tracked = () => lab.entries().filter((entry) => entry.projects);
|
|
1522
|
+
const graph = createGraph(lab, {
|
|
1523
|
+
camera: () => target(tracked())?.id ?? null,
|
|
1524
|
+
picked: () => picked,
|
|
1525
|
+
onPick: (next) => {
|
|
1526
|
+
picked = next;
|
|
1527
|
+
paintInspector();
|
|
1528
|
+
},
|
|
1529
|
+
onDrag: (active) => {
|
|
1530
|
+
dragging = active;
|
|
1531
|
+
if (!active) render();
|
|
1532
|
+
},
|
|
1533
|
+
say: (message) => say(message)
|
|
1534
|
+
});
|
|
1535
|
+
const ease = createEaseEditor({ onCommit: (bezier) => {
|
|
1536
|
+
if (picked) {
|
|
1537
|
+
const { camera, key } = picked;
|
|
1538
|
+
attempt(() => lab.updateKey(camera, key, { bezier }));
|
|
1539
|
+
}
|
|
1540
|
+
} });
|
|
1541
|
+
if (sidebar) {
|
|
1542
|
+
element.classList.add("is-combined");
|
|
1543
|
+
names.replaceChildren(sidebar.element);
|
|
1544
|
+
area.insertBefore(graph.side, playhead);
|
|
1545
|
+
} else names.append(graph.side);
|
|
1546
|
+
area.insertBefore(graph.plot, playhead);
|
|
1547
|
+
$(".tcm-ease-slot").replaceWith(ease.element);
|
|
1548
|
+
/** The camera "Key" acts on: the picked one, else the first that can be keyed. */
|
|
1549
|
+
const target = (entries) => entries.find((entry) => entry.selected) ?? entries[0] ?? null;
|
|
1550
|
+
const timeAt = (clientX) => {
|
|
1551
|
+
const box = ruler.getBoundingClientRect();
|
|
1552
|
+
const share = box.width > 0 ? (clientX - box.left) / box.width : 0;
|
|
1553
|
+
return Math.min(1, Math.max(0, share)) * lab.timeline().duration;
|
|
1554
|
+
};
|
|
1555
|
+
const percent = (time) => `${time / Math.max(lab.timeline().duration, .001) * 100}%`;
|
|
1556
|
+
/** Playhead, clock and play button: every frame while playing, else on changes. */
|
|
1557
|
+
const paintClock = () => {
|
|
1558
|
+
const state = lab.timeline();
|
|
1559
|
+
playhead.style.left = percent(state.time);
|
|
1560
|
+
clock.textContent = `${seconds(state.time)} / ${seconds(state.duration)} s`;
|
|
1561
|
+
playButton.innerHTML = svg(state.playing ? ICONS.pause : ICONS.play);
|
|
1562
|
+
playButton.title = state.playing ? "Pause (Space)" : "Play (Space)";
|
|
1563
|
+
playButton.setAttribute("aria-label", state.playing ? "Pause" : "Play");
|
|
1564
|
+
element.classList.toggle("is-engaged", state.engaged);
|
|
1565
|
+
element.dispatchEvent(new CustomEvent("tcm-clock", { detail: `${seconds(state.time)} / ${seconds(state.duration)} s` }));
|
|
1566
|
+
if (state.playing && !frame) {
|
|
1567
|
+
const tick = () => {
|
|
1568
|
+
paintClock();
|
|
1569
|
+
frame = lab.timeline().playing ? requestAnimationFrame(tick) : 0;
|
|
1570
|
+
};
|
|
1571
|
+
frame = requestAnimationFrame(tick);
|
|
1572
|
+
}
|
|
1573
|
+
};
|
|
1574
|
+
const paintRuler = (duration) => {
|
|
1575
|
+
if (rulerFor === duration) return;
|
|
1576
|
+
rulerFor = duration;
|
|
1577
|
+
const { major, minor } = rulerSteps(duration);
|
|
1578
|
+
const ticks = [];
|
|
1579
|
+
for (let at = 0; at <= duration + 1e-6; at += minor) {
|
|
1580
|
+
const isMajor = Math.abs(at / major - Math.round(at / major)) < 1e-6;
|
|
1581
|
+
ticks.push(`<span class="tcm-tick${isMajor ? " is-major" : ""}" style="left:${at / duration * 100}%">${isMajor ? `<b>${Math.round(at)}</b>` : ""}</span>`);
|
|
1582
|
+
}
|
|
1583
|
+
ruler.innerHTML = ticks.join("");
|
|
1584
|
+
};
|
|
1585
|
+
const pickedKey = () => picked ? lab.keys(picked.camera).find((key) => key.id === picked.key) ?? null : null;
|
|
1586
|
+
const paintInspector = () => {
|
|
1587
|
+
const key = pickedKey();
|
|
1588
|
+
inspector.hidden = !key;
|
|
1589
|
+
if (!key || !picked) {
|
|
1590
|
+
ease.set(null, false);
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
const keys = lab.keys(picked.camera);
|
|
1594
|
+
ease.set(key, keys.indexOf(key) < keys.length - 1);
|
|
1595
|
+
inspectorTitle.textContent = `${picked.camera} key`;
|
|
1596
|
+
if (document.activeElement !== keyTime) keyTime.value = seconds(key.time);
|
|
1597
|
+
keyEase.value = key.ease;
|
|
1598
|
+
};
|
|
1599
|
+
/** Lanes and keys, from the lab. Skipped mid-drag so the dragged key isn't rebuilt under the pointer. */
|
|
1600
|
+
const render = () => {
|
|
1601
|
+
if (dragging || ease.dragging) return;
|
|
1602
|
+
const state = lab.timeline();
|
|
1603
|
+
const entries = tracked();
|
|
1604
|
+
const aim = target(entries);
|
|
1605
|
+
paintRuler(state.duration);
|
|
1606
|
+
if (document.activeElement !== lengthInput) lengthInput.value = String(Math.round(state.duration * 100) / 100);
|
|
1607
|
+
loopButton.setAttribute("aria-pressed", String(state.loop));
|
|
1608
|
+
addKey.disabled = !aim;
|
|
1609
|
+
addKeyLabel.textContent = aim ? `Key ${aim.id}` : "Key";
|
|
1610
|
+
addKey.title = aim ? `Key ${aim.id} as it is now, at the playhead` : "No camera to key";
|
|
1611
|
+
element.classList.toggle("is-graph", mode === "graph");
|
|
1612
|
+
for (const button of element.querySelectorAll("[data-action=\"mode\"]")) button.setAttribute("aria-checked", String(button.dataset.mode === mode));
|
|
1613
|
+
const laneEntries = sidebar ? lab.entries() : entries;
|
|
1614
|
+
if (!sidebar) {
|
|
1615
|
+
names.querySelectorAll(".tcm-tl-row").forEach((node) => node.remove());
|
|
1616
|
+
names.insertBefore(document.createRange().createContextualFragment(entries.map((entry) => `<div class="tcm-tl-row">
|
|
1617
|
+
<button type="button" class="tcm-tl-name${entry.selected ? " is-selected" : ""}" data-camera="${escape(entry.id)}" title="Pick ${escape(entry.id)}">${escape(entry.id)}</button>
|
|
1618
|
+
<button type="button" class="tcm-icon tcm-trail" data-action="trail" data-camera="${escape(entry.id)}" aria-pressed="${entry.trail}" title="${entry.trail ? "Hide" : "Show"} its motion path in the scene" aria-label="${entry.trail ? "Hide" : "Show"} ${escape(entry.id)}'s motion path"${entry.keys === 0 ? " disabled" : ""}>${svg(ICONS.trail)}</button>
|
|
1619
|
+
</div>`).join("")), graph.side);
|
|
1620
|
+
}
|
|
1621
|
+
lanes.innerHTML = laneEntries.map((entry) => {
|
|
1622
|
+
const keys = lab.keys(entry.id).map((key) => {
|
|
1623
|
+
return `<button type="button" class="tcm-key${picked?.camera === entry.id && picked.key === key.id ? " is-picked" : ""}${key.ease === "hold" ? " is-hold" : ""}" data-key="${escape(key.id)}" style="left:${percent(key.time)}" title="${seconds(key.time)} s · ${EASE_LABELS[key.ease]}" aria-label="${escape(entry.id)} key at ${seconds(key.time)} s"></button>`;
|
|
1624
|
+
}).join("");
|
|
1625
|
+
return `<div class="tcm-lane${entry.selected ? " is-selected" : ""}${entry.projects ? "" : " is-inert"}" data-camera="${escape(entry.id)}"${entry.projects ? ` title="Double-click to key ${escape(entry.id)} here"` : ""}>${keys}</div>`;
|
|
1626
|
+
}).join("");
|
|
1627
|
+
if (picked && !pickedKey()) picked = null;
|
|
1628
|
+
empty.hidden = mode === "graph" || entries.some((entry) => entry.keys > 0);
|
|
1629
|
+
paintInspector();
|
|
1630
|
+
paintClock();
|
|
1631
|
+
if (mode === "graph") graph.paint();
|
|
1632
|
+
syncLanes();
|
|
1633
|
+
};
|
|
1634
|
+
/** With a sidebar: each lane at its camera row's height and place, keys on the row's header. */
|
|
1635
|
+
const syncLanes = () => {
|
|
1636
|
+
if (!sidebar || mode === "graph") return;
|
|
1637
|
+
const origin = lanes.getBoundingClientRect().top;
|
|
1638
|
+
let bottom = 0;
|
|
1639
|
+
for (const lane of lanes.querySelectorAll(".tcm-lane")) {
|
|
1640
|
+
const row = sidebar.row(lane.dataset.camera ?? "");
|
|
1641
|
+
if (!row || row.offsetParent === null || row.classList.contains("is-hidden")) {
|
|
1642
|
+
lane.hidden = true;
|
|
1643
|
+
continue;
|
|
1644
|
+
}
|
|
1645
|
+
const box = row.getBoundingClientRect();
|
|
1646
|
+
const head = row.querySelector(".tcm-head")?.getBoundingClientRect().height ?? box.height;
|
|
1647
|
+
lane.hidden = false;
|
|
1648
|
+
lane.style.top = `${box.top - origin}px`;
|
|
1649
|
+
lane.style.height = `${box.height}px`;
|
|
1650
|
+
lane.style.setProperty("--tcm-head", `${head}px`);
|
|
1651
|
+
bottom = Math.max(bottom, box.bottom - origin);
|
|
1652
|
+
}
|
|
1653
|
+
lanes.style.height = `${bottom}px`;
|
|
1654
|
+
};
|
|
1655
|
+
ruler.addEventListener("pointerdown", (event) => {
|
|
1656
|
+
ruler.setPointerCapture(event.pointerId);
|
|
1657
|
+
lab.seek(timeAt(event.clientX));
|
|
1658
|
+
const onMove = (move) => lab.seek(timeAt(move.clientX));
|
|
1659
|
+
const onUp = () => {
|
|
1660
|
+
ruler.removeEventListener("pointermove", onMove);
|
|
1661
|
+
ruler.removeEventListener("pointerup", onUp);
|
|
1662
|
+
ruler.removeEventListener("pointercancel", onUp);
|
|
1663
|
+
};
|
|
1664
|
+
ruler.addEventListener("pointermove", onMove);
|
|
1665
|
+
ruler.addEventListener("pointerup", onUp);
|
|
1666
|
+
ruler.addEventListener("pointercancel", onUp);
|
|
1667
|
+
});
|
|
1668
|
+
lanes.addEventListener("pointerdown", (event) => {
|
|
1669
|
+
const keyButton = event.target.closest(".tcm-key");
|
|
1670
|
+
const lane = event.target.closest(".tcm-lane");
|
|
1671
|
+
const camera = lane?.dataset.camera;
|
|
1672
|
+
if (!lane || !camera) return;
|
|
1673
|
+
if (!keyButton) {
|
|
1674
|
+
lab.select(camera);
|
|
1675
|
+
lab.seek(timeAt(event.clientX));
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
const keyId = keyButton.dataset.key;
|
|
1679
|
+
const key = lab.keys(camera).find((other) => other.id === keyId);
|
|
1680
|
+
if (!key) return;
|
|
1681
|
+
dragging = true;
|
|
1682
|
+
event.preventDefault();
|
|
1683
|
+
picked = {
|
|
1684
|
+
camera,
|
|
1685
|
+
key: keyId
|
|
1686
|
+
};
|
|
1687
|
+
lab.select(camera);
|
|
1688
|
+
keyButton.setPointerCapture(event.pointerId);
|
|
1689
|
+
const startX = event.clientX;
|
|
1690
|
+
const box = lanes.getBoundingClientRect();
|
|
1691
|
+
const duration = lab.timeline().duration;
|
|
1692
|
+
let moved = false;
|
|
1693
|
+
let time = key.time;
|
|
1694
|
+
const onMove = (move) => {
|
|
1695
|
+
if (!moved && Math.abs(move.clientX - startX) < DRAG_PX) return;
|
|
1696
|
+
moved = true;
|
|
1697
|
+
const raw = key.time + (move.clientX - startX) / Math.max(box.width, 1) * duration;
|
|
1698
|
+
const snapped = move.shiftKey ? raw : Math.round(raw / SNAP) * SNAP;
|
|
1699
|
+
time = Math.min(duration, Math.max(0, snapped));
|
|
1700
|
+
keyButton.style.left = percent(time);
|
|
1701
|
+
keyButton.title = `${seconds(time)} s`;
|
|
1702
|
+
};
|
|
1703
|
+
const onUp = () => {
|
|
1704
|
+
keyButton.removeEventListener("pointermove", onMove);
|
|
1705
|
+
keyButton.removeEventListener("pointerup", onUp);
|
|
1706
|
+
keyButton.removeEventListener("pointercancel", onUp);
|
|
1707
|
+
dragging = false;
|
|
1708
|
+
if (moved) attempt(() => lab.updateKey(camera, keyId, { time }));
|
|
1709
|
+
else lab.seek(key.time);
|
|
1710
|
+
render();
|
|
1711
|
+
};
|
|
1712
|
+
keyButton.addEventListener("pointermove", onMove);
|
|
1713
|
+
keyButton.addEventListener("pointerup", onUp);
|
|
1714
|
+
keyButton.addEventListener("pointercancel", onUp);
|
|
1715
|
+
});
|
|
1716
|
+
lanes.addEventListener("dblclick", (event) => {
|
|
1717
|
+
const camera = event.target.closest(".tcm-lane")?.dataset.camera;
|
|
1718
|
+
if (camera && !event.target.closest(".tcm-key")) attempt(() => {
|
|
1719
|
+
const key = lab.addKey(camera, { time: Math.round(timeAt(event.clientX) / SNAP) * SNAP });
|
|
1720
|
+
picked = {
|
|
1721
|
+
camera,
|
|
1722
|
+
key: key.id
|
|
1723
|
+
};
|
|
1724
|
+
});
|
|
1725
|
+
});
|
|
1726
|
+
names.addEventListener("click", (event) => {
|
|
1727
|
+
const camera = event.target.closest(".tcm-tl-name")?.dataset.camera;
|
|
1728
|
+
if (camera) lab.select(camera);
|
|
1729
|
+
});
|
|
1730
|
+
element.addEventListener("click", (event) => {
|
|
1731
|
+
const action = event.target.closest("[data-action]")?.dataset.action;
|
|
1732
|
+
const state = lab.timeline();
|
|
1733
|
+
if (action === "start") lab.seek(0);
|
|
1734
|
+
else if (action === "play") {
|
|
1735
|
+
if (state.playing) lab.pause();
|
|
1736
|
+
else lab.play();
|
|
1737
|
+
} else if (action === "stop") lab.stop();
|
|
1738
|
+
else if (action === "loop") lab.setLoop(!state.loop);
|
|
1739
|
+
else if (action === "mode") {
|
|
1740
|
+
mode = event.target.closest("[data-mode]")?.dataset.mode === "graph" ? "graph" : "keys";
|
|
1741
|
+
render();
|
|
1742
|
+
} else if (action === "trail") {
|
|
1743
|
+
const camera = event.target.closest("[data-camera]")?.dataset.camera;
|
|
1744
|
+
const entry = camera ? lab.entry(camera) : null;
|
|
1745
|
+
if (camera && entry) attempt(() => lab.setTrail(camera, !entry.trail));
|
|
1746
|
+
} else if (action === "add-key") {
|
|
1747
|
+
const aim = target(tracked());
|
|
1748
|
+
if (aim) attempt(() => {
|
|
1749
|
+
const key = lab.addKey(aim.id);
|
|
1750
|
+
picked = {
|
|
1751
|
+
camera: aim.id,
|
|
1752
|
+
key: key.id
|
|
1753
|
+
};
|
|
1754
|
+
});
|
|
1755
|
+
} else if (action === "key-update" && picked) {
|
|
1756
|
+
const { camera, key } = picked;
|
|
1757
|
+
attempt(() => lab.updateKey(camera, key, { recapture: true }));
|
|
1758
|
+
} else if (action === "key-delete" && picked) {
|
|
1759
|
+
const { camera, key } = picked;
|
|
1760
|
+
picked = null;
|
|
1761
|
+
attempt(() => lab.deleteKey(camera, key));
|
|
1762
|
+
}
|
|
1763
|
+
});
|
|
1764
|
+
lengthInput.addEventListener("change", () => {
|
|
1765
|
+
const value = Number(lengthInput.value);
|
|
1766
|
+
if (Number.isFinite(value) && value > 0) lab.setDuration(value);
|
|
1767
|
+
});
|
|
1768
|
+
keyTime.addEventListener("change", () => {
|
|
1769
|
+
const value = Number(keyTime.value);
|
|
1770
|
+
if (picked && Number.isFinite(value) && value >= 0) {
|
|
1771
|
+
const { camera, key } = picked;
|
|
1772
|
+
attempt(() => lab.updateKey(camera, key, { time: value }));
|
|
1773
|
+
}
|
|
1774
|
+
});
|
|
1775
|
+
keyEase.addEventListener("change", () => {
|
|
1776
|
+
if (picked) {
|
|
1777
|
+
const { camera, key } = picked;
|
|
1778
|
+
attempt(() => lab.updateKey(camera, key, { ease: keyEase.value }));
|
|
1779
|
+
}
|
|
1780
|
+
});
|
|
1781
|
+
element.addEventListener("keydown", (event) => {
|
|
1782
|
+
if (event.target.closest("input, select, textarea")) return;
|
|
1783
|
+
if (event.key === " ") {
|
|
1784
|
+
event.preventDefault();
|
|
1785
|
+
if (lab.timeline().playing) lab.pause();
|
|
1786
|
+
else lab.play();
|
|
1787
|
+
} else if ((event.key === "Delete" || event.key === "Backspace") && picked) {
|
|
1788
|
+
const { camera, key } = picked;
|
|
1789
|
+
picked = null;
|
|
1790
|
+
attempt(() => lab.deleteKey(camera, key));
|
|
1791
|
+
}
|
|
1792
|
+
});
|
|
1793
|
+
area.addEventListener("pointerdown", () => element.focus({ preventScroll: true }));
|
|
1794
|
+
const unsubscribe = lab.subscribe(render);
|
|
1795
|
+
const unsubscribeSidebar = sidebar?.onRender(syncLanes);
|
|
1796
|
+
const resize = sidebar && typeof ResizeObserver !== "undefined" ? new ResizeObserver(syncLanes) : null;
|
|
1797
|
+
if (sidebar) resize?.observe(sidebar.element);
|
|
1798
|
+
render();
|
|
1799
|
+
return {
|
|
1800
|
+
element,
|
|
1801
|
+
setTheme: (theme) => {
|
|
1802
|
+
if (element.closest(".perf-hud__card")) delete element.dataset.theme;
|
|
1803
|
+
else element.dataset.theme = theme;
|
|
1804
|
+
},
|
|
1805
|
+
dispose: () => {
|
|
1806
|
+
cancelAnimationFrame(frame);
|
|
1807
|
+
graph.dispose();
|
|
1808
|
+
resize?.disconnect();
|
|
1809
|
+
unsubscribeSidebar?.();
|
|
1810
|
+
unsubscribe();
|
|
1811
|
+
element.remove();
|
|
1812
|
+
}
|
|
1813
|
+
};
|
|
1814
|
+
};
|
|
1815
|
+
//#endregion
|
|
1816
|
+
//#region src/ui/mount.ts
|
|
1817
|
+
/**
|
|
1818
|
+
* The panel in the zkmake dev-panel frame (three-meter's `mountDevPanel`), so it docks, wakes,
|
|
1819
|
+
* dims and toggles exactly like the three-meter HUD: drag grip, expand/compact and dim-on-leave
|
|
1820
|
+
* discs beside a card with the brand label on top. One call for a vanilla three app.
|
|
1821
|
+
*
|
|
1822
|
+
* With the timeline (the default) it's one full-width panel anchored to the bottom of the screen:
|
|
1823
|
+
* the camera list as the timeline's left sidebar, each camera's row beside its lane. Without the
|
|
1824
|
+
* timeline, it's the camera list, docked and draggable like the other dev panels. Collapsed, either
|
|
1825
|
+
* is a small widget with the picked camera (else the one looked through, else a live one); a click
|
|
1826
|
+
* on it expands the panel again.
|
|
1827
|
+
*/
|
|
1828
|
+
const readFlag = (key) => {
|
|
1829
|
+
try {
|
|
1830
|
+
return localStorage.getItem(key);
|
|
1831
|
+
} catch {
|
|
1832
|
+
return null;
|
|
1833
|
+
}
|
|
1834
|
+
};
|
|
1835
|
+
const writeFlag = (key, value) => {
|
|
1836
|
+
try {
|
|
1837
|
+
localStorage.setItem(key, value);
|
|
1838
|
+
} catch {}
|
|
1839
|
+
};
|
|
1840
|
+
/** A JSON value in localStorage under one key, with a fallback when it's missing or unreadable. */
|
|
1841
|
+
const localJson = (key, fallback, valid) => ({
|
|
1842
|
+
load: () => {
|
|
1843
|
+
try {
|
|
1844
|
+
const parsed = JSON.parse(localStorage.getItem(key) ?? "null");
|
|
1845
|
+
return valid(parsed) ? parsed : fallback;
|
|
1846
|
+
} catch {
|
|
1847
|
+
return fallback;
|
|
1848
|
+
}
|
|
1849
|
+
},
|
|
1850
|
+
save: (value) => {
|
|
1851
|
+
try {
|
|
1852
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
1853
|
+
} catch {}
|
|
1854
|
+
}
|
|
1855
|
+
});
|
|
1856
|
+
const isObject = (value) => value !== null && typeof value === "object";
|
|
1857
|
+
const mountCameraPanel = (options) => {
|
|
1858
|
+
const key = options.storageKey === null ? null : options.storageKey ?? "three-cameras";
|
|
1859
|
+
const store = options.store ?? (key ? localJson(`${key}:views`, {}, isObject) : void 0);
|
|
1860
|
+
const trackStore = options.trackStore ?? (key ? localJson(`${key}:tracks`, { tracks: {} }, (value) => isObject(value) && isObject(value.tracks)) : void 0);
|
|
1861
|
+
const lab = new CameraLab({
|
|
1862
|
+
...options,
|
|
1863
|
+
...store ? { store } : {},
|
|
1864
|
+
...trackStore ? { trackStore } : {}
|
|
1865
|
+
});
|
|
1866
|
+
const theme = new HudTheme(options.theme ?? "system");
|
|
1867
|
+
const withTimeline = options.timeline !== false;
|
|
1868
|
+
const panel = createCameraPanel(lab, { paths: withTimeline });
|
|
1869
|
+
const timeline = withTimeline ? createTimelinePanel(lab, { sidebar: panel }) : null;
|
|
1870
|
+
const content = document.createElement("div");
|
|
1871
|
+
const mini = document.createElement("button");
|
|
1872
|
+
content.className = "tcm-shell";
|
|
1873
|
+
mini.type = "button";
|
|
1874
|
+
mini.className = "tcm tcm-mini";
|
|
1875
|
+
content.append(mini, timeline?.element ?? panel.element);
|
|
1876
|
+
const compactKey = key ? `${key}:compact` : null;
|
|
1877
|
+
let count = Number(panel.element.dataset.count ?? 0);
|
|
1878
|
+
let clock = timeline ? `${lab.timeline().time.toFixed(2)} / ${lab.timeline().duration.toFixed(2)} s` : "";
|
|
1879
|
+
const paintDetail = () => {
|
|
1880
|
+
const cameras = `${count} camera${count === 1 ? "" : "s"}`;
|
|
1881
|
+
frame.detail.textContent = clock ? `${cameras} · ${clock}` : cameras;
|
|
1882
|
+
};
|
|
1883
|
+
const escape = (text) => text.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
1884
|
+
/** The collapsed widget: the picked camera, else the one looked through, else a live one. */
|
|
1885
|
+
const paintMini = () => {
|
|
1886
|
+
if (!frame.element.classList.contains("tcm-compact")) return;
|
|
1887
|
+
const entries = lab.entries();
|
|
1888
|
+
const shown = entries.find((entry) => entry.selected) ?? entries.find((entry) => entry.viewing) ?? entries.find((entry) => entry.live) ?? entries[0];
|
|
1889
|
+
const state = lab.timeline();
|
|
1890
|
+
mini.classList.toggle("is-live", Boolean(shown?.live));
|
|
1891
|
+
mini.classList.toggle("is-viewing", Boolean(shown?.viewing));
|
|
1892
|
+
mini.title = shown ? `${shown.id}: expand the panel` : "Expand the panel";
|
|
1893
|
+
mini.setAttribute("aria-label", mini.title);
|
|
1894
|
+
mini.innerHTML = shown ? `<span class="tcm-mini-kind">${svg$1(KIND_ICONS[shown.kind])}</span><span class="tcm-mini-name">${escape(shown.id)}</span><span class="tcm-badge${shown.viewing ? " is-viewing" : shown.live ? " is-live" : ""}">${shown.viewing ? "viewing" : shown.live ? `${shown.fps} fps` : ""}</span>${timeline && state.playing ? `<span class="tcm-mini-clock">${state.time.toFixed(1)} s</span>` : ""}` : `<span class="tcm-mini-name">no cameras yet</span>`;
|
|
1895
|
+
};
|
|
1896
|
+
let miniTimer = null;
|
|
1897
|
+
const setCompact = (compact) => {
|
|
1898
|
+
frame.element.classList.toggle("tcm-compact", compact);
|
|
1899
|
+
panel.setActive(!compact);
|
|
1900
|
+
if (compact && !miniTimer) miniTimer = setInterval(paintMini, 500);
|
|
1901
|
+
else if (!compact && miniTimer) {
|
|
1902
|
+
clearInterval(miniTimer);
|
|
1903
|
+
miniTimer = null;
|
|
1904
|
+
}
|
|
1905
|
+
paintMini();
|
|
1906
|
+
if (compactKey) writeFlag(compactKey, compact ? "1" : "0");
|
|
1907
|
+
frame.refresh();
|
|
1908
|
+
};
|
|
1909
|
+
const frame = mountDevPanel({
|
|
1910
|
+
brand: "three-cameras",
|
|
1911
|
+
content,
|
|
1912
|
+
defaultPlacement: timeline ? {
|
|
1913
|
+
edge: "bottom",
|
|
1914
|
+
align: "start"
|
|
1915
|
+
} : options.defaultPlacement ?? {
|
|
1916
|
+
edge: "right",
|
|
1917
|
+
align: "start"
|
|
1918
|
+
},
|
|
1919
|
+
label: "Cameras",
|
|
1920
|
+
onToggle: () => setCompact(!frame.element.classList.contains("tcm-compact")),
|
|
1921
|
+
storageKey: key ? `${key}:${timeline ? "dock" : "panel"}` : null,
|
|
1922
|
+
theme,
|
|
1923
|
+
toggleLabel: "Collapse or expand the panel",
|
|
1924
|
+
...options.container ? { parent: options.container } : {}
|
|
1925
|
+
});
|
|
1926
|
+
frame.element.classList.add("tcm-frame");
|
|
1927
|
+
if (timeline) frame.element.classList.add("tcm-combined-frame");
|
|
1928
|
+
mini.addEventListener("click", () => setCompact(false));
|
|
1929
|
+
const canvas = options.renderer?.domElement;
|
|
1930
|
+
const paths = timeline && typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement ? createPathEditor(lab, canvas) : null;
|
|
1931
|
+
const unsubscribeMini = lab.subscribe(paintMini);
|
|
1932
|
+
const onCount = (event) => {
|
|
1933
|
+
count = Number(event.detail);
|
|
1934
|
+
paintDetail();
|
|
1935
|
+
};
|
|
1936
|
+
const onClock = (event) => {
|
|
1937
|
+
clock = String(event.detail);
|
|
1938
|
+
paintDetail();
|
|
1939
|
+
};
|
|
1940
|
+
panel.element.addEventListener("tcm-count", onCount);
|
|
1941
|
+
timeline?.element.addEventListener("tcm-clock", onClock);
|
|
1942
|
+
paintDetail();
|
|
1943
|
+
const stored = compactKey ? readFlag(compactKey) : null;
|
|
1944
|
+
setCompact(stored === null ? options.compact === true : stored === "1");
|
|
1945
|
+
const paintTheme = () => {
|
|
1946
|
+
panel.setTheme(theme.resolved);
|
|
1947
|
+
timeline?.setTheme(theme.resolved);
|
|
1948
|
+
};
|
|
1949
|
+
const unsubscribeTheme = theme.subscribe(paintTheme);
|
|
1950
|
+
paintTheme();
|
|
1951
|
+
return {
|
|
1952
|
+
lab,
|
|
1953
|
+
element: frame.element,
|
|
1954
|
+
setCompact,
|
|
1955
|
+
setTheme: (mode) => theme.setMode(mode),
|
|
1956
|
+
dispose: () => {
|
|
1957
|
+
if (miniTimer) clearInterval(miniTimer);
|
|
1958
|
+
unsubscribeMini();
|
|
1959
|
+
paths?.dispose();
|
|
1960
|
+
unsubscribeTheme();
|
|
1961
|
+
theme.dispose();
|
|
1962
|
+
panel.element.removeEventListener("tcm-count", onCount);
|
|
1963
|
+
timeline?.element.removeEventListener("tcm-clock", onClock);
|
|
1964
|
+
panel.dispose();
|
|
1965
|
+
timeline?.dispose();
|
|
1966
|
+
frame.dispose();
|
|
1967
|
+
lab.dispose();
|
|
1968
|
+
}
|
|
1969
|
+
};
|
|
1970
|
+
};
|
|
1971
|
+
//#endregion
|
|
1972
|
+
export { CAMERA_PANEL_STYLES as a, createCameraPanel as i, createTimelinePanel as n, injectStyles as o, createPathEditor as r, mountCameraPanel as t };
|
|
1973
|
+
|
|
1974
|
+
//# sourceMappingURL=mount-BAkbs7K2.js.map
|