@zkmake/three-studio 0.1.0 → 0.3.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 CHANGED
@@ -34,7 +34,9 @@ const config: StudioConfig = {
34
34
  ```
35
35
 
36
36
  `orbitViews(centre, near, { play })` gives `play`, `close` (along the play bearing), `front`, `side`,
37
- `back` and `top`. Write your own as `{ position, target, fov?, near?, far?, zoom? }`.
37
+ `back` and `top`. `front` looks from +x; for an object facing another way, pass where its front
38
+ camera stands, `{ front: [0, 0.25, -1] }` for a vehicle driving down -z, and `side` and `back` turn
39
+ with it. Write your own as `{ position, target, fov?, near?, far?, zoom? }`.
38
40
 
39
41
  ## With React Three Fiber
40
42
 
@@ -72,7 +74,9 @@ useFrame(({ clock }) => {
72
74
  ```
73
75
 
74
76
  Building the `<Canvas>` yourself? Put `<StudioRig state={resolveStudio(config, location.search)} />`
75
- inside it for the same wiring.
77
+ inside it for the same wiring. Or hand `<Studio>` the `Canvas` component to mount: on React Three
78
+ Fiber 10, whose default `Canvas` is WebGPU, a game drawn with WebGL (GLSL material patches) passes
79
+ `Canvas={Canvas}` from `@react-three/fiber/legacy`.
76
80
 
77
81
  ## Vanilla three
78
82
 
@@ -100,14 +104,15 @@ post-processing, pass `render: () => composer.render()` so shots include it.
100
104
 
101
105
  ## The URL
102
106
 
103
- | Parameter | Does |
104
- | ------------- | ------------------------------------------------------------------- |
105
- | `area=<name>` | The area. A bare `?<name>` works too, as single-object studios link |
106
- | `v=<name>` | The variant; the area's first by default |
107
- | `view=<name>` | The camera view; `play` by default |
108
- | `t=<seconds>` | Freezes animation at that time, for steady shots (`useStudioTime`) |
109
- | `wire=1` | Draws every material as wireframe, to read the topology |
110
- | `clean=1` | Hides the overlay, for screenshots of the whole window |
107
+ | Parameter | Does |
108
+ | -------------- | ------------------------------------------------------------------- |
109
+ | `area=<name>` | The area. A bare `?<name>` works too, as single-object studios link |
110
+ | `v=<name>` | The variant; the area's first by default |
111
+ | `view=<name>` | The camera view; `play` by default |
112
+ | `t=<seconds>` | Freezes animation at that time, for steady shots (`useStudioTime`) |
113
+ | `wire=1` | Draws every material as wireframe, to read the topology |
114
+ | `clean=1` | Hides the overlay, for screenshots of the whole window |
115
+ | `shots=<port>` | Where `saveShot` posts, when `three-studio serve` isn't on 8787 |
111
116
 
112
117
  Unknown names fall back to the defaults and are listed in `state.unknown`. `studioHref(state, change)`
113
118
  builds links: a new area starts at its first variant, the view and `wire` / `t` carry over.
@@ -129,6 +134,9 @@ await saveShot("round-1/trees-lush-play.png");
129
134
  // "/…/shots/round-1/trees-lush-play.png 38 calls · 112,400 tris"
130
135
  ```
131
136
 
137
+ Port 8787 taken (another receiver, another agent's pass)? `serve` says so: run it with
138
+ `--port 8797` and open the page with `&shots=8797`, which links on the page carry along.
139
+
132
140
  `saveShot` draws a frame and reads it in the same task, so it needs no `preserveDrawingBuffer` and
133
141
  works in a hidden browser pane where `requestAnimationFrame` never fires. The receiver only writes
134
142
  `.png` files inside its folder, and only listens locally. `{ size: "css" }` saves at the canvas's CSS
package/dist/cli.js CHANGED
@@ -95,6 +95,8 @@ const main = async (argv, options = {}) => {
95
95
  const shots = await startShotServer(files[0], {
96
96
  port,
97
97
  log: console.log
98
+ }).catch((error) => {
99
+ throw error.code === "EADDRINUSE" ? /* @__PURE__ */ new Error(`port ${port} is taken, by another receiver or app: pass --port <free port>, and open the page with &shots=<that port>`) : error;
98
100
  });
99
101
  console.log(`three-studio: writing .png files inside ${shots.root} from ${shots.url}`);
100
102
  await (options.stop ?? new Promise((stop) => {
package/dist/cli.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.js","names":[],"sources":["../src/cli/main.ts","../src/cli.ts"],"sourcesContent":["/**\n * `three-studio serve | diff | montage`: receive the page's shots, and compare them. `diff` exits 1\n * past `--max`, so a stored baseline can gate CI.\n */\n/* oxlint-disable no-console -- a CLI's output is the console */\nimport { readFileSync, writeFileSync } from \"node:fs\";\nimport { parseArgs } from \"node:util\";\n\nimport { SHOT_PORT } from \"../core/capture.ts\";\nimport { diffImages, montage } from \"../node/compare.ts\";\nimport { decodePng, encodePng } from \"../node/png.ts\";\nimport { startShotServer } from \"../node/serve.ts\";\n\nconst HELP = `Usage: three-studio <command> [options]\n\nCommands:\n serve <folder> Receive saveShot() PNGs from the page and write them inside\n <folder>. Local only. Runs until stopped.\n --port <n> Default ${SHOT_PORT}, the port saveShot() posts to\n\n diff <before.png> <after.png> Count the pixels that differ. Prints \"<n> px differ (<share>%)\".\n --fuzz <percent> How far a channel may move and still match (default 3)\n --max <percent> Exit 1 when more than this share differs (default: never)\n --out <mask.png> Write the differing pixels in white over a dimmed before\n --json Print the result as JSON\n\n montage <out.png> <before> <after> [<before> <after>…]\n One row per pair, before left and after right, rows stacked.\n --scale <n> Scale of the result (default 0.5)\n --gap <px> Space between images, before scaling (default 8)\n\n -h, --help Show this help\n -v, --version Show the version\n\nExits 2 on bad arguments or a file it can't read.`;\n\n/** This package's version: its package.json is the nearest one up from here (`src/cli` or `dist`). */\nconst version = () => {\n for (let up = \"../\"; up.length <= 12; up += \"../\") {\n try {\n const pkg = JSON.parse(\n readFileSync(new URL(`${up}package.json`, import.meta.url), \"utf8\"),\n ) as {\n name?: string;\n version: string;\n };\n\n if (pkg.name === \"@zkmake/three-studio\") {\n return pkg.version;\n }\n } catch {\n // Not here; one more up.\n }\n }\n\n return \"unknown\";\n};\n\nconst readPng = (file: string) => decodePng(readFileSync(file));\n\nconst percent = (text: string | undefined, name: string, fallback?: number) => {\n if (text === undefined) {\n return fallback;\n }\n\n const value = Number(text);\n\n if (!(value >= 0 && value <= 100)) {\n throw new Error(`--${name} must be a percentage from 0 to 100`);\n }\n\n return value / 100;\n};\n\nexport type MainOptions = {\n /** Resolves when `serve` should stop. Default: SIGINT or SIGTERM. */\n stop?: Promise<unknown> | undefined;\n};\n\nexport const main = async (argv: string[], options: MainOptions = {}): Promise<number> => {\n let parsed;\n\n try {\n parsed = parseArgs({\n args: argv,\n allowPositionals: true,\n options: {\n port: { type: \"string\" },\n fuzz: { type: \"string\" },\n max: { type: \"string\" },\n out: { type: \"string\" },\n json: { type: \"boolean\", default: false },\n scale: { type: \"string\" },\n gap: { type: \"string\" },\n help: { type: \"boolean\", short: \"h\", default: false },\n version: { type: \"boolean\", short: \"v\", default: false },\n },\n });\n } catch (error) {\n console.error(`three-studio: ${(error as Error).message}\\n\\n${HELP}`);\n\n return 2;\n }\n\n const { values, positionals } = parsed;\n const [command, ...files] = positionals;\n\n if (values.help) {\n console.log(HELP);\n\n return 0;\n }\n\n if (values.version) {\n console.log(version());\n\n return 0;\n }\n\n try {\n if (command === \"serve\" && files.length === 1) {\n const port = values.port === undefined ? SHOT_PORT : Number(values.port);\n\n if (!Number.isInteger(port) || port < 0 || port > 65535) {\n throw new Error(\"--port must be a port number\");\n }\n\n const shots = await startShotServer(files[0] as string, { port, log: console.log });\n\n console.log(`three-studio: writing .png files inside ${shots.root} from ${shots.url}`);\n await (options.stop ??\n new Promise((stop) => {\n process.once(\"SIGINT\", stop);\n process.once(\"SIGTERM\", stop);\n }));\n await shots.close();\n\n return 0;\n }\n\n if (command === \"diff\" && files.length === 2) {\n const fuzz = percent(values.fuzz, \"fuzz\", 0.03);\n const max = percent(values.max, \"max\");\n const result = diffImages(readPng(files[0] as string), readPng(files[1] as string), { fuzz });\n const over = max !== undefined && result.share > max;\n\n if (values.out) {\n writeFileSync(values.out, encodePng(result.mask));\n }\n\n if (values.json) {\n console.log(\n JSON.stringify({ pixels: result.pixels, total: result.total, share: result.share, over }),\n );\n } else {\n console.log(`${result.pixels} px differ (${(result.share * 100).toFixed(4)}%)`);\n }\n\n return over ? 1 : 0;\n }\n\n if (command === \"montage\" && files.length >= 3 && files.length % 2 === 1) {\n const [out, ...shots] = files as [string, ...string[]];\n const scale = values.scale === undefined ? 0.5 : Number(values.scale);\n const gap = values.gap === undefined ? 8 : Number(values.gap);\n\n if (!(scale > 0) || !(gap >= 0)) {\n throw new Error(\"--scale must be above 0 and --gap 0 or more\");\n }\n\n const pairs: [ReturnType<typeof readPng>, ReturnType<typeof readPng>][] = [];\n\n for (let i = 0; i < shots.length; i += 2) {\n pairs.push([readPng(shots[i] as string), readPng(shots[i + 1] as string)]);\n }\n\n writeFileSync(out, encodePng(montage(pairs, { scale, gap })));\n console.log(out);\n\n return 0;\n }\n } catch (error) {\n console.error(`three-studio: ${(error as Error).message}`);\n\n return 2;\n }\n\n console.error(HELP);\n\n return 2;\n};\n","#!/usr/bin/env node\n/** The `three-studio` bin. The work is in `cli/main.ts`, which the tests import. */\nimport { main } from \"./cli/main.ts\";\n\nprocess.exitCode = await main(process.argv.slice(2));\n"],"mappings":"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"}
1
+ {"version":3,"file":"cli.js","names":[],"sources":["../src/cli/main.ts","../src/cli.ts"],"sourcesContent":["/**\n * `three-studio serve | diff | montage`: receive the page's shots, and compare them. `diff` exits 1\n * past `--max`, so a stored baseline can gate CI.\n */\n/* oxlint-disable no-console -- a CLI's output is the console */\nimport { readFileSync, writeFileSync } from \"node:fs\";\nimport { parseArgs } from \"node:util\";\n\nimport { SHOT_PORT } from \"../core/capture.ts\";\nimport { diffImages, montage } from \"../node/compare.ts\";\nimport { decodePng, encodePng } from \"../node/png.ts\";\nimport { startShotServer } from \"../node/serve.ts\";\n\nconst HELP = `Usage: three-studio <command> [options]\n\nCommands:\n serve <folder> Receive saveShot() PNGs from the page and write them inside\n <folder>. Local only. Runs until stopped.\n --port <n> Default ${SHOT_PORT}, the port saveShot() posts to\n\n diff <before.png> <after.png> Count the pixels that differ. Prints \"<n> px differ (<share>%)\".\n --fuzz <percent> How far a channel may move and still match (default 3)\n --max <percent> Exit 1 when more than this share differs (default: never)\n --out <mask.png> Write the differing pixels in white over a dimmed before\n --json Print the result as JSON\n\n montage <out.png> <before> <after> [<before> <after>…]\n One row per pair, before left and after right, rows stacked.\n --scale <n> Scale of the result (default 0.5)\n --gap <px> Space between images, before scaling (default 8)\n\n -h, --help Show this help\n -v, --version Show the version\n\nExits 2 on bad arguments or a file it can't read.`;\n\n/** This package's version: its package.json is the nearest one up from here (`src/cli` or `dist`). */\nconst version = () => {\n for (let up = \"../\"; up.length <= 12; up += \"../\") {\n try {\n const pkg = JSON.parse(\n readFileSync(new URL(`${up}package.json`, import.meta.url), \"utf8\"),\n ) as {\n name?: string;\n version: string;\n };\n\n if (pkg.name === \"@zkmake/three-studio\") {\n return pkg.version;\n }\n } catch {\n // Not here; one more up.\n }\n }\n\n return \"unknown\";\n};\n\nconst readPng = (file: string) => decodePng(readFileSync(file));\n\nconst percent = (text: string | undefined, name: string, fallback?: number) => {\n if (text === undefined) {\n return fallback;\n }\n\n const value = Number(text);\n\n if (!(value >= 0 && value <= 100)) {\n throw new Error(`--${name} must be a percentage from 0 to 100`);\n }\n\n return value / 100;\n};\n\nexport type MainOptions = {\n /** Resolves when `serve` should stop. Default: SIGINT or SIGTERM. */\n stop?: Promise<unknown> | undefined;\n};\n\nexport const main = async (argv: string[], options: MainOptions = {}): Promise<number> => {\n let parsed;\n\n try {\n parsed = parseArgs({\n args: argv,\n allowPositionals: true,\n options: {\n port: { type: \"string\" },\n fuzz: { type: \"string\" },\n max: { type: \"string\" },\n out: { type: \"string\" },\n json: { type: \"boolean\", default: false },\n scale: { type: \"string\" },\n gap: { type: \"string\" },\n help: { type: \"boolean\", short: \"h\", default: false },\n version: { type: \"boolean\", short: \"v\", default: false },\n },\n });\n } catch (error) {\n console.error(`three-studio: ${(error as Error).message}\\n\\n${HELP}`);\n\n return 2;\n }\n\n const { values, positionals } = parsed;\n const [command, ...files] = positionals;\n\n if (values.help) {\n console.log(HELP);\n\n return 0;\n }\n\n if (values.version) {\n console.log(version());\n\n return 0;\n }\n\n try {\n if (command === \"serve\" && files.length === 1) {\n const port = values.port === undefined ? SHOT_PORT : Number(values.port);\n\n if (!Number.isInteger(port) || port < 0 || port > 65535) {\n throw new Error(\"--port must be a port number\");\n }\n\n const shots = await startShotServer(files[0] as string, { port, log: console.log }).catch(\n (error: NodeJS.ErrnoException) => {\n throw error.code === \"EADDRINUSE\"\n ? new Error(\n `port ${port} is taken, by another receiver or app: pass --port <free port>, and open the page with &shots=<that port>`,\n )\n : error;\n },\n );\n\n console.log(`three-studio: writing .png files inside ${shots.root} from ${shots.url}`);\n await (options.stop ??\n new Promise((stop) => {\n process.once(\"SIGINT\", stop);\n process.once(\"SIGTERM\", stop);\n }));\n await shots.close();\n\n return 0;\n }\n\n if (command === \"diff\" && files.length === 2) {\n const fuzz = percent(values.fuzz, \"fuzz\", 0.03);\n const max = percent(values.max, \"max\");\n const result = diffImages(readPng(files[0] as string), readPng(files[1] as string), { fuzz });\n const over = max !== undefined && result.share > max;\n\n if (values.out) {\n writeFileSync(values.out, encodePng(result.mask));\n }\n\n if (values.json) {\n console.log(\n JSON.stringify({ pixels: result.pixels, total: result.total, share: result.share, over }),\n );\n } else {\n console.log(`${result.pixels} px differ (${(result.share * 100).toFixed(4)}%)`);\n }\n\n return over ? 1 : 0;\n }\n\n if (command === \"montage\" && files.length >= 3 && files.length % 2 === 1) {\n const [out, ...shots] = files as [string, ...string[]];\n const scale = values.scale === undefined ? 0.5 : Number(values.scale);\n const gap = values.gap === undefined ? 8 : Number(values.gap);\n\n if (!(scale > 0) || !(gap >= 0)) {\n throw new Error(\"--scale must be above 0 and --gap 0 or more\");\n }\n\n const pairs: [ReturnType<typeof readPng>, ReturnType<typeof readPng>][] = [];\n\n for (let i = 0; i < shots.length; i += 2) {\n pairs.push([readPng(shots[i] as string), readPng(shots[i + 1] as string)]);\n }\n\n writeFileSync(out, encodePng(montage(pairs, { scale, gap })));\n console.log(out);\n\n return 0;\n }\n } catch (error) {\n console.error(`three-studio: ${(error as Error).message}`);\n\n return 2;\n }\n\n console.error(HELP);\n\n return 2;\n};\n","#!/usr/bin/env node\n/** The `three-studio` bin. The work is in `cli/main.ts`, which the tests import. */\nimport { main } from \"./cli/main.ts\";\n\nprocess.exitCode = await main(process.argv.slice(2));\n"],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as saveShot, S as installFrameShim, _ as setWireframe, a as StudioVariant, b as ShotSize, c as Vec3, d as studioTime, f as titleOf, g as frameStats, h as formatStats, i as StudioState, l as resolveStudio, m as StatsRenderer, n as StudioConfig, o as StudioView, p as FrameStats, r as StudioLink, s as StudioViews, t as StudioArea, u as studioHref, v as SHOT_PORT, x as canvasPng, y as SaveShotOptions } from "./state-BsQ-taE3.js";
1
+ import { C as saveShot, S as installFrameShim, _ as setWireframe, a as StudioVariant, b as ShotSize, c as Vec3, d as studioTime, f as titleOf, g as frameStats, h as formatStats, i as StudioState, l as resolveStudio, m as StatsRenderer, n as StudioConfig, o as StudioView, p as FrameStats, r as StudioLink, s as StudioViews, t as StudioArea, u as studioHref, v as SHOT_PORT, x as canvasPng, y as SaveShotOptions } from "./state-KovV7yXI.js";
2
2
  import { Camera, Vector3 } from "three";
