@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.
@@ -1,770 +0,0 @@
1
- import { i as capturePose, o as poseToCode, t as CameraLab } from "./lab-D4PnOVwG.js";
2
- import { HudTheme, mountDevPanel } from "@zkmake/three-meter/ui";
3
- //#region src/ui/styles.ts
4
- /** The panel's stylesheet, injected once. Palettes by `data-theme` on `.tcm`. */
5
- const CAMERA_PANEL_STYLES = `
6
- /* Inside a dev-panel card the tokens come from three-meter's (\`--perf-*\`), so the panels match;
7
- on its own (in a host's tab) the same palette is the fallback. */
8
- .tcm {
9
- --tcm-bg: var(--perf-bg, rgba(22, 24, 29, 0.92));
10
- --tcm-fg: var(--perf-fg, #e6e8eb);
11
- --tcm-dim: var(--perf-muted, #8b909a);
12
- --tcm-line: var(--perf-border, rgba(255, 255, 255, 0.12));
13
- --tcm-field: var(--perf-row, rgba(255, 255, 255, 0.04));
14
- --tcm-hover: var(--perf-row, rgba(255, 255, 255, 0.04));
15
- --tcm-accent: var(--perf-accent, #60a5fa);
16
- --tcm-accent-soft: rgba(96, 165, 250, 0.14);
17
- --tcm-live: #34d399;
18
- box-sizing: border-box;
19
- color: var(--tcm-fg);
20
- font: 11px/1.35 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
21
- }
22
- .tcm[data-theme="light"] {
23
- --tcm-bg: rgba(250, 250, 252, 0.94);
24
- --tcm-fg: #1a1c21;
25
- --tcm-dim: #6b7079;
26
- --tcm-line: rgba(0, 0, 0, 0.12);
27
- --tcm-field: rgba(0, 0, 0, 0.05);
28
- --tcm-hover: rgba(0, 0, 0, 0.05);
29
- --tcm-accent: #2563eb;
30
- --tcm-accent-soft: rgba(37, 99, 235, 0.1);
31
- --tcm-live: #047857;
32
- }
33
- .tcm *, .tcm *::before, .tcm *::after { box-sizing: inherit; }
34
- .tcm button { font: inherit; color: inherit; }
35
-
36
- .tcm-panel { display: flex; flex-direction: column; gap: 6px; min-height: 0; padding: 8px; }
37
- /* In the dev-panel card: a fixed width (the frame's cap raised to fit), the list scrolling
38
- inside the card's height. */
39
- .perf-hud.tcm-frame { max-width: min(23rem, calc(100vw - 48px)); }
40
- .perf-hud__card > .tcm-panel { width: 22rem; max-width: 100%; flex: 1; }
41
- /* Compact: the brand row alone, with the count. */
42
- .perf-hud.tcm-compact .tcm-panel { display: none; }
43
- .perf-hud.tcm-compact .perf-hud__brand { padding-bottom: 6px; }
44
-
45
- .tcm-toolbar { display: flex; gap: 6px; }
46
- .tcm-search {
47
- flex: 1;
48
- min-width: 0;
49
- border: 1px solid var(--tcm-line);
50
- border-radius: 4px;
51
- background: var(--tcm-field);
52
- padding: 4px 6px;
53
- color: inherit;
54
- font: inherit;
55
- }
56
- .tcm-search::placeholder { color: var(--tcm-dim); }
57
- .tcm-search:focus { outline: 1px solid var(--tcm-accent); outline-offset: 0; }
58
-
59
- .tcm-icon {
60
- display: inline-grid;
61
- place-items: center;
62
- width: 26px;
63
- height: 26px;
64
- flex: none;
65
- padding: 0;
66
- border: 1px solid transparent;
67
- border-radius: 5px;
68
- background: transparent;
69
- color: var(--tcm-dim);
70
- cursor: pointer;
71
- }
72
- .tcm-icon:hover:not(:disabled) { background: var(--tcm-hover); color: var(--tcm-fg); }
73
- .tcm-icon:focus-visible { outline: 1px solid var(--tcm-accent); }
74
- .tcm-icon:disabled { visibility: hidden; }
75
- .tcm-icon[aria-pressed="true"] { color: var(--tcm-accent); background: var(--tcm-accent-soft); }
76
- .tcm svg {
77
- width: 16px;
78
- height: 16px;
79
- fill: none;
80
- stroke: currentColor;
81
- stroke-width: 2;
82
- stroke-linecap: round;
83
- stroke-linejoin: round;
84
- }
85
-
86
- .tcm-list { margin: 0; padding: 0; list-style: none; overflow-y: auto; min-height: 0; }
87
- .tcm-row { border-radius: 5px; border: 1px solid transparent; }
88
- .tcm-row.is-hidden { display: none; }
89
- .tcm-row.is-open { border-color: var(--tcm-line); background: var(--tcm-field); }
90
- .tcm-head {
91
- display: grid;
92
- grid-template-columns: 32px 1fr auto;
93
- align-items: center;
94
- gap: 8px;
95
- padding: 4px;
96
- border-radius: 5px;
97
- }
98
- .tcm-row:not(.is-open) .tcm-head:hover { background: var(--tcm-hover); }
99
- .tcm-kind {
100
- display: grid;
101
- place-items: center;
102
- width: 32px;
103
- height: 32px;
104
- border: 1px solid var(--tcm-line);
105
- border-radius: 5px;
106
- color: var(--tcm-dim);
107
- }
108
- .tcm-row.is-live .tcm-kind { color: var(--tcm-live); border-color: color-mix(in srgb, var(--tcm-live) 45%, transparent); }
109
- .tcm-text {
110
- min-width: 0;
111
- padding: 0;
112
- border: 0;
113
- background: none;
114
- text-align: left;
115
- cursor: pointer;
116
- }
117
- .tcm-name-line { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
118
- .tcm-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
119
- .tcm-badge { flex: none; color: var(--tcm-dim); }
120
- .tcm-badge:empty { display: none; }
121
- .tcm-badge.is-live, .tcm-badge.is-viewing { display: inline-flex; align-items: center; gap: 4px; }
122
- .tcm-badge.is-live { color: var(--tcm-live); }
123
- .tcm-badge.is-viewing { color: var(--tcm-accent); }
124
- .tcm-badge.is-live::before {
125
- content: "";
126
- width: 6px;
127
- height: 6px;
128
- border-radius: 50%;
129
- background: currentColor;
130
- animation: tcm-pulse 1.6s ease-in-out infinite;
131
- }
132
- @keyframes tcm-pulse { 50% { opacity: 0.25; } }
133
- @media (prefers-reduced-motion: reduce) { .tcm-badge.is-live::before { animation: none; } }
134
- .tcm-row.is-viewing .tcm-kind { color: var(--tcm-accent); border-color: color-mix(in srgb, var(--tcm-accent) 50%, transparent); }
135
- .tcm-meta { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--tcm-dim); }
136
- .tcm-actions { display: flex; gap: 2px; }
137
- .tcm-chevron svg { transition: rotate 120ms; }
138
- .tcm-row.is-open .tcm-chevron svg { rotate: 180deg; }
139
-
140
- .tcm-details {
141
- display: none;
142
- gap: 8px;
143
- padding: 2px 8px 8px;
144
- }
145
- .tcm-row.is-open .tcm-details { display: grid; }
146
- .tcm-group { display: grid; gap: 3px; }
147
- .tcm-term { color: var(--tcm-dim); }
148
- .tcm-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; }
149
- .tcm-fields--lens { grid-template-columns: repeat(2, minmax(0, 1fr)); }
150
- .tcm-num {
151
- display: flex;
152
- align-items: center;
153
- min-width: 0;
154
- border: 1px solid var(--tcm-line);
155
- border-radius: 4px;
156
- background: var(--tcm-field);
157
- }
158
- .tcm-num:focus-within { border-color: var(--tcm-accent); }
159
- .tcm-scrub {
160
- flex: none;
161
- padding: 0 5px;
162
- color: var(--tcm-dim);
163
- cursor: ew-resize;
164
- user-select: none;
165
- touch-action: none;
166
- }
167
- .tcm-scrub:hover { color: var(--tcm-fg); }
168
- .tcm-num input {
169
- width: 100%;
170
- min-width: 0;
171
- padding: 3px 5px 3px 0;
172
- border: 0;
173
- background: none;
174
- color: inherit;
175
- font: inherit;
176
- font-variant-numeric: tabular-nums;
177
- text-align: right;
178
- -moz-appearance: textfield;
179
- appearance: textfield;
180
- }
181
- .tcm-num input::-webkit-inner-spin-button, .tcm-num input::-webkit-outer-spin-button { appearance: none; margin: 0; }
182
- .tcm-num input:focus { outline: none; }
183
- .tcm-readout { display: grid; grid-template-columns: auto 1fr; gap: 2px 10px; margin: 0; }
184
- .tcm-readout dt { color: var(--tcm-dim); }
185
- .tcm-readout dd { margin: 0; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
186
- .tcm-buttons { display: flex; gap: 6px; }
187
- .tcm-text-button {
188
- border: 1px solid var(--tcm-line);
189
- border-radius: 4px;
190
- background: var(--tcm-field);
191
- padding: 3px 8px;
192
- cursor: pointer;
193
- white-space: nowrap;
194
- }
195
- .tcm-text-button:hover { border-color: var(--tcm-accent); }
196
- .tcm-text-button:focus-visible { outline: 1px solid var(--tcm-accent); }
197
- .tcm-views { display: flex; flex-wrap: wrap; gap: 4px; margin: 0; padding: 0; list-style: none; }
198
- .tcm-views:empty { display: none; }
199
- .tcm-view { display: flex; align-items: center; border: 1px solid var(--tcm-line); border-radius: 4px; }
200
- .tcm-view .tcm-icon { width: 20px; height: 20px; }
201
- .tcm-view .tcm-icon svg { width: 12px; height: 12px; }
202
- .tcm-view-go {
203
- display: inline-flex;
204
- align-items: center;
205
- gap: 4px;
206
- padding: 2px 4px 2px 6px;
207
- border: 0;
208
- background: none;
209
- cursor: pointer;
210
- }
211
- .tcm-view-go svg { width: 12px; height: 12px; }
212
- .tcm-view-go:hover { color: var(--tcm-accent); }
213
- .tcm-banner {
214
- display: flex;
215
- align-items: center;
216
- justify-content: space-between;
217
- gap: 8px;
218
- padding: 4px 4px 4px 8px;
219
- border-radius: 5px;
220
- background: var(--tcm-accent-soft);
221
- color: var(--tcm-accent);
222
- }
223
- .tcm-banner[hidden] { display: none; }
224
- .tcm-status { color: var(--tcm-dim); padding: 0 4px; }
225
- .tcm-status:empty { display: none; }
226
- .tcm-empty { color: var(--tcm-dim); padding: 6px 4px; }
227
- `;
228
- const STYLE_ID = "zkmake-three-cameras-styles";
229
- /** Add the stylesheet to `document.head` once. */
230
- const injectStyles = () => {
231
- if (typeof document === "undefined" || document.getElementById(STYLE_ID)) return;
232
- const style = document.createElement("style");
233
- style.id = STYLE_ID;
234
- style.textContent = CAMERA_PANEL_STYLES;
235
- document.head.append(style);
236
- };
237
- //#endregion
238
- //#region src/ui/panel.ts
239
- /** Live badges and open rows update this often, ms. */
240
- const TICK_MS = 250;
241
- /** Per field: the step a typed arrow key takes and what a pixel of scrubbing adds. */
242
- const FIELDS = {
243
- px: {
244
- label: "x",
245
- step: .01,
246
- perPixel: .02
247
- },
248
- py: {
249
- label: "y",
250
- step: .01,
251
- perPixel: .02
252
- },
253
- pz: {
254
- label: "z",
255
- step: .01,
256
- perPixel: .02
257
- },
258
- rx: {
259
- label: "x",
260
- step: .1,
261
- perPixel: .5
262
- },
263
- ry: {
264
- label: "y",
265
- step: .1,
266
- perPixel: .5
267
- },
268
- rz: {
269
- label: "z",
270
- step: .1,
271
- perPixel: .5
272
- },
273
- fov: {
274
- label: "fov",
275
- step: .1,
276
- perPixel: .2,
277
- min: 1
278
- },
279
- near: {
280
- label: "near",
281
- step: .01,
282
- perPixel: .005,
283
- min: 1e-4
284
- },
285
- far: {
286
- label: "far",
287
- step: 1,
288
- perPixel: .5,
289
- min: .001
290
- },
291
- zoom: {
292
- label: "zoom",
293
- step: .01,
294
- perPixel: .005,
295
- min: .01
296
- }
297
- };
298
- /** Line icons on a 24 grid, stroked in the text colour (styles.ts). */
299
- const KIND_ICONS = {
300
- perspective: "<rect x=\"2\" y=\"7\" width=\"12\" height=\"10\" rx=\"2\"/><path d=\"m14 10.5 7-4v11l-7-4\"/>",
301
- orthographic: "<rect x=\"3\" y=\"5\" width=\"18\" height=\"14\" rx=\"2\"/><path d=\"M3 12h18\"/>",
302
- 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\"/>",
303
- 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\"/>",
304
- other: "<circle cx=\"12\" cy=\"12\" r=\"8\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>"
305
- };
306
- const ICONS = {
307
- 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\"/>",
308
- frustum: "<path d=\"M3 12 20 5v14Z\"/><path d=\"M20 5v14\"/><circle cx=\"3\" cy=\"12\" r=\"1\"/>",
309
- chevron: "<path d=\"m6 9 6 6 6-6\"/>",
310
- refresh: "<path d=\"M21 12a9 9 0 1 1-3-6.7L21 8\"/><path d=\"M21 3v5h-5\"/>",
311
- go: "<path d=\"M5 12h14\"/><path d=\"m13 6 6 6-6 6\"/>",
312
- remove: "<path d=\"M6 6l12 12M18 6 6 18\"/>"
313
- };
314
- const svg = (paths) => `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`;
315
- const icon = (name, label, extra = "") => `<button type="button" class="tcm-icon ${extra}" data-action="${name}" title="${label}" aria-label="${label}">${svg(ICONS[name])}</button>`;
316
- const trim = (value, digits = 2) => {
317
- const fixed = value.toFixed(digits);
318
- return fixed.includes(".") ? fixed.replace(/\.?0+$/, "") : fixed;
319
- };
320
- const range = (near, far) => `${trim(near)}–${trim(far)}`;
321
- const triple = (values, digits = 2) => values.map((value) => trim(value, digits)).join(" ");
322
- const escape = (text) => text.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
323
- /** A camera's name as a JS identifier, for copied code: `orbit view` → `orbitView`. */
324
- const identifier = (name) => {
325
- const joined = name.split(/[^A-Za-z0-9]+/).filter(Boolean).map((word, index) => index === 0 ? word.toLowerCase() : word[0].toUpperCase() + word.slice(1)).join("");
326
- return /^[A-Za-z_]/.test(joined) ? joined : `camera${joined}`;
327
- };
328
- /** The row's second line: how it projects, at a glance. */
329
- const describe = (entry, details) => {
330
- const projection = details?.projection;
331
- const parts = [];
332
- if (entry.kind === "cube") parts.push("cube · 6 faces");
333
- else if (projection?.fov !== void 0) {
334
- parts.push(`fov ${trim(projection.fov, 1)}°`, range(projection.near, projection.far));
335
- if (projection.aspect !== void 0) parts.push(trim(projection.aspect));
336
- } else if (projection) parts.push("ortho", `zoom ${trim(projection.zoom)}`, range(projection.near, projection.far));
337
- else parts.push(entry.kind);
338
- if (!entry.inScene) parts.push("not in scene");
339
- return parts.join(" · ");
340
- };
341
- /** Facts that aren't edited here, under the fields. */
342
- const readoutOf = (entry, details) => {
343
- const { projection } = details;
344
- const rows = [["world", triple(details.position)]];
345
- if (projection?.aspect !== void 0) rows.push(["aspect", trim(projection.aspect, 3)]);
346
- if (projection?.left !== void 0) rows.push(["frustum", `l ${trim(projection.left)} r ${trim(projection.right)} t ${trim(projection.top)} b ${trim(projection.bottom)}`]);
347
- rows.push(["drawing", entry.live ? `${entry.fps} fps` : "no"]);
348
- rows.push(["in", entry.inScene ? entry.path.join(" › ") || "scene" : "the renderer only (not in the scene)"]);
349
- return rows.map(([term, value]) => `<dt>${term}</dt><dd>${escape(value)}</dd>`).join("");
350
- };
351
- const numberField = (key) => {
352
- const { label, step, min } = FIELDS[key];
353
- 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>`;
354
- };
355
- /** The values a field shows, from the camera now. */
356
- const valuesOf = (details) => {
357
- const [px, py, pz] = details.local.position;
358
- const [rx, ry, rz] = details.local.rotation;
359
- const projection = details.projection;
360
- return {
361
- px,
362
- py,
363
- pz,
364
- rx,
365
- ry,
366
- rz,
367
- ...projection ? {
368
- near: projection.near,
369
- far: projection.far,
370
- zoom: projection.zoom,
371
- fov: projection.fov
372
- } : {}
373
- };
374
- };
375
- const formatField = (key, value) => trim(value, key.startsWith("r") || key === "fov" ? 2 : 4);
376
- const createCameraPanel = (lab) => {
377
- injectStyles();
378
- const element = document.createElement("div");
379
- element.className = "tcm tcm-panel";
380
- element.innerHTML = `
381
- <div class="tcm-toolbar">
382
- <input type="search" class="tcm-search" placeholder="Filter by name…" autocomplete="off" spellcheck="false" aria-label="Filter cameras" />
383
- ${icon("refresh", "Look for new cameras")}
384
- </div>
385
- <div class="tcm-banner" hidden><span></span><button type="button" class="tcm-text-button" data-action="look-back">Back</button></div>
386
- <ul class="tcm-list"></ul>
387
- <div class="tcm-empty" hidden>No cameras yet. The one the renderer draws with shows up on its first frame.</div>
388
- <div class="tcm-status" role="status"></div>
389
- `;
390
- const search = element.querySelector(".tcm-search");
391
- const list = element.querySelector(".tcm-list");
392
- const empty = element.querySelector(".tcm-empty");
393
- const status = element.querySelector(".tcm-status");
394
- const banner = element.querySelector(".tcm-banner");
395
- const bannerText = banner.querySelector("span");
396
- const rows = /* @__PURE__ */ new Map();
397
- let timer = null;
398
- /** The field being scrubbed: the tick leaves it alone. */
399
- let scrubbing = null;
400
- const say = (message) => {
401
- status.textContent = message;
402
- };
403
- const attempt = (work) => {
404
- try {
405
- say("");
406
- work();
407
- } catch (error) {
408
- say(error.message.replace(/^three-cameras: /, ""));
409
- }
410
- };
411
- /** Read one field (or its x/y/z group) into a patch and set it. */
412
- const apply = (id, controls, key) => {
413
- const read = (field) => Number(controls.fields.get(field)?.value);
414
- const patch = {};
415
- if (key.startsWith("p")) {
416
- const values = [
417
- read("px"),
418
- read("py"),
419
- read("pz")
420
- ];
421
- if (values.every(Number.isFinite)) patch.position = values;
422
- } else if (key.startsWith("r")) {
423
- const values = [
424
- read("rx"),
425
- read("ry"),
426
- read("rz")
427
- ];
428
- if (values.every(Number.isFinite)) patch.rotation = values;
429
- } else {
430
- const value = read(key);
431
- const min = FIELDS[key].min;
432
- if (Number.isFinite(value) && (min === void 0 || value >= min)) patch[key] = value;
433
- }
434
- attempt(() => lab.set(id, patch));
435
- };
436
- const buildControls = (row, entry) => {
437
- const lens = entry.kind === "perspective" ? [
438
- "fov",
439
- "near",
440
- "far",
441
- "zoom"
442
- ] : entry.kind === "orthographic" ? [
443
- "zoom",
444
- "near",
445
- "far"
446
- ] : [];
447
- row.details.innerHTML = `
448
- <div class="tcm-group"><span class="tcm-term">position</span><div class="tcm-fields">${numberField("px")}${numberField("py")}${numberField("pz")}</div></div>
449
- <div class="tcm-group"><span class="tcm-term">rotation °</span><div class="tcm-fields">${numberField("rx")}${numberField("ry")}${numberField("rz")}</div></div>
450
- ${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>` : ""}
451
- <dl class="tcm-readout"></dl>
452
- ${entry.projects ? `<div class="tcm-buttons">
453
- <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>
454
- <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>
455
- </div>
456
- <ul class="tcm-views" aria-label="Saved views"></ul>` : ""}
457
- `;
458
- const fields = /* @__PURE__ */ new Map();
459
- for (const input of row.details.querySelectorAll("input[data-field]")) fields.set(input.dataset.field, input);
460
- const controls = {
461
- fields,
462
- readout: row.details.querySelector(".tcm-readout"),
463
- views: row.details.querySelector(".tcm-views") ?? document.createElement("ul"),
464
- copy: row.details.querySelector("[data-action=\"copy\"]") ?? document.createElement("button"),
465
- viewsDrawn: ""
466
- };
467
- const id = entry.id;
468
- for (const [key, input] of fields) {
469
- input.addEventListener("input", () => apply(id, controls, key));
470
- input.addEventListener("blur", () => {
471
- const details = lab.details(id);
472
- const value = details ? valuesOf(details)[key] : void 0;
473
- if (value !== void 0) input.value = formatField(key, value);
474
- });
475
- }
476
- for (const handle of row.details.querySelectorAll("[data-scrub]")) {
477
- const key = handle.dataset.scrub;
478
- const input = fields.get(key);
479
- handle.addEventListener("pointerdown", (event) => {
480
- event.preventDefault();
481
- handle.setPointerCapture(event.pointerId);
482
- scrubbing = input;
483
- const startX = event.clientX;
484
- const startValue = Number(input.value) || 0;
485
- const { perPixel, min } = FIELDS[key];
486
- const onMove = (move) => {
487
- const scale = move.shiftKey ? .1 : move.altKey ? 10 : 1;
488
- let value = startValue + (move.clientX - startX) * perPixel * scale;
489
- if (min !== void 0) value = Math.max(min, value);
490
- input.value = formatField(key, value);
491
- apply(id, controls, key);
492
- };
493
- const onUp = () => {
494
- scrubbing = null;
495
- handle.removeEventListener("pointermove", onMove);
496
- handle.removeEventListener("pointerup", onUp);
497
- handle.removeEventListener("pointercancel", onUp);
498
- };
499
- handle.addEventListener("pointermove", onMove);
500
- handle.addEventListener("pointerup", onUp);
501
- handle.addEventListener("pointercancel", onUp);
502
- });
503
- }
504
- return controls;
505
- };
506
- const drawViews = (row, entry) => {
507
- const controls = row.controls;
508
- const views = lab.views(entry.id);
509
- const drawn = views.map((view) => `${view.id}:${view.name}`).join("|");
510
- if (drawn === controls.viewsDrawn) return;
511
- controls.viewsDrawn = drawn;
512
- controls.views.innerHTML = views.map((view) => `<li class="tcm-view" data-view="${escape(view.id)}">
513
- <button type="button" class="tcm-view-go" data-action="go" title="Fly back to ${escape(view.name)}">${svg(ICONS.go)}<span>${escape(view.name)}</span></button>
514
- ${icon("remove", `Forget ${escape(view.name)}`)}
515
- </li>`).join("");
516
- };
517
- const makeRow = (entry) => {
518
- const li = document.createElement("li");
519
- const id = entry.id;
520
- li.className = "tcm-row";
521
- li.innerHTML = `
522
- <div class="tcm-head">
523
- <span class="tcm-kind" title="${entry.kind} camera">${svg(KIND_ICONS[entry.kind])}</span>
524
- <button type="button" class="tcm-text" data-action="open" aria-expanded="false" title="Pick to edit">
525
- <span class="tcm-name-line"><span class="tcm-name"></span><span class="tcm-badge"></span></span>
526
- <span class="tcm-meta"></span>
527
- </button>
528
- <div class="tcm-actions">
529
- ${icon("look", "Look through it")}
530
- ${icon("frustum", "Show its frustum in the scene")}
531
- ${icon("chevron", "Edit", "tcm-chevron")}
532
- </div>
533
- </div>
534
- <div class="tcm-details"></div>
535
- `;
536
- const row = {
537
- li,
538
- name: li.querySelector(".tcm-name"),
539
- badge: li.querySelector(".tcm-badge"),
540
- meta: li.querySelector(".tcm-meta"),
541
- look: li.querySelector("[data-action=\"look\"]"),
542
- helper: li.querySelector("[data-action=\"frustum\"]"),
543
- details: li.querySelector(".tcm-details"),
544
- controls: null,
545
- open: false
546
- };
547
- row.name.textContent = id;
548
- row.name.title = id;
549
- li.addEventListener("click", (event) => {
550
- const target = event.target.closest("[data-action]");
551
- const action = target?.dataset.action;
552
- const current = lab.entry(id);
553
- if (!current || !action) return;
554
- if (action === "open" || action === "chevron") {
555
- row.open = !row.open;
556
- if (row.open && !row.controls) row.controls = buildControls(row, current);
557
- update(row, current);
558
- } else if (action === "look") attempt(() => lab.lookThrough(current.viewing ? null : id));
559
- else if (action === "frustum") attempt(() => lab.setHelper(id, !current.helper));
560
- else if (action === "copy") copyCode(row, current);
561
- else if (action === "save") attempt(() => lab.saveView(id));
562
- else if (action === "go" || action === "remove") {
563
- const viewId = target.closest("[data-view]")?.dataset.view;
564
- if (viewId) attempt(() => action === "go" ? lab.goToView(id, viewId) : lab.deleteView(id, viewId));
565
- }
566
- });
567
- return row;
568
- };
569
- const copyCode = async (row, entry) => {
570
- const code = poseToCode(capturePose(entry.camera), identifier(entry.id));
571
- const button = row.controls.copy;
572
- try {
573
- await navigator.clipboard.writeText(code);
574
- button.textContent = "Copied";
575
- } catch {
576
- say("The clipboard is blocked here. The code is logged to the console instead.");
577
- console.log(code);
578
- }
579
- setTimeout(() => {
580
- button.textContent = "Copy as code";
581
- }, 1200);
582
- };
583
- const update = (row, entry) => {
584
- const open = row.open;
585
- const details = lab.details(entry.id);
586
- row.li.classList.toggle("is-live", entry.live);
587
- row.li.classList.toggle("is-open", open);
588
- row.li.classList.toggle("is-viewing", entry.viewing);
589
- row.badge.className = `tcm-badge${entry.viewing ? " is-viewing" : entry.live ? " is-live" : ""}`;
590
- row.badge.textContent = entry.viewing ? "viewing" : entry.standingIn ? "replaced" : entry.live ? `live · ${entry.fps} fps` : "";
591
- row.meta.textContent = describe(entry, details);
592
- row.meta.title = row.meta.textContent;
593
- row.look.disabled = !entry.projects || !lab.watching;
594
- row.look.setAttribute("aria-pressed", String(entry.viewing));
595
- row.look.title = entry.viewing ? "Stop looking through it" : "Look through it";
596
- row.look.setAttribute("aria-label", row.look.title);
597
- row.helper.disabled = !entry.canHelp;
598
- row.helper.setAttribute("aria-pressed", String(entry.helper));
599
- row.helper.title = entry.helper ? "Hide its frustum" : "Show its frustum in the scene";
600
- row.helper.setAttribute("aria-label", row.helper.title);
601
- for (const toggle of row.li.querySelectorAll("[data-action=\"open\"], [data-action=\"chevron\"]")) toggle.setAttribute("aria-expanded", String(open));
602
- if (!open || !details || !row.controls) return;
603
- const values = valuesOf(details);
604
- for (const [key, input] of row.controls.fields) {
605
- const value = values[key];
606
- if (value !== void 0 && input !== document.activeElement && input !== scrubbing) input.value = formatField(key, value);
607
- }
608
- row.controls.readout.innerHTML = readoutOf(entry, details);
609
- drawViews(row, entry);
610
- };
611
- const filter = () => {
612
- const query = search.value.trim().toLowerCase();
613
- for (const [id, row] of rows) row.li.classList.toggle("is-hidden", query.length > 0 && !id.toLowerCase().includes(query));
614
- };
615
- /** Rows in, rows out, then every row's live state. */
616
- const render = () => {
617
- const entries = lab.entries();
618
- const ids = new Set(entries.map((entry) => entry.id));
619
- for (const [id, row] of rows) if (!ids.has(id)) {
620
- row.li.remove();
621
- rows.delete(id);
622
- }
623
- for (const entry of entries) {
624
- let row = rows.get(entry.id);
625
- if (!row) {
626
- row = makeRow(entry);
627
- rows.set(entry.id, row);
628
- list.append(row.li);
629
- }
630
- update(row, entry);
631
- }
632
- const viewing = entries.find((entry) => entry.viewing);
633
- const standIn = entries.find((entry) => entry.standingIn);
634
- banner.hidden = !viewing;
635
- bannerText.textContent = viewing ? `Looking through ${viewing.id}${standIn ? ` in place of ${standIn.id}` : ""}` : "";
636
- empty.hidden = entries.length > 0;
637
- filter();
638
- element.dataset.count = String(entries.length);
639
- element.dispatchEvent(new CustomEvent("tcm-count", { detail: entries.length }));
640
- };
641
- element.querySelector("[data-action=\"refresh\"]").addEventListener("click", () => lab.refresh());
642
- banner.querySelector("button").addEventListener("click", () => attempt(() => lab.lookThrough(null)));
643
- search.addEventListener("input", filter);
644
- const unsubscribe = lab.subscribe(render);
645
- const setActive = (active) => {
646
- if (active && !timer) {
647
- lab.refresh();
648
- render();
649
- timer = setInterval(() => {
650
- lab.refresh();
651
- render();
652
- }, TICK_MS);
653
- } else if (!active && timer) {
654
- clearInterval(timer);
655
- timer = null;
656
- }
657
- };
658
- render();
659
- setActive(true);
660
- return {
661
- element,
662
- setTheme: (theme) => {
663
- if (element.closest(".perf-hud__card")) delete element.dataset.theme;
664
- else element.dataset.theme = theme;
665
- },
666
- refresh: () => lab.refresh(),
667
- setActive,
668
- dispose: () => {
669
- setActive(false);
670
- unsubscribe();
671
- element.remove();
672
- }
673
- };
674
- };
675
- //#endregion
676
- //#region src/ui/mount.ts
677
- /**
678
- * The panel in the zkmake dev-panel frame (three-meter's `mountDevPanel`), so it docks, wakes,
679
- * dims and toggles exactly like the three-meter HUD: drag grip, expand/compact and dim-on-leave
680
- * discs beside a card with the brand label on top. One call for a vanilla three app.
681
- */
682
- const readFlag = (key) => {
683
- try {
684
- return localStorage.getItem(key);
685
- } catch {
686
- return null;
687
- }
688
- };
689
- const writeFlag = (key, value) => {
690
- try {
691
- localStorage.setItem(key, value);
692
- } catch {}
693
- };
694
- /** Saved views in localStorage, as JSON under one key. */
695
- const localViews = (key) => ({
696
- load: () => {
697
- try {
698
- const parsed = JSON.parse(localStorage.getItem(key) ?? "{}");
699
- return parsed && typeof parsed === "object" ? parsed : {};
700
- } catch {
701
- return {};
702
- }
703
- },
704
- save: (views) => {
705
- try {
706
- localStorage.setItem(key, JSON.stringify(views));
707
- } catch {}
708
- }
709
- });
710
- const mountCameraPanel = (options) => {
711
- const key = options.storageKey === null ? null : options.storageKey ?? "three-cameras";
712
- const store = options.store ?? (key ? localViews(`${key}:views`) : void 0);
713
- const lab = new CameraLab({
714
- ...options,
715
- ...store ? { store } : {}
716
- });
717
- const panel = createCameraPanel(lab);
718
- const theme = new HudTheme(options.theme ?? "system");
719
- const compactKey = key ? `${key}:compact` : null;
720
- const setCompact = (compact) => {
721
- frame.element.classList.toggle("tcm-compact", compact);
722
- panel.setActive(!compact);
723
- if (compactKey) writeFlag(compactKey, compact ? "1" : "0");
724
- frame.refresh();
725
- };
726
- const frame = mountDevPanel({
727
- brand: "three-cameras",
728
- content: panel.element,
729
- defaultPlacement: options.defaultPlacement ?? {
730
- edge: "right",
731
- align: "start"
732
- },
733
- label: "Cameras",
734
- onToggle: () => setCompact(!frame.element.classList.contains("tcm-compact")),
735
- storageKey: key ? `${key}:panel` : null,
736
- theme,
737
- toggleLabel: "Show or hide the camera list",
738
- ...options.container ? { parent: options.container } : {}
739
- });
740
- frame.element.classList.add("tcm-frame");
741
- const paintTheme = () => panel.setTheme(theme.resolved);
742
- const unsubscribeTheme = theme.subscribe(paintTheme);
743
- const showCount = (count) => {
744
- frame.detail.textContent = `${count} camera${count === 1 ? "" : "s"}`;
745
- };
746
- const onCount = (event) => showCount(event.detail);
747
- paintTheme();
748
- panel.element.addEventListener("tcm-count", onCount);
749
- showCount(Number(panel.element.dataset.count ?? 0));
750
- const stored = compactKey ? readFlag(compactKey) : null;
751
- setCompact(stored === null ? options.compact === true : stored === "1");
752
- return {
753
- lab,
754
- element: frame.element,
755
- setCompact,
756
- setTheme: (mode) => theme.setMode(mode),
757
- dispose: () => {
758
- unsubscribeTheme();
759
- theme.dispose();
760
- panel.element.removeEventListener("tcm-count", onCount);
761
- panel.dispose();
762
- frame.dispose();
763
- lab.dispose();
764
- }
765
- };
766
- };
767
- //#endregion
768
- export { injectStyles as i, createCameraPanel as n, CAMERA_PANEL_STYLES as r, mountCameraPanel as t };
769
-
770
- //# sourceMappingURL=mount-DBjcu7if.js.map