3
3
  /** What `applyView` moves alongside the camera: OrbitControls and the like. */
4
4
  type ViewControls = {
@@ -21,11 +21,17 @@ type OrbitViewsOptions = {
21
21
  } | undefined;
22
22
  /** The lens of every view but `play`. Default 40. */
23
23
  fov?: number | undefined;
24
+ /**
25
+ * Where `front` looks from: the direction from the object to a camera facing its front. Default
26
+ * `[1, 0.25, 0]`, for kits whose front is +x; `[0, 0.25, -1]` for one facing -z, like a vehicle
27
+ * driving down -z. `side` is a quarter turn round from it and `back` opposite, at its height.
28
+ */
29
+ front?: Vec3 | undefined;
24
30
  };
25
31
  /**
26
32
  * The views every studio wants around one object at `centre`: `play` (the game camera's exact
27
33
  * bearing, distance and lens, when given), `close` (that bearing, `near` away), and `front`,
28
- * `side`, `back` and `top` at `near`. Front looks down -x like most kits' fronts; rename to taste.
34
+ * `side`, `back` and `top` at `near`. Front looks down -x by default (`options.front` turns it).
29
35
  */
30
36
  export declare function orbitViews(centre: Vec3, near: number, options?: OrbitViewsOptions): StudioViews;
31
37
  export { type FrameStats, type OrbitViewsOptions, SHOT_PORT, type SaveShotOptions, type ShotSize, type StatsRenderer, type StudioArea, type StudioConfig, type StudioLink, type StudioState, type StudioVariant, type StudioView, type StudioViews, type Vec3, type ViewControls, canvasPng, formatStats, frameStats, installFrameShim, resolveStudio, saveShot, setWireframe, studioHref, studioTime, titleOf };
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  import { i as saveShot, n as canvasPng, r as installFrameShim, t as SHOT_PORT } from "./capture-D6phUGvg.js";
2
- import { a as studioTime, c as frameStats, i as studioHref, l as setWireframe, n as orbitViews, o as titleOf, r as resolveStudio, s as formatStats, t as applyView } from "./views-suF0RWQc.js";
2
+ import { a as studioTime, c as frameStats, i as studioHref, l as setWireframe, n as orbitViews, o as titleOf, r as resolveStudio, s as formatStats, t as applyView } from "./views-BH_N7aiA.js";
3
3
  export { SHOT_PORT, applyView, canvasPng, formatStats, frameStats, installFrameShim, orbitViews, resolveStudio, saveShot, setWireframe, studioHref, studioTime, titleOf };
@@ -1,4 +1,4 @@
1
- import { i as StudioState, m as StatsRenderer, y as SaveShotOptions } from "./state-BsQ-taE3.js";
1
+ import { i as StudioState, m as StatsRenderer, y as SaveShotOptions } from "./state-KovV7yXI.js";
2
2
  import { AuditHelpersOptions } from "@zkmake/three-audit";
3
3
  import { OrbitControls } from "three/addons/controls/OrbitControls.js";
4
4
  import { Camera, Object3D } from "three";
@@ -98,4 +98,4 @@ declare global {
98
98
  */
99
99
  declare function mountStudio(options: MountStudioOptions): StudioHandle;
100
100
  export { mountStudio as a, mountStudioOverlay as c, StudioRenderer as i, StudioGlobal as n, StudioOverlay as o, StudioHandle as r, StudioOverlayOptions as s, MountStudioOptions as t };
101
- //# sourceMappingURL=mount-DLIbCER1.d.ts.map
101
+ //# sourceMappingURL=mount-CeU8JUWr.d.ts.map
@@ -1,5 +1,5 @@
1
1
  import { i as saveShot, r as installFrameShim } from "./capture-D6phUGvg.js";
2
- import { c as frameStats, i as studioHref, l as setWireframe, o as titleOf, s as formatStats, t as applyView } from "./views-suF0RWQc.js";
2
+ import { c as frameStats, i as studioHref, l as setWireframe, o as titleOf, s as formatStats, t as applyView } from "./views-BH_N7aiA.js";
3
3
  import { installAuditHelpers } from "@zkmake/three-audit";
4
4
  import { OrbitControls } from "three/addons/controls/OrbitControls.js";
5
5
  // src/ui/styles.ts
@@ -185,7 +185,7 @@ function mountStudio(options) {
185
185
  canvas: renderer.domElement,
186
186
  render,
187
187
  stats,
188
- endpoint: options.shotEndpoint,
188
+ endpoint: options.shotEndpoint ?? (state.shots === null ? void 0 : `http://127.0.0.1:${state.shots}`),
189
189
  ...shotOptions
190
190
  });
191
191
  const overlayOptions = typeof options.overlay === "object" ? options.overlay : {};
@@ -239,4 +239,4 @@ function mountStudio(options) {
239
239
  }
240
240
  export { injectStyles as i, mountStudioOverlay as n, STUDIO_STYLES as r, mountStudio as t };
241
241
 
242
- //# sourceMappingURL=mount-D3fYtnHO.js.map
242
+ //# sourceMappingURL=mount-DrLKxP3Q.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"mount-D3fYtnHO.js","names":[],"sources":["../src/ui/styles.ts","../src/ui/overlay.ts","../src/ui/mount.ts"],"sourcesContent":["/** The overlay's stylesheet, injected once. Chips on a frosted pill, light or dark by the system. */\nexport const STUDIO_STYLES = /* css */ `\n.tks {\n --tks-bg: rgba(22, 24, 29, 0.78);\n --tks-fg: #e6e8eb;\n --tks-dim: #9aa0aa;\n --tks-on-bg: #f2f3f5;\n --tks-on-fg: #16181d;\n --tks-pick: #ffe27a;\n position: fixed;\n inset: 0;\n z-index: 2147483000;\n pointer-events: none;\n font: 12px/1.3 ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n color: var(--tks-fg);\n}\n@media (prefers-color-scheme: light) {\n .tks {\n --tks-bg: rgba(250, 250, 252, 0.86);\n --tks-fg: #1a1c21;\n --tks-dim: #6b7079;\n --tks-on-bg: #1a1c21;\n --tks-on-fg: #f6f7f9;\n --tks-pick: #8a6d00;\n }\n}\n.tks *, .tks *::before, .tks *::after { box-sizing: border-box; }\n.tks__col {\n position: absolute;\n display: flex;\n flex-direction: column;\n gap: 6px;\n max-width: calc(50vw - 18px);\n}\n.tks__col--start {\n top: calc(10px + env(safe-area-inset-top, 0px));\n left: calc(10px + env(safe-area-inset-left, 0px));\n align-items: flex-start;\n}\n.tks__col--end {\n top: calc(10px + env(safe-area-inset-top, 0px));\n right: calc(10px + env(safe-area-inset-right, 0px));\n align-items: flex-end;\n}\n.tks__row {\n display: flex;\n flex-wrap: wrap;\n gap: 4px;\n padding: 3px;\n border-radius: 9px;\n background: var(--tks-bg);\n backdrop-filter: blur(8px);\n pointer-events: auto;\n}\n.tks__col--end .tks__row { justify-content: flex-end; }\n.tks__chip {\n padding: 4px 8px;\n border-radius: 6px;\n color: inherit;\n text-decoration: none;\n white-space: nowrap;\n}\n.tks__chip:hover { background: rgba(127, 127, 127, 0.18); }\n.tks__chip:focus-visible { outline: 2px solid var(--tks-pick); outline-offset: 1px; }\n.tks__chip[aria-current=\"true\"] { background: var(--tks-on-bg); color: var(--tks-on-fg); }\n.tks__row--variants .tks__chip[aria-current=\"true\"] { box-shadow: inset 0 -2px 0 var(--tks-pick); }\n.tks__stats {\n padding: 5px 9px;\n border-radius: 9px;\n background: var(--tks-bg);\n backdrop-filter: blur(8px);\n font-variant-numeric: tabular-nums;\n pointer-events: auto;\n}\n.tks__stats:empty { display: none; }\n.tks__note { color: var(--tks-dim); }\n`;\n\nconst STYLE_ID = \"three-studio-styles\";\n\nexport const injectStyles = (target: Document = document) => {\n if (target.getElementById(STYLE_ID)) {\n return;\n }\n\n const style = target.createElement(\"style\");\n\n style.id = STYLE_ID;\n style.textContent = STUDIO_STYLES;\n target.head.append(style);\n};\n","import { type StudioState, studioHref, titleOf } from \"../core/state.ts\";\nimport { injectStyles } from \"./styles.ts\";\n\nexport type StudioOverlayOptions = {\n state: StudioState;\n /** Default `document.body`. */\n container?: HTMLElement | undefined;\n /**\n * Switches the view in place when a view chip is clicked (no reload). Without it the chips are\n * plain links. Area and variant chips always load their link, since they mount other things.\n */\n onView?: ((name: string) => void) | undefined;\n /** Default true. */\n injectStyles?: boolean | undefined;\n};\n\nexport type StudioOverlay = {\n element: HTMLElement;\n /** The readout line, e.g. `formatStats(frameStats(renderer))`. */\n setStats: (text: string) => void;\n /** Marks a view chip current, after a switch made from code. */\n setView: (name: string) => void;\n dispose: () => void;\n};\n\nconst chip = (text: string, href: string, current: boolean) => {\n const link = document.createElement(\"a\");\n\n link.className = \"tks__chip\";\n link.href = href;\n link.textContent = text;\n link.setAttribute(\"aria-current\", String(current));\n\n return link;\n};\n\nconst row = (label: string, kind: string, links: HTMLAnchorElement[]) => {\n const nav = document.createElement(\"nav\");\n\n nav.className = `tks__row tks__row--${kind}`;\n nav.setAttribute(\"aria-label\", label);\n nav.append(...links);\n\n return nav;\n};\n\n/**\n * The switchers over the canvas: areas and variants top left with the readout under them, views\n * top right. Rows with one entry are left out. Nothing shows under `?clean=1`. The overlay is DOM,\n * so `saveShot` never captures it.\n */\nexport function mountStudioOverlay(options: StudioOverlayOptions): StudioOverlay {\n const { state, onView } = options;\n const container = options.container ?? document.body;\n\n if (options.injectStyles !== false) {\n injectStyles(container.ownerDocument);\n }\n\n const element = document.createElement(\"div\");\n\n element.className = \"tks\";\n element.dataset.studio = state.tag;\n element.hidden = state.clean;\n\n const start = document.createElement(\"div\");\n const end = document.createElement(\"div\");\n const stats = document.createElement(\"div\");\n\n start.className = \"tks__col tks__col--start\";\n end.className = \"tks__col tks__col--end\";\n stats.className = \"tks__stats\";\n stats.setAttribute(\"aria-live\", \"off\");\n\n const areas = Object.entries(state.config.areas);\n const variants = Object.entries(state.area.variants);\n\n if (areas.length > 1) {\n start.append(\n row(\n \"Areas\",\n \"areas\",\n areas.map(([name, area]) =>\n chip(titleOf(name, area), studioHref(state, { area: name }), name === state.areaName),\n ),\n ),\n );\n }\n\n if (variants.length > 1) {\n start.append(\n row(\n \"Variants\",\n \"variants\",\n variants.map(([name, variant]) =>\n chip(\n titleOf(name, variant),\n studioHref(state, { variant: name }),\n name === state.variantName,\n ),\n ),\n ),\n );\n }\n\n start.append(stats);\n\n const viewLinks = Object.keys(state.views).map((name) => {\n const link = chip(name, studioHref(state, { view: name }), name === state.viewName);\n\n link.dataset.view = name;\n\n if (onView) {\n link.addEventListener(\"click\", (event) => {\n if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) {\n return;\n }\n\n event.preventDefault();\n onView(name);\n });\n }\n\n return link;\n });\n\n if (viewLinks.length > 1) {\n end.append(row(\"Views\", \"views\", viewLinks));\n }\n\n element.append(start, end);\n container.append(element);\n\n return {\n element,\n setStats: (text) => {\n if (stats.textContent !== text) {\n stats.textContent = text;\n }\n },\n setView: (name) => {\n for (const link of viewLinks) {\n link.setAttribute(\"aria-current\", String(link.dataset.view === name));\n }\n },\n dispose: () => element.remove(),\n };\n}\n","import { type AuditHelpersOptions, installAuditHelpers } from \"@zkmake/three-audit\";\nimport type { Camera, Object3D } from \"three\";\nimport { OrbitControls } from \"three/addons/controls/OrbitControls.js\";\n\nimport { installFrameShim, type SaveShotOptions, saveShot } from \"../core/capture.ts\";\nimport { formatStats, frameStats, setWireframe, type StatsRenderer } from \"../core/frame.ts\";\nimport type { StudioState } from \"../core/state.ts\";\nimport { applyView } from \"../core/views.ts\";\nimport { mountStudioOverlay, type StudioOverlay, type StudioOverlayOptions } from \"./overlay.ts\";\n\n/** What the studio needs of a renderer: `WebGLRenderer` and `WebGPURenderer` both fit. */\nexport type StudioRenderer = StatsRenderer & {\n domElement: HTMLCanvasElement;\n render: (scene: Object3D, camera: Camera) => unknown;\n};\n\nexport type MountStudioOptions = {\n state: StudioState;\n renderer: StudioRenderer;\n scene: Object3D;\n camera: Camera;\n /**\n * Draws one frame, for `saveShot`. Default `renderer.render(scene, camera)`; pass the\n * composer's render when the page has post-processing.\n */\n render?: (() => void) | undefined;\n /** OrbitControls on the canvas, starting at the view. Default true. */\n controls?: boolean | undefined;\n /**\n * three-audit's console helpers (`summary()`, `zfight()`, `census()`…) on the scene, with the\n * variant's group as the `studioObject`. Default true; options pass through.\n */\n audit?: boolean | Omit<AuditHelpersOptions, \"renderer\"> | undefined;\n /** The switchers and readout. Default true; options pass through. */\n overlay?: boolean | Omit<StudioOverlayOptions, \"state\" | \"onView\"> | undefined;\n /** `window.studio` and `window.saveShot`, for agents and the console. Default true. */\n globals?: boolean | undefined;\n /** Where `saveShot` posts. Default `http://127.0.0.1:8787` (`three-studio serve`). */\n shotEndpoint?: string | undefined;\n};\n\nexport type StudioHandle = {\n state: StudioState;\n camera: Camera;\n controls: OrbitControls | null;\n overlay: StudioOverlay | null;\n /** Moves the camera to a named view in place and puts it in the URL, so a reload keeps it. */\n setView: (name: string) => void;\n /** Draws a frame and saves it as a PNG at `path` (see `three-studio serve`). */\n saveShot: (path: string, options?: SaveShotOptions) => Promise<string>;\n /** The last frame's readout: `\"12 calls · 34,567 tris\"`. */\n stats: () => string;\n /** Call after each render: refreshes the readout and keeps `?wire=1` on new meshes. */\n update: () => void;\n dispose: () => void;\n};\n\n/** The object `window.studio` holds while a studio is mounted. */\nexport type StudioGlobal = Pick<\n StudioHandle,\n \"state\" | \"camera\" | \"setView\" | \"saveShot\" | \"stats\"\n> & {\n scene: Object3D;\n renderer: StudioRenderer;\n /** The view names `setView` takes. */\n views: string[];\n /** For a hidden browser pane: see `installFrameShim`. */\n installFrameShim: () => boolean;\n};\n\ndeclare global {\n interface Window {\n studio?: StudioGlobal;\n saveShot?: StudioHandle[\"saveShot\"];\n }\n}\n\n/**\n * Wires a page around one renderer: camera at the URL's view, orbit controls, three-audit's console\n * helpers, the overlay, `window.studio`, and `?wire=1`. The page builds the scene itself and tags\n * what's under study with `userData.studioObject = state.tag`.\n *\n * const state = resolveStudio(config, location.search);\n * const studio = mountStudio({ state, renderer, scene, camera });\n * renderer.setAnimationLoop(() => { renderer.render(scene, camera); studio.update(); });\n */\nexport function mountStudio(options: MountStudioOptions): StudioHandle {\n const { state, renderer, scene, camera } = options;\n const render = options.render ?? (() => renderer.render(scene, camera));\n const controls =\n options.controls === false ? null : new OrbitControls(camera, renderer.domElement);\n // The page's window; none when rendered under Node (tests, SSR), where only the camera moves.\n const page = typeof window === \"undefined\" ? null : window;\n\n applyView(camera, state.view, controls);\n\n const stats = () => formatStats(frameStats(renderer));\n\n const setView = (name: string) => {\n const view = state.views[name];\n\n if (!view) {\n throw new Error(\n `three-studio: no view \"${name}\"; this page has ${Object.keys(state.views).join(\", \")}`,\n );\n }\n\n applyView(camera, view, controls);\n overlay?.setView(name);\n\n if (page) {\n const url = new URL(page.location.href);\n\n url.searchParams.set(\"view\", name);\n page.history.replaceState(page.history.state, \"\", url);\n }\n };\n\n const shot = (path: string, shotOptions: SaveShotOptions = {}) =>\n saveShot(path, {\n canvas: renderer.domElement,\n render,\n stats,\n endpoint: options.shotEndpoint,\n ...shotOptions,\n });\n\n const overlayOptions = typeof options.overlay === \"object\" ? options.overlay : {};\n const overlay =\n options.overlay === false\n ? null\n : mountStudioOverlay({ ...overlayOptions, state, onView: setView });\n\n const auditOptions = typeof options.audit === \"object\" ? options.audit : {};\n const uninstallAudit =\n options.audit === false\n ? null\n : installAuditHelpers(scene, {\n ...auditOptions,\n renderer: renderer as unknown as AuditHelpersOptions[\"renderer\"],\n });\n\n const global: StudioGlobal = {\n state,\n scene,\n camera,\n renderer,\n views: Object.keys(state.views),\n setView,\n saveShot: shot,\n stats,\n installFrameShim,\n };\n\n if (page && options.globals !== false) {\n page.studio = global;\n page.saveShot = shot;\n }\n\n return {\n state,\n camera,\n controls,\n overlay,\n setView,\n saveShot: shot,\n stats,\n update: () => {\n overlay?.setStats(stats());\n\n if (state.wire) {\n setWireframe(scene, true);\n }\n },\n dispose: () => {\n controls?.dispose();\n overlay?.dispose();\n uninstallAudit?.();\n\n if (page?.studio === global) {\n delete page.studio;\n delete page.saveShot;\n }\n },\n };\n}\n"],"mappings":";;;;;;AACA,MAAa,gBAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6EvC,MAAM,WAAW;AAEjB,MAAa,gBAAgB,SAAmB,aAAa;CAC3D,IAAI,OAAO,eAAe,QAAQ,GAChC;CAGF,MAAM,QAAQ,OAAO,cAAc,OAAO;CAE1C,MAAM,KAAK;CACX,MAAM,cAAc;CACpB,OAAO,KAAK,OAAO,KAAK;AAC1B;;ACjEA,MAAM,QAAQ,MAAc,MAAc,YAAqB;CAC7D,MAAM,OAAO,SAAS,cAAc,GAAG;CAEvC,KAAK,YAAY;CACjB,KAAK,OAAO;CACZ,KAAK,cAAc;CACnB,KAAK,aAAa,gBAAgB,OAAO,OAAO,CAAC;CAEjD,OAAO;AACT;AAEA,MAAM,OAAO,OAAe,MAAc,UAA+B;CACvE,MAAM,MAAM,SAAS,cAAc,KAAK;CAExC,IAAI,YAAY,sBAAsB;CACtC,IAAI,aAAa,cAAc,KAAK;CACpC,IAAI,OAAO,GAAG,KAAK;CAEnB,OAAO;AACT;;;;;;AAOA,SAAgB,mBAAmB,SAA8C;CAC/E,MAAM,EAAE,OAAO,WAAW;CAC1B,MAAM,YAAY,QAAQ,aAAa,SAAS;CAEhD,IAAI,QAAQ,iBAAiB,OAC3B,aAAa,UAAU,aAAa;CAGtC,MAAM,UAAU,SAAS,cAAc,KAAK;CAE5C,QAAQ,YAAY;CACpB,QAAQ,QAAQ,SAAS,MAAM;CAC/B,QAAQ,SAAS,MAAM;CAEvB,MAAM,QAAQ,SAAS,cAAc,KAAK;CAC1C,MAAM,MAAM,SAAS,cAAc,KAAK;CACxC,MAAM,QAAQ,SAAS,cAAc,KAAK;CAE1C,MAAM,YAAY;CAClB,IAAI,YAAY;CAChB,MAAM,YAAY;CAClB,MAAM,aAAa,aAAa,KAAK;CAErC,MAAM,QAAQ,OAAO,QAAQ,MAAM,OAAO,KAAK;CAC/C,MAAM,WAAW,OAAO,QAAQ,MAAM,KAAK,QAAQ;CAEnD,IAAI,MAAM,SAAS,GACjB,MAAM,OACJ,IACE,SACA,SACA,MAAM,KAAK,CAAC,MAAM,UAChB,KAAK,QAAQ,MAAM,IAAI,GAAG,WAAW,OAAO,EAAE,MAAM,KAAK,CAAC,GAAG,SAAS,MAAM,QAAQ,CACtF,CACF,CACF;CAGF,IAAI,SAAS,SAAS,GACpB,MAAM,OACJ,IACE,YACA,YACA,SAAS,KAAK,CAAC,MAAM,aACnB,KACE,QAAQ,MAAM,OAAO,GACrB,WAAW,OAAO,EAAE,SAAS,KAAK,CAAC,GACnC,SAAS,MAAM,WACjB,CACF,CACF,CACF;CAGF,MAAM,OAAO,KAAK;CAElB,MAAM,YAAY,OAAO,KAAK,MAAM,KAAK,CAAC,CAAC,KAAK,SAAS;EACvD,MAAM,OAAO,KAAK,MAAM,WAAW,OAAO,EAAE,MAAM,KAAK,CAAC,GAAG,SAAS,MAAM,QAAQ;EAElF,KAAK,QAAQ,OAAO;EAEpB,IAAI,QACF,KAAK,iBAAiB,UAAU,UAAU;GACxC,IAAI,MAAM,WAAW,MAAM,WAAW,MAAM,YAAY,MAAM,WAAW,GACvE;GAGF,MAAM,eAAe;GACrB,OAAO,IAAI;EACb,CAAC;EAGH,OAAO;CACT,CAAC;CAED,IAAI,UAAU,SAAS,GACrB,IAAI,OAAO,IAAI,SAAS,SAAS,SAAS,CAAC;CAG7C,QAAQ,OAAO,OAAO,GAAG;CACzB,UAAU,OAAO,OAAO;CAExB,OAAO;EACL;EACA,WAAW,SAAS;GAClB,IAAI,MAAM,gBAAgB,MACxB,MAAM,cAAc;EAExB;EACA,UAAU,SAAS;GACjB,KAAK,MAAM,QAAQ,WACjB,KAAK,aAAa,gBAAgB,OAAO,KAAK,QAAQ,SAAS,IAAI,CAAC;EAExE;EACA,eAAe,QAAQ,OAAO;CAChC;AACF;;;;;;;;;;;AC7DA,SAAgB,YAAY,SAA2C;CACrE,MAAM,EAAE,OAAO,UAAU,OAAO,WAAW;CAC3C,MAAM,SAAS,QAAQ,iBAAiB,SAAS,OAAO,OAAO,MAAM;CACrE,MAAM,WACJ,QAAQ,aAAa,QAAQ,OAAO,IAAI,cAAc,QAAQ,SAAS,UAAU;CAEnF,MAAM,OAAO,OAAO,WAAW,cAAc,OAAO;CAEpD,UAAU,QAAQ,MAAM,MAAM,QAAQ;CAEtC,MAAM,cAAc,YAAY,WAAW,QAAQ,CAAC;CAEpD,MAAM,WAAW,SAAiB;EAChC,MAAM,OAAO,MAAM,MAAM;EAEzB,IAAI,CAAC,MACH,MAAM,IAAI,MACR,0BAA0B,KAAK,mBAAmB,OAAO,KAAK,MAAM,KAAK,CAAC,CAAC,KAAK,IAAI,GACtF;EAGF,UAAU,QAAQ,MAAM,QAAQ;EAChC,SAAS,QAAQ,IAAI;EAErB,IAAI,MAAM;GACR,MAAM,MAAM,IAAI,IAAI,KAAK,SAAS,IAAI;GAEtC,IAAI,aAAa,IAAI,QAAQ,IAAI;GACjC,KAAK,QAAQ,aAAa,KAAK,QAAQ,OAAO,IAAI,GAAG;EACvD;CACF;CAEA,MAAM,QAAQ,MAAc,cAA+B,CAAC,MAC1D,SAAS,MAAM;EACb,QAAQ,SAAS;EACjB;EACA;EACA,UAAU,QAAQ;EAClB,GAAG;CACL,CAAC;CAEH,MAAM,iBAAiB,OAAO,QAAQ,YAAY,WAAW,QAAQ,UAAU,CAAC;CAChF,MAAM,UACJ,QAAQ,YAAY,QAChB,OACA,mBAAmB;EAAE,GAAG;EAAgB;EAAO,QAAQ;CAAQ,CAAC;CAEtE,MAAM,eAAe,OAAO,QAAQ,UAAU,WAAW,QAAQ,QAAQ,CAAC;CAC1E,MAAM,iBACJ,QAAQ,UAAU,QACd,OACA,oBAAoB,OAAO;EACzB,GAAG;EACO;CACZ,CAAC;CAEP,MAAM,SAAuB;EAC3B;EACA;EACA;EACA;EACA,OAAO,OAAO,KAAK,MAAM,KAAK;EAC9B;EACA,UAAU;EACV;EACA;CACF;CAEA,IAAI,QAAQ,QAAQ,YAAY,OAAO;EACrC,KAAK,SAAS;EACd,KAAK,WAAW;CAClB;CAEA,OAAO;EACL;EACA;EACA;EACA;EACA;EACA,UAAU;EACV;EACA,cAAc;GACZ,SAAS,SAAS,MAAM,CAAC;GAEzB,IAAI,MAAM,MACR,aAAa,OAAO,IAAI;EAE5B;EACA,eAAe;GACb,UAAU,QAAQ;GAClB,SAAS,QAAQ;GACjB,iBAAiB;GAEjB,IAAI,MAAM,WAAW,QAAQ;IAC3B,OAAO,KAAK;IACZ,OAAO,KAAK;GACd;EACF;CACF;AACF"}
1
+ {"version":3,"file":"mount-DrLKxP3Q.js","names":[],"sources":["../src/ui/styles.ts","../src/ui/overlay.ts","../src/ui/mount.ts"],"sourcesContent":["/** The overlay's stylesheet, injected once. Chips on a frosted pill, light or dark by the system. */\nexport const STUDIO_STYLES = /* css */ `\n.tks {\n --tks-bg: rgba(22, 24, 29, 0.78);\n --tks-fg: #e6e8eb;\n --tks-dim: #9aa0aa;\n --tks-on-bg: #f2f3f5;\n --tks-on-fg: #16181d;\n --tks-pick: #ffe27a;\n position: fixed;\n inset: 0;\n z-index: 2147483000;\n pointer-events: none;\n font: 12px/1.3 ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n color: var(--tks-fg);\n}\n@media (prefers-color-scheme: light) {\n .tks {\n --tks-bg: rgba(250, 250, 252, 0.86);\n --tks-fg: #1a1c21;\n --tks-dim: #6b7079;\n --tks-on-bg: #1a1c21;\n --tks-on-fg: #f6f7f9;\n --tks-pick: #8a6d00;\n }\n}\n.tks *, .tks *::before, .tks *::after { box-sizing: border-box; }\n.tks__col {\n position: absolute;\n display: flex;\n flex-direction: column;\n gap: 6px;\n max-width: calc(50vw - 18px);\n}\n.tks__col--start {\n top: calc(10px + env(safe-area-inset-top, 0px));\n left: calc(10px + env(safe-area-inset-left, 0px));\n align-items: flex-start;\n}\n.tks__col--end {\n top: calc(10px + env(safe-area-inset-top, 0px));\n right: calc(10px + env(safe-area-inset-right, 0px));\n align-items: flex-end;\n}\n.tks__row {\n display: flex;\n flex-wrap: wrap;\n gap: 4px;\n padding: 3px;\n border-radius: 9px;\n background: var(--tks-bg);\n backdrop-filter: blur(8px);\n pointer-events: auto;\n}\n.tks__col--end .tks__row { justify-content: flex-end; }\n.tks__chip {\n padding: 4px 8px;\n border-radius: 6px;\n color: inherit;\n text-decoration: none;\n white-space: nowrap;\n}\n.tks__chip:hover { background: rgba(127, 127, 127, 0.18); }\n.tks__chip:focus-visible { outline: 2px solid var(--tks-pick); outline-offset: 1px; }\n.tks__chip[aria-current=\"true\"] { background: var(--tks-on-bg); color: var(--tks-on-fg); }\n.tks__row--variants .tks__chip[aria-current=\"true\"] { box-shadow: inset 0 -2px 0 var(--tks-pick); }\n.tks__stats {\n padding: 5px 9px;\n border-radius: 9px;\n background: var(--tks-bg);\n backdrop-filter: blur(8px);\n font-variant-numeric: tabular-nums;\n pointer-events: auto;\n}\n.tks__stats:empty { display: none; }\n.tks__note { color: var(--tks-dim); }\n`;\n\nconst STYLE_ID = \"three-studio-styles\";\n\nexport const injectStyles = (target: Document = document) => {\n if (target.getElementById(STYLE_ID)) {\n return;\n }\n\n const style = target.createElement(\"style\");\n\n style.id = STYLE_ID;\n style.textContent = STUDIO_STYLES;\n target.head.append(style);\n};\n","import { type StudioState, studioHref, titleOf } from \"../core/state.ts\";\nimport { injectStyles } from \"./styles.ts\";\n\nexport type StudioOverlayOptions = {\n state: StudioState;\n /** Default `document.body`. */\n container?: HTMLElement | undefined;\n /**\n * Switches the view in place when a view chip is clicked (no reload). Without it the chips are\n * plain links. Area and variant chips always load their link, since they mount other things.\n */\n onView?: ((name: string) => void) | undefined;\n /** Default true. */\n injectStyles?: boolean | undefined;\n};\n\nexport type StudioOverlay = {\n element: HTMLElement;\n /** The readout line, e.g. `formatStats(frameStats(renderer))`. */\n setStats: (text: string) => void;\n /** Marks a view chip current, after a switch made from code. */\n setView: (name: string) => void;\n dispose: () => void;\n};\n\nconst chip = (text: string, href: string, current: boolean) => {\n const link = document.createElement(\"a\");\n\n link.className = \"tks__chip\";\n link.href = href;\n link.textContent = text;\n link.setAttribute(\"aria-current\", String(current));\n\n return link;\n};\n\nconst row = (label: string, kind: string, links: HTMLAnchorElement[]) => {\n const nav = document.createElement(\"nav\");\n\n nav.className = `tks__row tks__row--${kind}`;\n nav.setAttribute(\"aria-label\", label);\n nav.append(...links);\n\n return nav;\n};\n\n/**\n * The switchers over the canvas: areas and variants top left with the readout under them, views\n * top right. Rows with one entry are left out. Nothing shows under `?clean=1`. The overlay is DOM,\n * so `saveShot` never captures it.\n */\nexport function mountStudioOverlay(options: StudioOverlayOptions): StudioOverlay {\n const { state, onView } = options;\n const container = options.container ?? document.body;\n\n if (options.injectStyles !== false) {\n injectStyles(container.ownerDocument);\n }\n\n const element = document.createElement(\"div\");\n\n element.className = \"tks\";\n element.dataset.studio = state.tag;\n element.hidden = state.clean;\n\n const start = document.createElement(\"div\");\n const end = document.createElement(\"div\");\n const stats = document.createElement(\"div\");\n\n start.className = \"tks__col tks__col--start\";\n end.className = \"tks__col tks__col--end\";\n stats.className = \"tks__stats\";\n stats.setAttribute(\"aria-live\", \"off\");\n\n const areas = Object.entries(state.config.areas);\n const variants = Object.entries(state.area.variants);\n\n if (areas.length > 1) {\n start.append(\n row(\n \"Areas\",\n \"areas\",\n areas.map(([name, area]) =>\n chip(titleOf(name, area), studioHref(state, { area: name }), name === state.areaName),\n ),\n ),\n );\n }\n\n if (variants.length > 1) {\n start.append(\n row(\n \"Variants\",\n \"variants\",\n variants.map(([name, variant]) =>\n chip(\n titleOf(name, variant),\n studioHref(state, { variant: name }),\n name === state.variantName,\n ),\n ),\n ),\n );\n }\n\n start.append(stats);\n\n const viewLinks = Object.keys(state.views).map((name) => {\n const link = chip(name, studioHref(state, { view: name }), name === state.viewName);\n\n link.dataset.view = name;\n\n if (onView) {\n link.addEventListener(\"click\", (event) => {\n if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) {\n return;\n }\n\n event.preventDefault();\n onView(name);\n });\n }\n\n return link;\n });\n\n if (viewLinks.length > 1) {\n end.append(row(\"Views\", \"views\", viewLinks));\n }\n\n element.append(start, end);\n container.append(element);\n\n return {\n element,\n setStats: (text) => {\n if (stats.textContent !== text) {\n stats.textContent = text;\n }\n },\n setView: (name) => {\n for (const link of viewLinks) {\n link.setAttribute(\"aria-current\", String(link.dataset.view === name));\n }\n },\n dispose: () => element.remove(),\n };\n}\n","import { type AuditHelpersOptions, installAuditHelpers } from \"@zkmake/three-audit\";\nimport type { Camera, Object3D } from \"three\";\nimport { OrbitControls } from \"three/addons/controls/OrbitControls.js\";\n\nimport { installFrameShim, type SaveShotOptions, saveShot } from \"../core/capture.ts\";\nimport { formatStats, frameStats, setWireframe, type StatsRenderer } from \"../core/frame.ts\";\nimport type { StudioState } from \"../core/state.ts\";\nimport { applyView } from \"../core/views.ts\";\nimport { mountStudioOverlay, type StudioOverlay, type StudioOverlayOptions } from \"./overlay.ts\";\n\n/** What the studio needs of a renderer: `WebGLRenderer` and `WebGPURenderer` both fit. */\nexport type StudioRenderer = StatsRenderer & {\n domElement: HTMLCanvasElement;\n render: (scene: Object3D, camera: Camera) => unknown;\n};\n\nexport type MountStudioOptions = {\n state: StudioState;\n renderer: StudioRenderer;\n scene: Object3D;\n camera: Camera;\n /**\n * Draws one frame, for `saveShot`. Default `renderer.render(scene, camera)`; pass the\n * composer's render when the page has post-processing.\n */\n render?: (() => void) | undefined;\n /** OrbitControls on the canvas, starting at the view. Default true. */\n controls?: boolean | undefined;\n /**\n * three-audit's console helpers (`summary()`, `zfight()`, `census()`…) on the scene, with the\n * variant's group as the `studioObject`. Default true; options pass through.\n */\n audit?: boolean | Omit<AuditHelpersOptions, \"renderer\"> | undefined;\n /** The switchers and readout. Default true; options pass through. */\n overlay?: boolean | Omit<StudioOverlayOptions, \"state\" | \"onView\"> | undefined;\n /** `window.studio` and `window.saveShot`, for agents and the console. Default true. */\n globals?: boolean | undefined;\n /** Where `saveShot` posts. Default `http://127.0.0.1:8787` (`three-studio serve`). */\n shotEndpoint?: string | undefined;\n};\n\nexport type StudioHandle = {\n state: StudioState;\n camera: Camera;\n controls: OrbitControls | null;\n overlay: StudioOverlay | null;\n /** Moves the camera to a named view in place and puts it in the URL, so a reload keeps it. */\n setView: (name: string) => void;\n /** Draws a frame and saves it as a PNG at `path` (see `three-studio serve`). */\n saveShot: (path: string, options?: SaveShotOptions) => Promise<string>;\n /** The last frame's readout: `\"12 calls · 34,567 tris\"`. */\n stats: () => string;\n /** Call after each render: refreshes the readout and keeps `?wire=1` on new meshes. */\n update: () => void;\n dispose: () => void;\n};\n\n/** The object `window.studio` holds while a studio is mounted. */\nexport type StudioGlobal = Pick<\n StudioHandle,\n \"state\" | \"camera\" | \"setView\" | \"saveShot\" | \"stats\"\n> & {\n scene: Object3D;\n renderer: StudioRenderer;\n /** The view names `setView` takes. */\n views: string[];\n /** For a hidden browser pane: see `installFrameShim`. */\n installFrameShim: () => boolean;\n};\n\ndeclare global {\n interface Window {\n studio?: StudioGlobal;\n saveShot?: StudioHandle[\"saveShot\"];\n }\n}\n\n/**\n * Wires a page around one renderer: camera at the URL's view, orbit controls, three-audit's console\n * helpers, the overlay, `window.studio`, and `?wire=1`. The page builds the scene itself and tags\n * what's under study with `userData.studioObject = state.tag`.\n *\n * const state = resolveStudio(config, location.search);\n * const studio = mountStudio({ state, renderer, scene, camera });\n * renderer.setAnimationLoop(() => { renderer.render(scene, camera); studio.update(); });\n */\nexport function mountStudio(options: MountStudioOptions): StudioHandle {\n const { state, renderer, scene, camera } = options;\n const render = options.render ?? (() => renderer.render(scene, camera));\n const controls =\n options.controls === false ? null : new OrbitControls(camera, renderer.domElement);\n // The page's window; none when rendered under Node (tests, SSR), where only the camera moves.\n const page = typeof window === \"undefined\" ? null : window;\n\n applyView(camera, state.view, controls);\n\n const stats = () => formatStats(frameStats(renderer));\n\n const setView = (name: string) => {\n const view = state.views[name];\n\n if (!view) {\n throw new Error(\n `three-studio: no view \"${name}\"; this page has ${Object.keys(state.views).join(\", \")}`,\n );\n }\n\n applyView(camera, view, controls);\n overlay?.setView(name);\n\n if (page) {\n const url = new URL(page.location.href);\n\n url.searchParams.set(\"view\", name);\n page.history.replaceState(page.history.state, \"\", url);\n }\n };\n\n const shot = (path: string, shotOptions: SaveShotOptions = {}) =>\n saveShot(path, {\n canvas: renderer.domElement,\n render,\n stats,\n endpoint:\n options.shotEndpoint ??\n (state.shots === null ? undefined : `http://127.0.0.1:${state.shots}`),\n ...shotOptions,\n });\n\n const overlayOptions = typeof options.overlay === \"object\" ? options.overlay : {};\n const overlay =\n options.overlay === false\n ? null\n : mountStudioOverlay({ ...overlayOptions, state, onView: setView });\n\n const auditOptions = typeof options.audit === \"object\" ? options.audit : {};\n const uninstallAudit =\n options.audit === false\n ? null\n : installAuditHelpers(scene, {\n ...auditOptions,\n renderer: renderer as unknown as AuditHelpersOptions[\"renderer\"],\n });\n\n const global: StudioGlobal = {\n state,\n scene,\n camera,\n renderer,\n views: Object.keys(state.views),\n setView,\n saveShot: shot,\n stats,\n installFrameShim,\n };\n\n if (page && options.globals !== false) {\n page.studio = global;\n page.saveShot = shot;\n }\n\n return {\n state,\n camera,\n controls,\n overlay,\n setView,\n saveShot: shot,\n stats,\n update: () => {\n overlay?.setStats(stats());\n\n if (state.wire) {\n setWireframe(scene, true);\n }\n },\n dispose: () => {\n controls?.dispose();\n overlay?.dispose();\n uninstallAudit?.();\n\n if (page?.studio === global) {\n delete page.studio;\n delete page.saveShot;\n }\n },\n };\n}\n"],"mappings":"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"}
package/dist/react.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { a as StudioVariant, i as StudioState, n as StudioConfig } from "./state-BsQ-taE3.js";
2
- import { r as StudioHandle, t as MountStudioOptions } from "./mount-DLIbCER1.js";
1
+ import { a as StudioVariant, i as StudioState, n as StudioConfig } from "./state-KovV7yXI.js";
2
+ import { r as StudioHandle, t as MountStudioOptions } from "./mount-CeU8JUWr.js";
3
3
  import { CanvasProps } from "@react-three/fiber";
4
- import { CSSProperties, ReactNode } from "react";
4
+ import { CSSProperties, ComponentType, ReactNode } from "react";
5
5
  /** A variant `<Studio>` can draw: what goes in the canvas, and optionally DOM over it. */
6
6
  export type StudioReactVariant = StudioVariant & {
7
7
  render: (state: StudioState) => ReactNode;
@@ -36,6 +36,12 @@ export type StudioProps<V extends StudioReactVariant> = RigOptions & {
36
36
  * merged over `{ preserveDrawingBuffer: true }`; `camera` objects over the view's lens.
37
37
  */
38
38
  canvas?: CanvasOptions | ((state: StudioState<V>) => CanvasOptions) | undefined;
39
+ /**
40
+ * The `<Canvas>` component to mount. Default `@react-three/fiber`'s. An app whose game draws
41
+ * through another passes that one, e.g. R3F 10's WebGL `Canvas` from `@react-three/fiber/legacy`
42
+ * (the default there is WebGPU, which GLSL material patches don't run on).
43
+ */
44
+ Canvas?: ComponentType<CanvasProps> | undefined;
39
45
  /** What every variant stands in: lights, ground, background. */
40
46
  stage?: ((state: StudioState<V>) => ReactNode) | undefined;
41
47
  onReady?: ((studio: StudioHandle) => void) | undefined;
package/dist/react.js CHANGED
@@ -1,5 +1,5 @@
1
- import { a as studioTime, r as resolveStudio } from "./views-suF0RWQc.js";
2
- import { t as mountStudio } from "./mount-D3fYtnHO.js";
1
+ import { a as studioTime, r as resolveStudio } from "./views-BH_N7aiA.js";
2
+ import { t as mountStudio } from "./mount-DrLKxP3Q.js";
3
3
  import { Canvas, useFrame, useThree } from "@react-three/fiber";
4
4
  import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef } from "react";
5
5
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -85,7 +85,7 @@ function StudioRig({ state, onReady, children, ...options }) {
85
85
  * createRoot(root).render(<Studio config={{ areas: { trees: { variants: TREES } } }} />);
86
86
  */
87
87
  function Studio(props) {
88
- const { config, search, stage, className, style, canvas, ...rig } = props;
88
+ const { config, search, stage, className, style, canvas, Canvas: Frame = Canvas, ...rig } = props;
89
89
  const state = useMemo(() => resolveStudio(config, search ?? globalThis.location?.search ?? ""), [config, search]);
90
90
  const own = typeof canvas === "function" ? canvas(state) : canvas ?? {};
91
91
  const { view } = state;
@@ -111,7 +111,7 @@ function Studio(props) {
111
111
  ...style
112
112
  },
113
113
  "data-studio": state.tag,
114
- children: [/* @__PURE__ */ jsx(Canvas, {
114
+ children: [/* @__PURE__ */ jsx(Frame, {
115
115
  ...own,
116
116
  gl,
117
117
  camera,
package/dist/react.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"react.js","names":[],"sources":["../src/react/index.tsx"],"sourcesContent":["/**\n * `@zkmake/three-studio/react`: `<Studio>` is a whole mockups or model-studio page for React Three\n * Fiber, from a config of areas and variants; `<StudioRig>` adds the same wiring to a `<Canvas>`\n * the page builds itself. Variants read the page state through `useStudio()`, and freeze their\n * animations on `?t=` through `useStudioTime()`.\n */\nimport { Canvas, type CanvasProps, useFrame, useThree } from \"@react-three/fiber\";\nimport {\n type CSSProperties,\n createContext,\n type ReactNode,\n useContext,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n} from \"react\";\n\nimport {\n resolveStudio,\n type StudioConfig,\n type StudioState,\n studioTime,\n type StudioVariant,\n} from \"../core/state.ts\";\nimport { mountStudio, type MountStudioOptions, type StudioHandle } from \"../ui/mount.ts\";\n\n/** A variant `<Studio>` can draw: what goes in the canvas, and optionally DOM over it. */\nexport type StudioReactVariant = StudioVariant & {\n render: (state: StudioState) => ReactNode;\n /** DOM over the canvas (a HUD mockup). Not in `saveShot`'s image, which is the canvas alone. */\n hud?: ((state: StudioState) => ReactNode) | undefined;\n};\n\ntype RigOptions = Pick<\n MountStudioOptions,\n \"controls\" | \"audit\" | \"overlay\" | \"globals\" | \"shotEndpoint\"\n>;\n\nconst StudioContext = createContext<StudioState | null>(null);\n\n/** The page state: area, variant, view and switches. Inside `<Studio>` or `<StudioRig>`. */\nexport function useStudio(): StudioState {\n const state = useContext(StudioContext);\n\n if (!state) {\n throw new Error(\"three-studio: useStudio() needs a <Studio> or <StudioRig> above it\");\n }\n\n return state;\n}\n\n/** Maps a clock time to the one to draw: frozen at `?t=` when set. */\nexport function useStudioTime(): (elapsed: number) => number {\n const state = useStudio();\n\n return (elapsed) => studioTime(state, elapsed);\n}\n\nexport type StudioRigProps = RigOptions & {\n state: StudioState;\n /** Called once mounted, with the handle (`setView`, `saveShot`, `stats`). */\n onReady?: ((studio: StudioHandle) => void) | undefined;\n children?: ReactNode;\n};\n\n/**\n * Inside a `<Canvas>`: puts the camera at the state's view, adds orbit controls, three-audit's\n * console helpers, the overlay with its readout, and `window.studio`. Options are read on mount.\n * `saveShot` draws through R3F's own loop, so post-processing that renders in `useFrame` is kept.\n */\nexport function StudioRig({ state, onReady, children, ...options }: StudioRigProps) {\n const gl = useThree((root) => root.gl);\n const scene = useThree((root) => root.scene);\n const camera = useThree((root) => root.camera);\n const advance = useThree((root) => root.advance);\n const invalidate = useThree((root) => root.invalidate);\n const latest = useRef({ options, onReady });\n const handle = useRef<StudioHandle | null>(null);\n\n useEffect(() => {\n latest.current = { options, onReady };\n });\n\n useLayoutEffect(() => {\n const studio = mountStudio({\n ...latest.current.options,\n state,\n renderer: gl,\n scene,\n camera,\n render: () => advance(performance.now(), true),\n });\n const redraw = () => invalidate();\n\n studio.controls?.addEventListener(\"change\", redraw);\n handle.current = studio;\n latest.current.onReady?.(studio);\n invalidate();\n\n return () => {\n studio.controls?.removeEventListener(\"change\", redraw);\n handle.current = null;\n studio.dispose();\n };\n }, [state, gl, scene, camera, advance, invalidate]);\n\n useFrame(() => handle.current?.update());\n\n return <StudioContext.Provider value={state}>{children}</StudioContext.Provider>;\n}\n\ntype CanvasOptions = Omit<CanvasProps, \"children\">;\n\nexport type StudioProps<V extends StudioReactVariant> = RigOptions & {\n config: StudioConfig<V>;\n /** The query to read. Default `window.location.search`. */\n search?: string | undefined;\n /**\n * The game's `<Canvas>` settings (tone mapping, `dpr`, `shadows`, fog in `scene`), so variants\n * look as they will in the game. A function gets the state, for per-area fog. `gl` objects are\n * merged over `{ preserveDrawingBuffer: true }`; `camera` objects over the view's lens.\n */\n canvas?: CanvasOptions | ((state: StudioState<V>) => CanvasOptions) | undefined;\n /** What every variant stands in: lights, ground, background. */\n stage?: ((state: StudioState<V>) => ReactNode) | undefined;\n onReady?: ((studio: StudioHandle) => void) | undefined;\n className?: string | undefined;\n style?: CSSProperties | undefined;\n};\n\n/**\n * A studio page: the canvas filling its parent, the variant inside a group tagged\n * `userData.studioObject = \"<area>-<variant>\"`, the stage around it, and the rig. Links reload the\n * page, so each variant mounts fresh.\n *\n * createRoot(root).render(<Studio config={{ areas: { trees: { variants: TREES } } }} />);\n */\nexport function Studio<V extends StudioReactVariant>(props: StudioProps<V>) {\n const { config, search, stage, className, style, canvas, ...rig } = props;\n const state = useMemo(\n () => resolveStudio(config, search ?? globalThis.location?.search ?? \"\"),\n [config, search],\n );\n const own = typeof canvas === \"function\" ? canvas(state) : (canvas ?? {});\n const { view } = state;\n const lens = {\n fov: view.fov ?? 40,\n near: view.near ?? 0.1,\n far: view.far ?? 2000,\n position: [...view.position] as [number, number, number],\n };\n const gl =\n own.gl === undefined || isPlainObject(own.gl)\n ? { preserveDrawingBuffer: true, ...(own.gl as object | undefined) }\n : own.gl;\n const camera =\n own.camera === undefined || isPlainObject(own.camera)\n ? { ...lens, ...(own.camera as object | undefined) }\n : own.camera;\n\n return (\n <div\n className={className}\n style={{ position: \"absolute\", inset: 0, ...style }}\n data-studio={state.tag}\n >\n <Canvas {...own} gl={gl as CanvasProps[\"gl\"]} camera={camera as CanvasProps[\"camera\"]}>\n <StudioRig state={state} {...rig}>\n {stage?.(state)}\n <group userData={{ studioObject: state.tag }}>{state.variant.render(state)}</group>\n </StudioRig>\n </Canvas>\n {state.variant.hud ? (\n <StudioContext.Provider value={state}>{state.variant.hud(state)}</StudioContext.Provider>\n ) : null}\n </div>\n );\n}\n\nconst isPlainObject = (value: unknown): value is object =>\n typeof value === \"object\" &&\n value !== null &&\n !Array.isArray(value) &&\n Object.getPrototypeOf(value) === Object.prototype;\n"],"mappings":"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"}
1
+ {"version":3,"file":"react.js","names":[],"sources":["../src/react/index.tsx"],"sourcesContent":["/**\n * `@zkmake/three-studio/react`: `<Studio>` is a whole mockups or model-studio page for React Three\n * Fiber, from a config of areas and variants; `<StudioRig>` adds the same wiring to a `<Canvas>`\n * the page builds itself. Variants read the page state through `useStudio()`, and freeze their\n * animations on `?t=` through `useStudioTime()`.\n */\nimport { Canvas, type CanvasProps, useFrame, useThree } from \"@react-three/fiber\";\nimport {\n type ComponentType,\n type CSSProperties,\n createContext,\n type ReactNode,\n useContext,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n} from \"react\";\n\nimport {\n resolveStudio,\n type StudioConfig,\n type StudioState,\n studioTime,\n type StudioVariant,\n} from \"../core/state.ts\";\nimport { mountStudio, type MountStudioOptions, type StudioHandle } from \"../ui/mount.ts\";\n\n/** A variant `<Studio>` can draw: what goes in the canvas, and optionally DOM over it. */\nexport type StudioReactVariant = StudioVariant & {\n render: (state: StudioState) => ReactNode;\n /** DOM over the canvas (a HUD mockup). Not in `saveShot`'s image, which is the canvas alone. */\n hud?: ((state: StudioState) => ReactNode) | undefined;\n};\n\ntype RigOptions = Pick<\n MountStudioOptions,\n \"controls\" | \"audit\" | \"overlay\" | \"globals\" | \"shotEndpoint\"\n>;\n\nconst StudioContext = createContext<StudioState | null>(null);\n\n/** The page state: area, variant, view and switches. Inside `<Studio>` or `<StudioRig>`. */\nexport function useStudio(): StudioState {\n const state = useContext(StudioContext);\n\n if (!state) {\n throw new Error(\"three-studio: useStudio() needs a <Studio> or <StudioRig> above it\");\n }\n\n return state;\n}\n\n/** Maps a clock time to the one to draw: frozen at `?t=` when set. */\nexport function useStudioTime(): (elapsed: number) => number {\n const state = useStudio();\n\n return (elapsed) => studioTime(state, elapsed);\n}\n\nexport type StudioRigProps = RigOptions & {\n state: StudioState;\n /** Called once mounted, with the handle (`setView`, `saveShot`, `stats`). */\n onReady?: ((studio: StudioHandle) => void) | undefined;\n children?: ReactNode;\n};\n\n/**\n * Inside a `<Canvas>`: puts the camera at the state's view, adds orbit controls, three-audit's\n * console helpers, the overlay with its readout, and `window.studio`. Options are read on mount.\n * `saveShot` draws through R3F's own loop, so post-processing that renders in `useFrame` is kept.\n */\nexport function StudioRig({ state, onReady, children, ...options }: StudioRigProps) {\n const gl = useThree((root) => root.gl);\n const scene = useThree((root) => root.scene);\n const camera = useThree((root) => root.camera);\n const advance = useThree((root) => root.advance);\n const invalidate = useThree((root) => root.invalidate);\n const latest = useRef({ options, onReady });\n const handle = useRef<StudioHandle | null>(null);\n\n useEffect(() => {\n latest.current = { options, onReady };\n });\n\n useLayoutEffect(() => {\n const studio = mountStudio({\n ...latest.current.options,\n state,\n renderer: gl,\n scene,\n camera,\n render: () => advance(performance.now(), true),\n });\n const redraw = () => invalidate();\n\n studio.controls?.addEventListener(\"change\", redraw);\n handle.current = studio;\n latest.current.onReady?.(studio);\n invalidate();\n\n return () => {\n studio.controls?.removeEventListener(\"change\", redraw);\n handle.current = null;\n studio.dispose();\n };\n }, [state, gl, scene, camera, advance, invalidate]);\n\n useFrame(() => handle.current?.update());\n\n return <StudioContext.Provider value={state}>{children}</StudioContext.Provider>;\n}\n\ntype CanvasOptions = Omit<CanvasProps, \"children\">;\n\nexport type StudioProps<V extends StudioReactVariant> = RigOptions & {\n config: StudioConfig<V>;\n /** The query to read. Default `window.location.search`. */\n search?: string | undefined;\n /**\n * The game's `<Canvas>` settings (tone mapping, `dpr`, `shadows`, fog in `scene`), so variants\n * look as they will in the game. A function gets the state, for per-area fog. `gl` objects are\n * merged over `{ preserveDrawingBuffer: true }`; `camera` objects over the view's lens.\n */\n canvas?: CanvasOptions | ((state: StudioState<V>) => CanvasOptions) | undefined;\n /**\n * The `<Canvas>` component to mount. Default `@react-three/fiber`'s. An app whose game draws\n * through another passes that one, e.g. R3F 10's WebGL `Canvas` from `@react-three/fiber/legacy`\n * (the default there is WebGPU, which GLSL material patches don't run on).\n */\n Canvas?: ComponentType<CanvasProps> | undefined;\n /** What every variant stands in: lights, ground, background. */\n stage?: ((state: StudioState<V>) => ReactNode) | undefined;\n onReady?: ((studio: StudioHandle) => void) | undefined;\n className?: string | undefined;\n style?: CSSProperties | undefined;\n};\n\n/**\n * A studio page: the canvas filling its parent, the variant inside a group tagged\n * `userData.studioObject = \"<area>-<variant>\"`, the stage around it, and the rig. Links reload the\n * page, so each variant mounts fresh.\n *\n * createRoot(root).render(<Studio config={{ areas: { trees: { variants: TREES } } }} />);\n */\nexport function Studio<V extends StudioReactVariant>(props: StudioProps<V>) {\n const { config, search, stage, className, style, canvas, Canvas: Frame = Canvas, ...rig } = props;\n const state = useMemo(\n () => resolveStudio(config, search ?? globalThis.location?.search ?? \"\"),\n [config, search],\n );\n const own = typeof canvas === \"function\" ? canvas(state) : (canvas ?? {});\n const { view } = state;\n const lens = {\n fov: view.fov ?? 40,\n near: view.near ?? 0.1,\n far: view.far ?? 2000,\n position: [...view.position] as [number, number, number],\n };\n const gl =\n own.gl === undefined || isPlainObject(own.gl)\n ? { preserveDrawingBuffer: true, ...(own.gl as object | undefined) }\n : own.gl;\n const camera =\n own.camera === undefined || isPlainObject(own.camera)\n ? { ...lens, ...(own.camera as object | undefined) }\n : own.camera;\n\n return (\n <div\n className={className}\n style={{ position: \"absolute\", inset: 0, ...style }}\n data-studio={state.tag}\n >\n <Frame {...own} gl={gl as CanvasProps[\"gl\"]} camera={camera as CanvasProps[\"camera\"]}>\n <StudioRig state={state} {...rig}>\n {stage?.(state)}\n <group userData={{ studioObject: state.tag }}>{state.variant.render(state)}</group>\n </StudioRig>\n </Frame>\n {state.variant.hud ? (\n <StudioContext.Provider value={state}>{state.variant.hud(state)}</StudioContext.Provider>\n ) : null}\n </div>\n );\n}\n\nconst isPlainObject = (value: unknown): value is object =>\n typeof value === \"object\" &&\n value !== null &&\n !Array.isArray(value) &&\n Object.getPrototypeOf(value) === Object.prototype;\n"],"mappings":"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"}
@@ -64,7 +64,7 @@ declare function setWireframe(root: Object3D, on: boolean): void;
64
64
  * the shot switches (`clean`, `wire`, `t`). Pure: the same config and query give the same state,
65
65
  * so a link is a reproducible shot.
66
66
  *
67
- * ?area=<area>&v=<variant>&view=<view>&clean=1&wire=1&t=<seconds>
67
+ * ?area=<area>&v=<variant>&view=<view>&clean=1&wire=1&t=<seconds>&shots=<port>
68
68
  *
69
69
  * A bare `?<area>` (`?plate`) also picks an area, the way single-object model studios link.
70
70
  */
@@ -117,6 +117,11 @@ type StudioState<V extends StudioVariant = StudioVariant> = {
117
117
  wire: boolean;
118
118
  /** `?t=<s>`: animations frozen at this time, for steady shots; null when running. */
119
119
  time: number | null;
120
+ /**
121
+ * `?shots=<port>`: the port `three-studio serve` listens on, when it isn't the default 8787
122
+ * (another receiver holds that one); `saveShot` posts there. Null for the default.
123
+ */
124
+ shots: number | null;
120
125
  /**
121
126
  * `<area>-<variant>`, for `userData.studioObject` on the variant's group, so three-audit's
122
127
  * `zfight()` and `summary()` audit only the thing under study.
@@ -137,7 +142,7 @@ type StudioLink = {
137
142
  };
138
143
  /**
139
144
  * The query for the state with `change` applied, `?` included. A new area starts at its first
140
- * variant; the view and the `wire` / `t` switches carry over, `clean` only when asked for.
145
+ * variant; the view, the `wire` / `t` switches and `shots` carry over, `clean` only when asked for.
141
146
  */
142
147
  declare function studioHref(state: StudioState, change?: StudioLink): string;
143
148
  /** The animation time to draw at: the frozen `?t=` when set, the clock's otherwise. */
@@ -148,4 +153,4 @@ declare const titleOf: (name: string, entry: {
148
153
  label?: string | undefined;
149
154
  }) => string;
150
155
  export { saveShot as C, installFrameShim as S, setWireframe as _, StudioVariant as a, ShotSize as b, Vec3 as c, studioTime as d, titleOf as f, frameStats as g, formatStats as h, StudioState as i, resolveStudio as l, StatsRenderer as m, StudioConfig as n, StudioView as o, FrameStats as p, StudioLink as r, StudioViews as s, StudioArea as t, studioHref as u, SHOT_PORT as v, canvasPng as x, SaveShotOptions as y };
151
- //# sourceMappingURL=state-BsQ-taE3.d.ts.map
156
+ //# sourceMappingURL=state-KovV7yXI.d.ts.map
package/dist/ui.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as mountStudio, c as mountStudioOverlay, i as StudioRenderer, n as StudioGlobal, o as StudioOverlay, r as StudioHandle, s as StudioOverlayOptions, t as MountStudioOptions } from "./mount-DLIbCER1.js";
1
+ import { a as mountStudio, c as mountStudioOverlay, i as StudioRenderer, n as StudioGlobal, o as StudioOverlay, r as StudioHandle, s as StudioOverlayOptions, t as MountStudioOptions } from "./mount-CeU8JUWr.js";
2
2
  /** The overlay's stylesheet, injected once. Chips on a frosted pill, light or dark by the system. */
3
3
  export declare const STUDIO_STYLES = "\n.tks {\n --tks-bg: rgba(22, 24, 29, 0.78);\n --tks-fg: #e6e8eb;\n --tks-dim: #9aa0aa;\n --tks-on-bg: #f2f3f5;\n --tks-on-fg: #16181d;\n --tks-pick: #ffe27a;\n position: fixed;\n inset: 0;\n z-index: 2147483000;\n pointer-events: none;\n font: 12px/1.3 ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n color: var(--tks-fg);\n}\n@media (prefers-color-scheme: light) {\n .tks {\n --tks-bg: rgba(250, 250, 252, 0.86);\n --tks-fg: #1a1c21;\n --tks-dim: #6b7079;\n --tks-on-bg: #1a1c21;\n --tks-on-fg: #f6f7f9;\n --tks-pick: #8a6d00;\n }\n}\n.tks *, .tks *::before, .tks *::after { box-sizing: border-box; }\n.tks__col {\n position: absolute;\n display: flex;\n flex-direction: column;\n gap: 6px;\n max-width: calc(50vw - 18px);\n}\n.tks__col--start {\n top: calc(10px + env(safe-area-inset-top, 0px));\n left: calc(10px + env(safe-area-inset-left, 0px));\n align-items: flex-start;\n}\n.tks__col--end {\n top: calc(10px + env(safe-area-inset-top, 0px));\n right: calc(10px + env(safe-area-inset-right, 0px));\n align-items: flex-end;\n}\n.tks__row {\n display: flex;\n flex-wrap: wrap;\n gap: 4px;\n padding: 3px;\n border-radius: 9px;\n background: var(--tks-bg);\n backdrop-filter: blur(8px);\n pointer-events: auto;\n}\n.tks__col--end .tks__row { justify-content: flex-end; }\n.tks__chip {\n padding: 4px 8px;\n border-radius: 6px;\n color: inherit;\n text-decoration: none;\n white-space: nowrap;\n}\n.tks__chip:hover { background: rgba(127, 127, 127, 0.18); }\n.tks__chip:focus-visible { outline: 2px solid var(--tks-pick); outline-offset: 1px; }\n.tks__chip[aria-current=\"true\"] { background: var(--tks-on-bg); color: var(--tks-on-fg); }\n.tks__row--variants .tks__chip[aria-current=\"true\"] { box-shadow: inset 0 -2px 0 var(--tks-pick); }\n.tks__stats {\n padding: 5px 9px;\n border-radius: 9px;\n background: var(--tks-bg);\n backdrop-filter: blur(8px);\n font-variant-numeric: tabular-nums;\n pointer-events: auto;\n}\n.tks__stats:empty { display: none; }\n.tks__note { color: var(--tks-dim); }\n";
4
4
  export declare const injectStyles: (target?: Document) => void;
package/dist/ui.js CHANGED
@@ -1 +1 @@
1
- export { r as STUDIO_STYLES, i as injectStyles, t as mountStudio, n as mountStudioOverlay } from "./mount-D3fYtnHO.js";
1
+ export { r as STUDIO_STYLES, i as injectStyles, t as mountStudio, n as mountStudioOverlay } from "./mount-DrLKxP3Q.js";
@@ -84,6 +84,10 @@ function resolveStudio(config, search) {
84
84
  if (askedView in views) viewName = askedView;
85
85
  else unknown.push(`view=${askedView}`);
86
86
  }
87
+ const shotsParam = params.get("shots");
88
+ const shots = shotsParam === null || shotsParam === "" ? null : Number(shotsParam);
89
+ const shotPort = shots !== null && Number.isInteger(shots) && shots > 0 && shots < 65536;
90
+ if (shots !== null && !shotPort) unknown.push(`shots=${shotsParam}`);
87
91
  const time = params.get("t");
88
92
  const seconds = time === null || time === "" ? null : Number(time);
89
93
  if (seconds !== null && !Number.isFinite(seconds)) unknown.push(`t=${time}`);
@@ -99,6 +103,7 @@ function resolveStudio(config, search) {
99
103
  clean: flag(params.get("clean")),
100
104
  wire: flag(params.get("wire")),
101
105
  time: seconds !== null && Number.isFinite(seconds) ? seconds : null,
106
+ shots: shotPort ? shots : null,
102
107
  tag: `${areaName}-${variantName}`,
103
108
  unknown
104
109
  };
@@ -106,7 +111,7 @@ function resolveStudio(config, search) {
106
111
  const flag = (value) => value !== null && value !== "0" && value !== "false";
107
112
  /**
108
113
  * The query for the state with `change` applied, `?` included. A new area starts at its first
109
- * variant; the view and the `wire` / `t` switches carry over, `clean` only when asked for.
114
+ * variant; the view, the `wire` / `t` switches and `shots` carry over, `clean` only when asked for.
110
115
  */
111
116
  function studioHref(state, change = {}) {
112
117
  const area = change.area ?? state.areaName;
@@ -119,6 +124,7 @@ function studioHref(state, change = {}) {
119
124
  if (change.clean) params.set("clean", "1");
120
125
  const time = change.time === void 0 ? state.time : change.time;
121
126
  if (time !== null) params.set("t", String(time));
127
+ if (state.shots !== null) params.set("shots", String(state.shots));
122
128
  return `?${params}`;
123
129
  }
124
130
  /** The animation time to draw at: the frozen `?t=` when set, the clock's otherwise. */
@@ -160,7 +166,7 @@ const along = (centre, direction, distance, fov) => {
160
166
  /**
161
167
  * The views every studio wants around one object at `centre`: `play` (the game camera's exact
162
168
  * bearing, distance and lens, when given), `close` (that bearing, `near` away), and `front`,
163
- * `side`, `back` and `top` at `near`. Front looks down -x like most kits' fronts; rename to taste.
169
+ * `side`, `back` and `top` at `near`. Front looks down -x by default (`options.front` turns it).
164
170
  */
165
171
  function orbitViews(centre, near, options = {}) {
166
172
  const fov = options.fov ?? 40;
@@ -184,20 +190,25 @@ function orbitViews(centre, near, options = {}) {
184
190
  };
185
191
  }
186
192
  views.close = along(centre, bearing, near, fov);
187
- views.front = along(centre, [
193
+ const [fx, fy, fz] = options.front ?? [
188
194
  1,
189
195
  .25,
190
196
  0
197
+ ];
198
+ views.front = along(centre, [
199
+ fx,
200
+ fy,
201
+ fz
191
202
  ], near, fov);
192
203
  views.side = along(centre, [
193
- 0,
194
- .25,
195
- 1
204
+ -fz,
205
+ fy,
206
+ fx
196
207
  ], near, fov);
197
208
  views.back = along(centre, [
198
- -1,
199
- .25,
200
- 0
209
+ -fx,
210
+ fy,
211
+ -fz
201
212
  ], near, fov);
202
213
  views.top = along(centre, [
203
214
  .001,
@@ -208,4 +219,4 @@ function orbitViews(centre, near, options = {}) {
208
219
  }
209
220
  export { studioTime as a, frameStats as c, studioHref as i, setWireframe as l, orbitViews as n, titleOf as o, resolveStudio as r, formatStats as s, applyView as t };
210
221
 
211
- //# sourceMappingURL=views-suF0RWQc.js.map
222
+ //# sourceMappingURL=views-BH_N7aiA.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"views-BH_N7aiA.js","names":[],"sources":["../src/core/frame.ts","../src/core/state.ts","../src/core/views.ts"],"sourcesContent":["import type { Material, Object3D } from \"three\";\n\n/** What the readout needs of a renderer: `WebGLRenderer` and `WebGPURenderer` both fit. */\nexport type StatsRenderer = { info: { render: { calls: number; triangles: number } } };\n\nexport type FrameStats = { calls: number; triangles: number };\n\n/**\n * The draws and triangles of the renderer's last `render()` call, shadow passes included. A\n * composer that renders several passes reports its last one only; three-audit's `ledger()` counts\n * every pass.\n */\nexport const frameStats = (renderer: StatsRenderer): FrameStats => ({\n calls: renderer.info.render.calls,\n triangles: renderer.info.render.triangles,\n});\n\n/** `\"12 calls · 34,567 tris\"`, the line every studio page shows. */\nexport const formatStats = ({ calls, triangles }: FrameStats): string =>\n `${calls} calls · ${triangles.toLocaleString(\"en-US\")} tris`;\n\nconst materialsOf = (object: Object3D): Material[] => {\n const material = (object as Object3D & { material?: Material | Material[] }).material;\n\n return Array.isArray(material) ? material : material ? [material] : [];\n};\n\n/**\n * Draws every material under `root` as wireframe, or puts back what each had. Cheap enough to\n * call each frame, so meshes that mount later are covered too.\n */\nexport function setWireframe(root: Object3D, on: boolean) {\n root.traverse((object) => {\n for (const material of materialsOf(object)) {\n const wire = material as Material & { wireframe?: boolean };\n\n if (!(\"wireframe\" in wire)) {\n continue;\n }\n\n const saved = material.userData.studioWireframe as boolean | undefined;\n\n if (on && saved === undefined) {\n material.userData.studioWireframe = Boolean(wire.wireframe);\n wire.wireframe = true;\n } else if (!on && saved !== undefined) {\n wire.wireframe = saved;\n delete material.userData.studioWireframe;\n }\n }\n });\n}\n","/**\n * The page's state, read from its URL: which area, which variant of it, which camera view, and\n * the shot switches (`clean`, `wire`, `t`). Pure: the same config and query give the same state,\n * so a link is a reproducible shot.\n *\n * ?area=<area>&v=<variant>&view=<view>&clean=1&wire=1&t=<seconds>&shots=<port>\n *\n * A bare `?<area>` (`?plate`) also picks an area, the way single-object model studios link.\n */\n\nexport type Vec3 = readonly [number, number, number];\n\n/** A camera pose. `fov`, `near` and `far` apply to a perspective camera, `zoom` to any. */\nexport type StudioView = {\n position: Vec3;\n target: Vec3;\n fov?: number | undefined;\n near?: number | undefined;\n far?: number | undefined;\n zoom?: number | undefined;\n};\n\nexport type StudioViews = Readonly<Record<string, StudioView>>;\n\n/** One look for one area. Adapters add what they render (`render`, `hud`); views it brings win. */\nexport type StudioVariant = {\n /** Its chip's text; the key otherwise. */\n label?: string | undefined;\n /** Views of its own (a camera variant's poses), over the area's and the page's. */\n views?: StudioViews | undefined;\n};\n\nexport type StudioArea<V extends StudioVariant = StudioVariant> = {\n /** Its chip's text; the key otherwise. */\n title?: string | undefined;\n variants: Readonly<Record<string, V>>;\n /** Views for this area, over the page's. */\n views?: StudioViews | undefined;\n};\n\nexport type StudioConfig<V extends StudioVariant = StudioVariant> = {\n /** In chip order; the first is the default. */\n areas: Readonly<Record<string, StudioArea<V>>>;\n /** Views every area has (`play`, copied from the game camera, belongs here). */\n views?: StudioViews | undefined;\n /** The view a link without `view=` opens, and the fallback for an unknown one. Default `play`. */\n defaultView?: string | undefined;\n};\n\nexport type StudioState<V extends StudioVariant = StudioVariant> = {\n config: StudioConfig<V>;\n areaName: string;\n area: StudioArea<V>;\n variantName: string;\n variant: V;\n viewName: string;\n view: StudioView;\n /** Every view this variant can take: the page's, the area's, then its own. */\n views: StudioViews;\n /** `?clean=1`: no overlay, for shots taken of the whole window. */\n clean: boolean;\n /** `?wire=1`: every material drawn as wireframe, to read the topology. */\n wire: boolean;\n /** `?t=<s>`: animations frozen at this time, for steady shots; null when running. */\n time: number | null;\n /**\n * `?shots=<port>`: the port `three-studio serve` listens on, when it isn't the default 8787\n * (another receiver holds that one); `saveShot` posts there. Null for the default.\n */\n shots: number | null;\n /**\n * `<area>-<variant>`, for `userData.studioObject` on the variant's group, so three-audit's\n * `zfight()` and `summary()` audit only the thing under study.\n */\n tag: string;\n /** Names asked for in the URL that the config doesn't have; the defaults were used instead. */\n unknown: string[];\n};\n\nconst FALLBACK_VIEW: StudioView = { position: [4, 3, 6], target: [0, 0.5, 0], fov: 40 };\n\nconst first = <T>(record: Readonly<Record<string, T>>): [string, T] | undefined =>\n Object.entries(record)[0];\n\n/** Reads a `location.search` (or any query string) against the config. */\nexport function resolveStudio<V extends StudioVariant>(\n config: StudioConfig<V>,\n search: string,\n): StudioState<V> {\n const params = new URLSearchParams(search);\n const unknown: string[] = [];\n const firstArea = first(config.areas);\n\n if (!firstArea) {\n throw new Error(\"three-studio: the config has no areas\");\n }\n\n const bare = [...params.keys()].find((key) => params.get(key) === \"\" && key in config.areas);\n const askedArea = params.get(\"area\") ?? bare ?? null;\n let [areaName, area] = firstArea;\n\n if (askedArea !== null) {\n const found = config.areas[askedArea];\n\n if (found) {\n [areaName, area] = [askedArea, found];\n } else {\n unknown.push(`area=${askedArea}`);\n }\n }\n\n const firstVariant = first(area.variants);\n\n if (!firstVariant) {\n throw new Error(`three-studio: area \"${areaName}\" has no variants`);\n }\n\n const askedVariant = params.get(\"v\");\n let [variantName, variant] = firstVariant;\n\n if (askedVariant !== null) {\n const found = area.variants[askedVariant];\n\n if (found) {\n [variantName, variant] = [askedVariant, found];\n } else {\n unknown.push(`v=${askedVariant}`);\n }\n }\n\n const views: StudioViews = { ...config.views, ...area.views, ...variant.views };\n const defaultView = config.defaultView ?? \"play\";\n const askedView = params.get(\"view\");\n const fallbackName = defaultView in views ? defaultView : (first(views)?.[0] ?? defaultView);\n let viewName = fallbackName;\n\n if (askedView !== null) {\n if (askedView in views) {\n viewName = askedView;\n } else {\n unknown.push(`view=${askedView}`);\n }\n }\n\n const shotsParam = params.get(\"shots\");\n const shots = shotsParam === null || shotsParam === \"\" ? null : Number(shotsParam);\n const shotPort = shots !== null && Number.isInteger(shots) && shots > 0 && shots < 65536;\n\n if (shots !== null && !shotPort) {\n unknown.push(`shots=${shotsParam}`);\n }\n\n const time = params.get(\"t\");\n const seconds = time === null || time === \"\" ? null : Number(time);\n\n if (seconds !== null && !Number.isFinite(seconds)) {\n unknown.push(`t=${time}`);\n }\n\n return {\n config,\n areaName,\n area,\n variantName,\n variant,\n viewName,\n view: views[viewName] ?? FALLBACK_VIEW,\n views,\n clean: flag(params.get(\"clean\")),\n wire: flag(params.get(\"wire\")),\n time: seconds !== null && Number.isFinite(seconds) ? seconds : null,\n shots: shotPort ? shots : null,\n tag: `${areaName}-${variantName}`,\n unknown,\n };\n}\n\nconst flag = (value: string | null) => value !== null && value !== \"0\" && value !== \"false\";\n\nexport type StudioLink = {\n area?: string;\n variant?: string;\n view?: string;\n clean?: boolean;\n wire?: boolean;\n time?: number | null;\n};\n\n/**\n * The query for the state with `change` applied, `?` included. A new area starts at its first\n * variant; the view, the `wire` / `t` switches and `shots` carry over, `clean` only when asked for.\n */\nexport function studioHref(state: StudioState, change: StudioLink = {}): string {\n const area = change.area ?? state.areaName;\n const sameArea = area === state.areaName;\n const variant = change.variant ?? (sameArea ? state.variantName : undefined);\n const params = new URLSearchParams({ area });\n\n if (variant !== undefined) {\n params.set(\"v\", variant);\n }\n\n params.set(\"view\", change.view ?? state.viewName);\n\n if (change.wire ?? state.wire) {\n params.set(\"wire\", \"1\");\n }\n\n if (change.clean) {\n params.set(\"clean\", \"1\");\n }\n\n const time = change.time === undefined ? state.time : change.time;\n\n if (time !== null) {\n params.set(\"t\", String(time));\n }\n\n if (state.shots !== null) {\n params.set(\"shots\", String(state.shots));\n }\n\n return `?${params}`;\n}\n\n/** The animation time to draw at: the frozen `?t=` when set, the clock's otherwise. */\nexport const studioTime = (state: Pick<StudioState, \"time\">, elapsed: number): number =>\n state.time ?? elapsed;\n\n/** An area's or variant's chip text. */\nexport const titleOf = (\n name: string,\n entry: { title?: string | undefined; label?: string | undefined },\n) => entry.title ?? entry.label ?? name;\n","import type { Camera, Vector3 } from \"three\";\n\nimport type { StudioView, StudioViews, Vec3 } from \"./state.ts\";\n\n/** What `applyView` moves alongside the camera: OrbitControls and the like. */\nexport type ViewControls = { target: Vector3; update: () => unknown };\n\n/**\n * Puts the camera at the view and aims it, lens included, and moves the controls' target with it\n * so an orbit starts from there.\n */\nexport function applyView(camera: Camera, view: StudioView, controls?: ViewControls | null) {\n camera.position.set(...view.position);\n\n const lens = camera as Camera & {\n isPerspectiveCamera?: boolean;\n fov?: number;\n near?: number;\n far?: number;\n zoom?: number;\n updateProjectionMatrix?: () => void;\n };\n\n if (lens.isPerspectiveCamera && view.fov !== undefined) {\n lens.fov = view.fov;\n }\n\n if (view.near !== undefined && lens.near !== undefined) {\n lens.near = view.near;\n }\n\n if (view.far !== undefined && lens.far !== undefined) {\n lens.far = view.far;\n }\n\n if (view.zoom !== undefined && lens.zoom !== undefined) {\n lens.zoom = view.zoom;\n }\n\n lens.updateProjectionMatrix?.();\n camera.lookAt(...view.target);\n camera.updateMatrixWorld();\n\n if (controls) {\n controls.target.set(...view.target);\n controls.update();\n }\n}\n\nexport type OrbitViewsOptions = {\n /**\n * The game camera: its offset from what it looks at, and its lens. Gives the `play` view, and\n * the bearing `close` looks along. Copy it from the game's camera code, never by eye.\n */\n play?: { offset: Vec3; fov: number } | undefined;\n /** The lens of every view but `play`. Default 40. */\n fov?: number | undefined;\n /**\n * Where `front` looks from: the direction from the object to a camera facing its front. Default\n * `[1, 0.25, 0]`, for kits whose front is +x; `[0, 0.25, -1]` for one facing -z, like a vehicle\n * driving down -z. `side` is a quarter turn round from it and `back` opposite, at its height.\n */\n front?: Vec3 | undefined;\n};\n\nconst along = (centre: Vec3, direction: Vec3, distance: number, fov: number): StudioView => {\n const length = Math.hypot(...direction) || 1;\n\n return {\n position: [\n centre[0] + (direction[0] / length) * distance,\n centre[1] + (direction[1] / length) * distance,\n centre[2] + (direction[2] / length) * distance,\n ],\n target: centre,\n fov,\n };\n};\n\n/**\n * The views every studio wants around one object at `centre`: `play` (the game camera's exact\n * bearing, distance and lens, when given), `close` (that bearing, `near` away), and `front`,\n * `side`, `back` and `top` at `near`. Front looks down -x by default (`options.front` turns it).\n */\nexport function orbitViews(\n centre: Vec3,\n near: number,\n options: OrbitViewsOptions = {},\n): StudioViews {\n const fov = options.fov ?? 40;\n const play = options.play;\n const bearing: Vec3 = play ? play.offset : [1, 0.6, 1.2];\n const views: Record<string, StudioView> = {};\n\n if (play) {\n const [x, y, z] = play.offset;\n\n views.play = {\n position: [centre[0] + x, centre[1] + y, centre[2] + z],\n target: centre,\n fov: play.fov,\n };\n }\n\n views.close = along(centre, bearing, near, fov);\n const [fx, fy, fz] = options.front ?? [1, 0.25, 0];\n\n views.front = along(centre, [fx, fy, fz], near, fov);\n views.side = along(centre, [-fz, fy, fx], near, fov);\n views.back = along(centre, [-fx, fy, -fz], near, fov);\n // Not straight down: lookAt from directly above spins on its up vector.\n views.top = along(centre, [0.001, 1, 0.001], near, fov);\n\n return views;\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zkmake/three-studio",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "A dev page for three.js scenes: areas, variants and camera views from the URL, an orbit rig, a draw-call readout, audit helpers and screenshots an agent can take, plus a CLI to save and diff them. Vanilla three and React Three Fiber.",
5
5
  "keywords": [
6
6
  "devtools",
@@ -23,7 +23,7 @@
23
23
  "directory": "packages/three-studio"
24
24
  },
25
25
  "bin": {
26
- "three-studio": "./dist/cli.js"
26
+ "three-studio": "dist/cli.js"
27
27
  },
28
28
  "files": [
29
29
  "dist",
@@ -51,7 +51,7 @@
51
51
  "typecheck": "tsc"
52
52
  },
53
53
  "dependencies": {
54
- "@zkmake/three-audit": "^0.3.2"
54
+ "@zkmake/three-audit": "^0.5.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@arethetypeswrong/core": "catalog:",
@@ -59,9 +59,11 @@
59
59
  "@react-three/test-renderer": "catalog:",
60
60
  "@types/node": "catalog:",
61
61
  "@types/react": "catalog:",
62
+ "@types/react-dom": "catalog:",
62
63
  "@types/three": "catalog:",
63
64
  "publint": "catalog:",
64
65
  "react": "catalog:",
66
+ "react-dom": "catalog:",
65
67
  "three": "catalog:",
66
68
  "tsdown": "catalog:",
67
69
  "typescript": "catalog:",
@@ -1 +0,0 @@
1
- {"version":3,"file":"views-suF0RWQc.js","names":[],"sources":["../src/core/frame.ts","../src/core/state.ts","../src/core/views.ts"],"sourcesContent":["import type { Material, Object3D } from \"three\";\n\n/** What the readout needs of a renderer: `WebGLRenderer` and `WebGPURenderer` both fit. */\nexport type StatsRenderer = { info: { render: { calls: number; triangles: number } } };\n\nexport type FrameStats = { calls: number; triangles: number };\n\n/**\n * The draws and triangles of the renderer's last `render()` call, shadow passes included. A\n * composer that renders several passes reports its last one only; three-audit's `ledger()` counts\n * every pass.\n */\nexport const frameStats = (renderer: StatsRenderer): FrameStats => ({\n calls: renderer.info.render.calls,\n triangles: renderer.info.render.triangles,\n});\n\n/** `\"12 calls · 34,567 tris\"`, the line every studio page shows. */\nexport const formatStats = ({ calls, triangles }: FrameStats): string =>\n `${calls} calls · ${triangles.toLocaleString(\"en-US\")} tris`;\n\nconst materialsOf = (object: Object3D): Material[] => {\n const material = (object as Object3D & { material?: Material | Material[] }).material;\n\n return Array.isArray(material) ? material : material ? [material] : [];\n};\n\n/**\n * Draws every material under `root` as wireframe, or puts back what each had. Cheap enough to\n * call each frame, so meshes that mount later are covered too.\n */\nexport function setWireframe(root: Object3D, on: boolean) {\n root.traverse((object) => {\n for (const material of materialsOf(object)) {\n const wire = material as Material & { wireframe?: boolean };\n\n if (!(\"wireframe\" in wire)) {\n continue;\n }\n\n const saved = material.userData.studioWireframe as boolean | undefined;\n\n if (on && saved === undefined) {\n material.userData.studioWireframe = Boolean(wire.wireframe);\n wire.wireframe = true;\n } else if (!on && saved !== undefined) {\n wire.wireframe = saved;\n delete material.userData.studioWireframe;\n }\n }\n });\n}\n","/**\n * The page's state, read from its URL: which area, which variant of it, which camera view, and\n * the shot switches (`clean`, `wire`, `t`). Pure: the same config and query give the same state,\n * so a link is a reproducible shot.\n *\n * ?area=<area>&v=<variant>&view=<view>&clean=1&wire=1&t=<seconds>\n *\n * A bare `?<area>` (`?plate`) also picks an area, the way single-object model studios link.\n */\n\nexport type Vec3 = readonly [number, number, number];\n\n/** A camera pose. `fov`, `near` and `far` apply to a perspective camera, `zoom` to any. */\nexport type StudioView = {\n position: Vec3;\n target: Vec3;\n fov?: number | undefined;\n near?: number | undefined;\n far?: number | undefined;\n zoom?: number | undefined;\n};\n\nexport type StudioViews = Readonly<Record<string, StudioView>>;\n\n/** One look for one area. Adapters add what they render (`render`, `hud`); views it brings win. */\nexport type StudioVariant = {\n /** Its chip's text; the key otherwise. */\n label?: string | undefined;\n /** Views of its own (a camera variant's poses), over the area's and the page's. */\n views?: StudioViews | undefined;\n};\n\nexport type StudioArea<V extends StudioVariant = StudioVariant> = {\n /** Its chip's text; the key otherwise. */\n title?: string | undefined;\n variants: Readonly<Record<string, V>>;\n /** Views for this area, over the page's. */\n views?: StudioViews | undefined;\n};\n\nexport type StudioConfig<V extends StudioVariant = StudioVariant> = {\n /** In chip order; the first is the default. */\n areas: Readonly<Record<string, StudioArea<V>>>;\n /** Views every area has (`play`, copied from the game camera, belongs here). */\n views?: StudioViews | undefined;\n /** The view a link without `view=` opens, and the fallback for an unknown one. Default `play`. */\n defaultView?: string | undefined;\n};\n\nexport type StudioState<V extends StudioVariant = StudioVariant> = {\n config: StudioConfig<V>;\n areaName: string;\n area: StudioArea<V>;\n variantName: string;\n variant: V;\n viewName: string;\n view: StudioView;\n /** Every view this variant can take: the page's, the area's, then its own. */\n views: StudioViews;\n /** `?clean=1`: no overlay, for shots taken of the whole window. */\n clean: boolean;\n /** `?wire=1`: every material drawn as wireframe, to read the topology. */\n wire: boolean;\n /** `?t=<s>`: animations frozen at this time, for steady shots; null when running. */\n time: number | null;\n /**\n * `<area>-<variant>`, for `userData.studioObject` on the variant's group, so three-audit's\n * `zfight()` and `summary()` audit only the thing under study.\n */\n tag: string;\n /** Names asked for in the URL that the config doesn't have; the defaults were used instead. */\n unknown: string[];\n};\n\nconst FALLBACK_VIEW: StudioView = { position: [4, 3, 6], target: [0, 0.5, 0], fov: 40 };\n\nconst first = <T>(record: Readonly<Record<string, T>>): [string, T] | undefined =>\n Object.entries(record)[0];\n\n/** Reads a `location.search` (or any query string) against the config. */\nexport function resolveStudio<V extends StudioVariant>(\n config: StudioConfig<V>,\n search: string,\n): StudioState<V> {\n const params = new URLSearchParams(search);\n const unknown: string[] = [];\n const firstArea = first(config.areas);\n\n if (!firstArea) {\n throw new Error(\"three-studio: the config has no areas\");\n }\n\n const bare = [...params.keys()].find((key) => params.get(key) === \"\" && key in config.areas);\n const askedArea = params.get(\"area\") ?? bare ?? null;\n let [areaName, area] = firstArea;\n\n if (askedArea !== null) {\n const found = config.areas[askedArea];\n\n if (found) {\n [areaName, area] = [askedArea, found];\n } else {\n unknown.push(`area=${askedArea}`);\n }\n }\n\n const firstVariant = first(area.variants);\n\n if (!firstVariant) {\n throw new Error(`three-studio: area \"${areaName}\" has no variants`);\n }\n\n const askedVariant = params.get(\"v\");\n let [variantName, variant] = firstVariant;\n\n if (askedVariant !== null) {\n const found = area.variants[askedVariant];\n\n if (found) {\n [variantName, variant] = [askedVariant, found];\n } else {\n unknown.push(`v=${askedVariant}`);\n }\n }\n\n const views: StudioViews = { ...config.views, ...area.views, ...variant.views };\n const defaultView = config.defaultView ?? \"play\";\n const askedView = params.get(\"view\");\n const fallbackName = defaultView in views ? defaultView : (first(views)?.[0] ?? defaultView);\n let viewName = fallbackName;\n\n if (askedView !== null) {\n if (askedView in views) {\n viewName = askedView;\n } else {\n unknown.push(`view=${askedView}`);\n }\n }\n\n const time = params.get(\"t\");\n const seconds = time === null || time === \"\" ? null : Number(time);\n\n if (seconds !== null && !Number.isFinite(seconds)) {\n unknown.push(`t=${time}`);\n }\n\n return {\n config,\n areaName,\n area,\n variantName,\n variant,\n viewName,\n view: views[viewName] ?? FALLBACK_VIEW,\n views,\n clean: flag(params.get(\"clean\")),\n wire: flag(params.get(\"wire\")),\n time: seconds !== null && Number.isFinite(seconds) ? seconds : null,\n tag: `${areaName}-${variantName}`,\n unknown,\n };\n}\n\nconst flag = (value: string | null) => value !== null && value !== \"0\" && value !== \"false\";\n\nexport type StudioLink = {\n area?: string;\n variant?: string;\n view?: string;\n clean?: boolean;\n wire?: boolean;\n time?: number | null;\n};\n\n/**\n * The query for the state with `change` applied, `?` included. A new area starts at its first\n * variant; the view and the `wire` / `t` switches carry over, `clean` only when asked for.\n */\nexport function studioHref(state: StudioState, change: StudioLink = {}): string {\n const area = change.area ?? state.areaName;\n const sameArea = area === state.areaName;\n const variant = change.variant ?? (sameArea ? state.variantName : undefined);\n const params = new URLSearchParams({ area });\n\n if (variant !== undefined) {\n params.set(\"v\", variant);\n }\n\n params.set(\"view\", change.view ?? state.viewName);\n\n if (change.wire ?? state.wire) {\n params.set(\"wire\", \"1\");\n }\n\n if (change.clean) {\n params.set(\"clean\", \"1\");\n }\n\n const time = change.time === undefined ? state.time : change.time;\n\n if (time !== null) {\n params.set(\"t\", String(time));\n }\n\n return `?${params}`;\n}\n\n/** The animation time to draw at: the frozen `?t=` when set, the clock's otherwise. */\nexport const studioTime = (state: Pick<StudioState, \"time\">, elapsed: number): number =>\n state.time ?? elapsed;\n\n/** An area's or variant's chip text. */\nexport const titleOf = (\n name: string,\n entry: { title?: string | undefined; label?: string | undefined },\n) => entry.title ?? entry.label ?? name;\n","import type { Camera, Vector3 } from \"three\";\n\nimport type { StudioView, StudioViews, Vec3 } from \"./state.ts\";\n\n/** What `applyView` moves alongside the camera: OrbitControls and the like. */\nexport type ViewControls = { target: Vector3; update: () => unknown };\n\n/**\n * Puts the camera at the view and aims it, lens included, and moves the controls' target with it\n * so an orbit starts from there.\n */\nexport function applyView(camera: Camera, view: StudioView, controls?: ViewControls | null) {\n camera.position.set(...view.position);\n\n const lens = camera as Camera & {\n isPerspectiveCamera?: boolean;\n fov?: number;\n near?: number;\n far?: number;\n zoom?: number;\n updateProjectionMatrix?: () => void;\n };\n\n if (lens.isPerspectiveCamera && view.fov !== undefined) {\n lens.fov = view.fov;\n }\n\n if (view.near !== undefined && lens.near !== undefined) {\n lens.near = view.near;\n }\n\n if (view.far !== undefined && lens.far !== undefined) {\n lens.far = view.far;\n }\n\n if (view.zoom !== undefined && lens.zoom !== undefined) {\n lens.zoom = view.zoom;\n }\n\n lens.updateProjectionMatrix?.();\n camera.lookAt(...view.target);\n camera.updateMatrixWorld();\n\n if (controls) {\n controls.target.set(...view.target);\n controls.update();\n }\n}\n\nexport type OrbitViewsOptions = {\n /**\n * The game camera: its offset from what it looks at, and its lens. Gives the `play` view, and\n * the bearing `close` looks along. Copy it from the game's camera code, never by eye.\n */\n play?: { offset: Vec3; fov: number } | undefined;\n /** The lens of every view but `play`. Default 40. */\n fov?: number | undefined;\n};\n\nconst along = (centre: Vec3, direction: Vec3, distance: number, fov: number): StudioView => {\n const length = Math.hypot(...direction) || 1;\n\n return {\n position: [\n centre[0] + (direction[0] / length) * distance,\n centre[1] + (direction[1] / length) * distance,\n centre[2] + (direction[2] / length) * distance,\n ],\n target: centre,\n fov,\n };\n};\n\n/**\n * The views every studio wants around one object at `centre`: `play` (the game camera's exact\n * bearing, distance and lens, when given), `close` (that bearing, `near` away), and `front`,\n * `side`, `back` and `top` at `near`. Front looks down -x like most kits' fronts; rename to taste.\n */\nexport function orbitViews(\n centre: Vec3,\n near: number,\n options: OrbitViewsOptions = {},\n): StudioViews {\n const fov = options.fov ?? 40;\n const play = options.play;\n const bearing: Vec3 = play ? play.offset : [1, 0.6, 1.2];\n const views: Record<string, StudioView> = {};\n\n if (play) {\n const [x, y, z] = play.offset;\n\n views.play = {\n position: [centre[0] + x, centre[1] + y, centre[2] + z],\n target: centre,\n fov: play.fov,\n };\n }\n\n views.close = along(centre, bearing, near, fov);\n views.front = along(centre, [1, 0.25, 0], near, fov);\n views.side = along(centre, [0, 0.25, 1], near, fov);\n views.back = along(centre, [-1, 0.25, 0], near, fov);\n // Not straight down: lookAt from directly above spins on its up vector.\n views.top = along(centre, [0.001, 1, 0.001], near, fov);\n\n return views;\n}\n"],"mappings":"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"}