@homie-rocks/studio 0.21.0 → 0.22.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +55 -0
  3. package/assets/assets.ts +500 -0
  4. package/assets/render-page.ts +629 -0
  5. package/assets/safety.mjs +194 -0
  6. package/bin/homie-studio.mjs +47 -1
  7. package/lib/art-cli.mjs +296 -0
  8. package/lib/art-tools.mjs +331 -0
  9. package/lib/asset-check.mjs +189 -0
  10. package/lib/asset-import.mjs +136 -0
  11. package/lib/asset-manifest.mjs +318 -0
  12. package/lib/build.mjs +25 -2
  13. package/lib/codex.mjs +43 -1
  14. package/lib/colour.mjs +160 -0
  15. package/lib/decisions.mjs +800 -0
  16. package/lib/directory.mjs +9 -1
  17. package/lib/library.mjs +169 -0
  18. package/lib/mcp-tools.mjs +22 -4
  19. package/lib/mcp.mjs +6 -3
  20. package/lib/optimise.mjs +380 -0
  21. package/lib/remix-assets.mjs +84 -0
  22. package/lib/render3d.mjs +81 -0
  23. package/lib/scaffold.mjs +26 -0
  24. package/lib/site.mjs +6 -1
  25. package/lib/studio.mjs +136 -9
  26. package/lib/style-board.mjs +227 -0
  27. package/lib/style-presets.mjs +121 -0
  28. package/lib/template-history.json +38 -0
  29. package/mcp/ui/card.css +15 -0
  30. package/mcp/ui/cast.js +62 -0
  31. package/mcp/ui/decision.js +61 -0
  32. package/mcp/ui/lineup.js +68 -0
  33. package/mcp/ui/rights.js +30 -0
  34. package/mcp/ui/style.js +154 -0
  35. package/package.json +14 -2
  36. package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
  37. package/starters/gem-rush-3d/assets/manifest.json +2318 -0
  38. package/starters/gem-rush-3d/credits.json +31 -0
  39. package/starters/gem-rush-3d/game.json +11 -0
  40. package/starters/gem-rush-3d/index.html +20 -0
  41. package/starters/gem-rush-3d/lab.json +19 -0
  42. package/starters/gem-rush-3d/src/main.ts +2086 -0
  43. package/starters/gem-rush-3d/style.json +66 -0
  44. package/starters/gem-rush-3d/tunables.json +13 -0
  45. package/worker/index.mjs +5 -3
  46. package/worker/pages.mjs +13 -5
  47. package/worker/qr.mjs +11 -0
  48. package/worker/site.mjs +5 -3
  49. package/worker/version.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -17,9 +17,47 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.22.0] - 2026-10-02
21
+
22
+ **Plugin 0.23.0** · [#32](https://github.com/homie-rocks/homie/pull/32)
23
+
24
+ Your game's look as decisions you can steer and lock, a style board the engine draws for free, free CC0 3D models, props made on your own fal account, and every asset licensed and checked for phones.
25
+
26
+ ### Added
27
+
28
+ - `homie-studio style`: your game's look as about thirty decisions (render style, palette, shape, proportions, materials, light, camera, fonts, effects; the cast, its library family, scale and phone budgets; rigs and animation for later). `style init <game> --prompt "…"` picks each from your words, the codex and the genre, with a one-line why, and the Game Codex's Art direction tab draws them. `style steer` nudges one in your words ("warmer", "closer", "golden hour"), `style lock` freezes one for you, and the first model built on an automatic decision pins it.
29
+ - `homie-studio style board <game>`: three coherent directions, each drawn by the game's own engine (its palette, light, camera, material model, fonts and proportions, with the starter library's pieces re-tinted into its palette). Free, in about 20 seconds. Pick one, mix rows from several, steer, lock. A painted mood image per direction is optional and paid, on your own fal account, and labelled a target.
30
+ - Changing a locked decision shows its blast radius first (`style blast`): every model made under it that would go stale, what remaking each would cost, and what a free palette re-tint fixes. Nothing is ever remade by itself.
31
+ - `homie-studio assets`: every model, texture and sky a game ships is recorded in `games/<id>/assets/manifest.json` with where it came from, each step that made it, its licence and what a remix gets. `assets rights` writes `RIGHTS.md` in plain words, credits go on the game's landing, and `assets remove` takes a model out with the copy the game ships.
32
+ - Homie's starter library: free CC0 models, materials and skies from Kenney, KayKit, Poly Haven and ambientCG, made phone-sized, with thumbnails and their licences. `assets find "pine tree"` searches it and `assets add <game> <item>` copies one into the game (never hot-linked). It is served from homie.rocks; `HOMIE_LIBRARY` points at another copy.
33
+ - `assets add <game> --file <model> --license <kind>`: your own models, checked first (a file that loads anything from an address, or is too big, is refused), made phone-sized (meshopt geometry, WebP pictures, the pivot at the bottom centre, scaled to metres) and recorded. The raw file stays in `art/<slug>/raw/`, which git ignores, and `assets redo <game> <asset>` makes it again from there for free. Unless the game's render style is `pbr`, models are made non-metal: metal with nothing to reflect draws black on a phone.
34
+ - `assets check <game>`: every model against phone budgets (triangles, draw calls, picture memory, the first-play download), the Khronos glTF-Validator, its licence, and whether a decision it was made under has changed. `assets lineup <game>` draws them side by side at true scale on a 1 m grid, with their silhouettes and how far their colours drift from the palette. The first-play download counts one file per sound (a browser fetches the .ogg or its .wav fallback, never both).
35
+ - `@homie-rocks/studio/assets`: one model loader for three.js games. It refuses unsafe or oversized files before three.js reads them, decodes meshopt, clones skinned models properly, and says in development when a model is over its budget. `stylize` draws a model in the game's material model and ink outline from `style.json`, the way the style board drew it.
36
+ - A starter in 3D: `game new <id> --from gem-rush-3d` is Gem Rush's rules drawn with three.js and dressed with models from the starter library. `game new` fetches them, checked by SHA-256 (the repository holds no model file), and a game you already planned keeps its decisions, its `style.json` and its own models. Its light, ground and materials follow the style board: faceted low-poly ground and grass in the palette's greens, every model drawn in the style's materials and outline, the gem in the palette's gold, the animals' colours pulled toward the palette, and the sun's real shadows on a computer (soft discs on a phone). The camera looks past the clearing into the woods; a phone held upright follows its player from closer and lower, so the action fills it. The game opens on a short title card with its name, and its HUD, results and name labels share one paper-and-ink look that reads in any palette. The meadow is a clearing with a campfire at its heart and a few solid rocks, bushes and a tree players move round; game.json `"scoring": "together"` turns the ranking into one total the room fills, for a cozy game. `assets lineup` draws each model at the height and in the colours the game gives it.
37
+ - A remix now arrives with its models: the original site serves `/games/<id>/assets.json`, and `game remix` fetches every model its licence lets a remix carry, checked by SHA-256, with a grey placeholder of the same size for the rest. It adds three.js when the game needs it, and no other package by itself.
38
+ - Homie for Claude Desktop: the tools `style_explore`, `style_board`, `decision_set`, `assets_plan`, `assets_find`, `asset_add`, `asset_make`, `asset_check`, `asset_lineup` and `asset_rights`, with style board, look decision, cast, lineup and rights cards.
39
+ - Plugin: the `style` skill (decisions, the board, locks and the blast radius) and the `models` skill (the starter library, your own models, and generated props: a concept in the locked style, then Tripo P1 image-to-3D, on your own fal account under a budget with a receipt per call, about US$0.54 a prop). The plan asks whether you want to steer the look and what art budget you have.
40
+ - Plugin: the Studio mod's Art tab and its commands `/look`, `/lock`, `/assets`, `/lineup` and `/rights`; it refuses a hand edit to a locked decision, a deploy that ships an unlicensed model, and a commit of a big binary under `games/`, and holds a paid prop past the budget.
41
+ - `games/<id>/style.json`: the palette, fonts, light and camera a game draws with. Its landing takes its accents from it, and the video skill's title cards (`card --game <id>`) wear its palette and font.
42
+
43
+ ### Changed
44
+
45
+ - A game with an art direction gets its play page's buttons in its own paper and ink (from `style.json`), so they match its HUD.
46
+ - Gather games pick a low three-quarter camera: down among the players, the woods beyond the clearing in view.
47
+ - Local development never shows a phone an address it cannot open: the TV view drops its join card, the landing drops its code, and the room button's sheet says to deploy to share (every starter: the page is the studio's).
48
+ - `publish` refuses to list a studio while a public game ships a model with no licence record, or one whose licence forbids it.
49
+ - `check` refuses a game with a binary over 5 MB committed under `games/` (it belongs in R2).
50
+ - The remix source leaves out each game's `codex/` folder (its decisions and style board), like `CODEX.md`.
51
+ - `@homie-rocks/studio` now depends on three.js, @gltf-transform, meshoptimizer, sharp and the Khronos glTF-Validator (about 60 MB more in a studio's `node_modules`).
52
+
53
+ ### Upgrade notes
54
+
55
+ - `homie-studio upgrade --apply` adds the art section to AGENTS.md and `.gitignore` lines for raw 3D files (`art/**/raw/`, `*.blend`, `*.fbx`). Commit no raw model file: move any already committed into `art/<slug>/raw/`.
56
+ - The starter library is served from homie.rocks once it is online; until then `assets find` says so, and `HOMIE_LIBRARY` can point at a local copy.
57
+
20
58
  ## [0.21.0] - 2026-10-02
21
59
 
22
- **Plugin 0.22.0** · [#31](https://github.com/homie-rocks/homie/pull/31)
60
+ **Plugin 0.22.0** · [#31](https://github.com/homie-rocks/homie/pull/31) · [release-2026-10-02-studio-0.21.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-02-studio-0.21.0)
23
61
 
24
62
  The Homie mod for Claude Code: your studio, its rooms and a real seat in a live game beside the chat, with a look
25
63
  before protected files, deploys and spending, and keys kept out of what Claude reads.
package/README.md CHANGED
@@ -17,6 +17,9 @@ npx homie-studio dev # the whole site locally
17
17
  npx homie-studio check crown-thief --url http://127.0.0.1:8787
18
18
  npx homie-studio perf crown-thief --url http://127.0.0.1:8787 # how fast it runs: a computer and an emulated phone, host and replica
19
19
  npx homie-studio lab crown-thief # the Game Lab: how a move feels, New beside Today, frame by frame
20
+ npx homie-studio style board crown-thief # its look as decisions: three directions drawn by the engine (free)
21
+ npx homie-studio assets find "pine tree" # free CC0 models from Homie's starter library, phone-sized
22
+ npx homie-studio assets check crown-thief # every model against phone budgets, the validator and its licence
20
23
  npx homie-studio deploy --plan # what deploy will create, and what it costs; changes nothing
21
24
  npx homie-studio deploy # the studio's own Cloudflare
22
25
  npx homie-studio publish # the homie.rocks directory
@@ -491,6 +494,58 @@ replay check), REPORT.md (New against Today), sheet.png (the lab at each phase's
491
494
  The local MCP's `game_lab` starts the lab and answers with a card. The Homie plugin's `lab` skill has the method:
492
495
  instrument and commit first (Today is the game as it is), then change how it feels, and keep what the person likes.
493
496
 
497
+ ## Art direction and models (`homie-studio style`, `homie-studio assets`)
498
+
499
+ ```sh
500
+ npx homie-studio style init crown-thief --prompt "a cozy low-poly forest where foxes gather berries"
501
+ npx homie-studio style crown-thief # every decision: · auto ~ steered ● pinned by use ■ locked
502
+ npx homie-studio style board crown-thief # three directions, drawn by the game engine: codex/board/*.jpg
503
+ npx homie-studio style pick crown-thief b --mix style.camera=a
504
+ npx homie-studio style steer crown-thief style.palette "warmer"
505
+ npx homie-studio style lock crown-thief style.palette --words "keep that palette"
506
+ npx homie-studio style blast crown-thief style.palette # what a change would make stale, and what remaking costs
507
+ npx homie-studio assets find "fox" --kind creature # the starter library (HOMIE_LIBRARY: another copy)
508
+ npx homie-studio assets add crown-thief kenney-cube-pets/animal-fox --height 0.7 --card "Characters/Fox"
509
+ npx homie-studio assets add crown-thief --file ./lantern.glb --license own --height 0.6
510
+ npx homie-studio assets check crown-thief # budgets, the glTF-Validator, licences, staleness, big files
511
+ npx homie-studio assets lineup crown-thief # true scale, silhouettes, palette drift: .studio/art/<id>/
512
+ npx homie-studio assets rights crown-thief # games/<id>/assets/RIGHTS.md and the credits
513
+ npx homie-studio assets redo crown-thief lantern # made again from its kept raw file, free
514
+ npx homie-studio assets remove crown-thief lantern # the record and the copy the game ships
515
+ ```
516
+
517
+ A game's look is a set of decisions (`games/<id>/codex/decisions.json`, private like the codex, drawn in its Art
518
+ direction tab): render style, palette, shape, proportions, materials, light, camera, fonts and effects; the cast, its
519
+ library family, scale and phone budgets; rigs and animation (later phases). Each starts as an automatic pick with a
520
+ why. A person's nudge makes it steered, their lock freezes it, and the first asset built on an automatic one pins it.
521
+ Every asset records the revision of each decision it was made under, so changing one lists exactly what went stale and
522
+ what remaking it would cost; nothing is remade by itself. `games/<id>/style.json` (public) is the palette, fonts, light
523
+ and camera the game, its landing and its title cards draw with.
524
+
525
+ Every model, texture and sky a game ships has an entry in `games/<id>/assets/manifest.json`: its route (procedural,
526
+ library, generated, imported), each step that made it, its licence and what a remix gets, its measurements. From it:
527
+ `RIGHTS.md`, the credits on the game's landing, `assets check`, the licence check `publish` makes, and
528
+ `/games/<id>/assets.json` on the site, from which `game remix` fetches every model its licence lets a remix carry
529
+ (checked by SHA-256; a grey placeholder of the same size for the rest). Models are refused when they load anything
530
+ from an address or are over the size caps; `assets add` makes them phone-sized (meshopt geometry, WebP pictures, the
531
+ pivot at the bottom centre, scaled to metres) with @gltf-transform, meshoptimizer and sharp; raw files stay in
532
+ `art/<slug>/raw/`, which git ignores.
533
+
534
+ In a three.js game, `@homie-rocks/studio/assets` is the one loader:
535
+
536
+ ```ts
537
+ import { createModels } from '@homie-rocks/studio/assets';
538
+ const models = createModels();
539
+ const fox = await models.instance('./models/fox.glb').catch(() => models.placeholder({ x: 0.6, y: 0.7, z: 0.9 }));
540
+ ```
541
+
542
+ It checks every file before three.js parses it (no external URIs, no buffer over the cap, only extensions three.js
543
+ reads), decodes meshopt, clones skinned models properly, and says in development when a model is over its budget
544
+ (`stats()`, `window.__homieModels`). `game new <id> --from gem-rush-3d` is Gem Rush in 3D, dressed from the library.
545
+ The local MCP's `style_explore`, `decision_set`, `assets_plan`, `assets_find`, `asset_add`, `asset_make`,
546
+ `asset_check`, `asset_lineup` and `asset_rights` do the same with cards. Generated props (on the person's own fal
547
+ account, under a budget, receipted) are the Homie plugin's `models` skill.
548
+
494
549
  ## Checks on a computer without a GPU
495
550
 
496
551
  `check`, `port check` and `look` run Chrome headless. On a Mac they use its GPU. On Linux (a Claude Code cloud
@@ -0,0 +1,500 @@
1
+ /*
2
+ * @homie-rocks/studio/assets — ONE MODEL LOADER FOR EVERY three.js GAME A STUDIO MAKES.
3
+ *
4
+ * import { createModels } from '@homie-rocks/studio/assets';
5
+ * const models = createModels(); // one per game
6
+ * const fox = await models.instance('./models/fox.glb'); // a copy to place (skinned models too)
7
+ * scene.add(fox);
8
+ *
9
+ * Every model is a self-contained glTF binary (.glb) from the game's own files (games/<id>/public/models/), made
10
+ * phone-sized by `homie-studio assets add` or `assets optimise`: meshopt geometry and WebP pictures, decoded here.
11
+ * Before three.js parses a byte, the file is checked (assets/safety.mjs, the same rules `assets check` and
12
+ * `assets add` use): a file over the size cap, a buffer or picture loaded from an address (an external URI), an
13
+ * accessor reaching past its buffer or an extension three.js cannot read is REFUSED. A refused or missing model
14
+ * rejects with a plain-words Error; `models.placeholder()` is a grey box of the size it would have been, so a round
15
+ * never waits on a model.
16
+ *
17
+ * Models load from the game's own origin only (relative addresses): a game never depends on another site at
18
+ * runtime (homie.rocks's starter library is copied into the game, never hot-linked). `allowOrigins` opens one on
19
+ * purpose.
20
+ *
21
+ * In development (localhost, or ?debug=1) a model over its budget says so in the console, and `stats()` (also
22
+ * `window.__homieModels`) has the totals the perf skill reads: models, triangles, texture memory and refusals.
23
+ *
24
+ * KTX2 pictures (KHR_texture_basisu) need a transcoder (about 580 KB): pass your own KTX2Loader as `ktx2` when a
25
+ * game ships them (`assets check` says when); WebP, the default, needs nothing.
26
+ *
27
+ * Things a game repeats (trees, gems, a fence) are instanced, one draw call per mesh however many there are:
28
+ *
29
+ * const copies = instancedCopies((await models.load('./models/tree.glb')).scene, 40);
30
+ * copies.forEach((c) => { placeCopy(c, 0, where); c.mesh.count = 1; scene.add(c.mesh); });
31
+ *
32
+ * Never bake a mesh's own transform into its geometry for that (geometry.applyMatrix4): an optimised model's positions
33
+ * are meshopt-quantized int16, dequantized by its node's scale, and a baked scale clips everything past one metre.
34
+ *
35
+ * A flat-coloured model (a library item with `paletteSwap`: its colours in one tiny palette picture) is repainted
36
+ * into a game's own palette with `repaint(model, pick)`, once, on the loaded model, before it is copied.
37
+ *
38
+ * `stylize(model, style)` draws it the way the game's art direction says (style.json: `materials.model` toon, flat,
39
+ * hand-painted or pbr, and `materials.outline`), the same material model and ink line the style board was drawn with.
40
+ * Once, on the loaded model, after `repaint` and before it is copied: its outline hulls are instanced with it.
41
+ */
42
+ import { BackSide, Box3, BoxGeometry, BufferGeometry, Float32BufferAttribute, CanvasTexture, Color, DataTexture, InstancedMesh, Matrix4, Mesh, MeshBasicMaterial, MeshLambertMaterial, MeshStandardMaterial, MeshToonMaterial, NearestFilter, RedFormat, SRGBColorSpace, Vector3, type AnimationClip, type Group, type Material, type Object3D, type Texture } from 'three';
43
+ import { GLTFLoader, type GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js';
44
+ import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';
45
+ import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js';
46
+ import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
47
+ import { LIMITS, checkGlb, refusal } from './safety.mjs';
48
+
49
+ export { LIMITS, checkGlb } from './safety.mjs';
50
+
51
+ /** The size caps a file is checked against (assets/safety.mjs LIMITS.game unless a game narrows or widens them). */
52
+ export interface ModelLimits { bytes: number; json: number; buffer: number; dataUri: number; vertices: number; meshes: number; nodes: number; materials: number; images: number; textures: number; animations: number; skins: number; accessors: number }
53
+
54
+ /** What one model may cost on a phone (a prop 1,500 triangles and 512 px; a hero 8,000 and 1,024 px). */
55
+ export interface ModelBudget { triangles?: number; texturePx?: number; materials?: number; bytes?: number }
56
+
57
+ export interface LoadedModel {
58
+ url: string;
59
+ /** The model as loaded: never add this one to a scene twice; `instance()` gives copies. */
60
+ scene: Group;
61
+ animations: AnimationClip[];
62
+ bytes: number;
63
+ triangles: number;
64
+ textures: number;
65
+ /** GPU memory of its pictures, RGBA with mipmaps (what `gpuSize` in gltf-transform's inspect says). */
66
+ textureBytes: number;
67
+ warnings: string[];
68
+ }
69
+
70
+ export interface ModelsOptions {
71
+ /** Narrow or widen the file checks (LIMITS.game by default). */
72
+ limits?: Partial<ModelLimits>;
73
+ /** A per-model budget for the development warnings (the prop tier by default). */
74
+ budget?: ModelBudget;
75
+ /** A KTX2Loader the game set up (transcoder path, renderer support): only for models with KTX2 pictures. */
76
+ ktx2?: { load?: unknown } | null;
77
+ /** Origins a game deliberately loads models from, besides its own. */
78
+ allowOrigins?: string[];
79
+ /** Where development warnings go (console.warn by default; outside development, nowhere). */
80
+ onWarn?: (text: string) => void;
81
+ /** Say budget warnings even outside development. */
82
+ dev?: boolean;
83
+ }
84
+
85
+ export interface ModelStats { models: number; triangles: number; textureBytes: number; bytes: number; refused: { url: string; why: string }[] }
86
+
87
+ export interface Models {
88
+ /** Load (once per address) and check one model. Rejects with a plain-words Error when it is refused or missing. */
89
+ load(url: string, opts?: { limits?: Partial<ModelLimits>; budget?: ModelBudget }): Promise<LoadedModel>;
90
+ /** A copy of a loaded model to place in the scene (skeletons cloned properly). */
91
+ instance(url: string, opts?: { limits?: Partial<ModelLimits>; budget?: ModelBudget }): Promise<Object3D>;
92
+ /** A grey box (metres), for a model that was refused, is missing or is still loading. */
93
+ placeholder(size?: { x: number; y: number; z: number }, colour?: number): Mesh;
94
+ /** Totals for the perf probe and the console. */
95
+ stats(): ModelStats;
96
+ /** Free every geometry, material and picture this loader made. */
97
+ dispose(): void;
98
+ }
99
+
100
+ const PROP_BUDGET: Required<ModelBudget> = { triangles: 1500, texturePx: 512, materials: 1, bytes: 300 * 1024 };
101
+ const devHost = (): boolean => {
102
+ try { return /^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname) || new URLSearchParams(location.search).get('debug') === '1'; } catch { return false; }
103
+ };
104
+
105
+ /** Where an address points: relative ones resolve against the page; another origin is refused unless allowed. */
106
+ function resolveUrl(url: string, allow: string[]): string {
107
+ const base = typeof location !== 'undefined' ? location.href : 'http://localhost/';
108
+ const u = new URL(url, base);
109
+ const own = typeof location !== 'undefined' ? location.origin : u.origin;
110
+ if (u.origin !== own && !allow.includes(u.origin)) throw new Error(`refused ${url}: a model from another site (${u.origin}); copy it into the game's own files (homie-studio assets add)`);
111
+ if (!/^https?:$/.test(u.protocol) && u.protocol !== 'blob:') throw new Error(`refused ${url}: not a web address`);
112
+ return u.href;
113
+ }
114
+
115
+ /** The file's bytes, refusing early when it says (or turns out) to be over the cap. */
116
+ async function fetchCapped(url: string, cap: number): Promise<Uint8Array> {
117
+ const res = await fetch(url, { credentials: 'same-origin' });
118
+ if (!res.ok) throw new Error(`${url} answered ${res.status}`);
119
+ const said = Number(res.headers.get('content-length') ?? NaN);
120
+ // A served file may be compressed on the wire: the header is only trusted when the body is not.
121
+ if (Number.isFinite(said) && !res.headers.get('content-encoding') && said > cap) throw new Error(`refused ${url}: ${Math.ceil(said / 1024)} KB, over ${Math.ceil(cap / 1024)} KB`);
122
+ if (!res.body) { const b = new Uint8Array(await res.arrayBuffer()); if (b.byteLength > cap) throw new Error(`refused ${url}: over ${Math.ceil(cap / 1024)} KB`); return b; }
123
+ const reader = res.body.getReader();
124
+ const parts: Uint8Array[] = [];
125
+ let total = 0;
126
+ for (;;) {
127
+ const { done, value } = await reader.read();
128
+ if (done) break;
129
+ total += value.byteLength;
130
+ if (total > cap) { void reader.cancel(); throw new Error(`refused ${url}: over ${Math.ceil(cap / 1024)} KB`); }
131
+ parts.push(value);
132
+ }
133
+ const out = new Uint8Array(total);
134
+ let at = 0;
135
+ for (const p of parts) { out.set(p, at); at += p.byteLength; }
136
+ return out;
137
+ }
138
+
139
+ /** GPU bytes of one picture: RGBA8 with mipmaps (x 4/3), or the compressed size a KTX2 upload names. */
140
+ function textureBytes(t: Texture): number {
141
+ const img = t.image as { width?: number; height?: number } | undefined;
142
+ const w = Number(img?.width ?? 0); const h = Number(img?.height ?? 0);
143
+ return Math.round(w * h * 4 * (4 / 3));
144
+ }
145
+
146
+ /** Instanced copies of one mesh of a model, and the mesh's own place in the model (`base`). */
147
+ export interface Copies { mesh: InstancedMesh; base: Matrix4 }
148
+
149
+ /**
150
+ * A loaded model (`(await models.load(url)).scene`, or a placeholder) as instanced copies: one InstancedMesh per mesh
151
+ * in it, `count` copies at most, sharing the file's geometry and materials. Place copy `i` with placeCopy (where it
152
+ * goes, times the mesh's own place in the model), then set `mesh.count`.
153
+ */
154
+ export function instancedCopies(model: Object3D, count: number): Copies[] {
155
+ model.updateMatrixWorld(true);
156
+ const out: Copies[] = [];
157
+ model.traverse((o) => {
158
+ const m = o as Mesh;
159
+ if (m.isMesh) { const mesh = new InstancedMesh(m.geometry, m.material, count); mesh.name = m.name; out.push({ mesh, base: m.matrixWorld.clone() }); }
160
+ });
161
+ return out;
162
+ }
163
+
164
+ const placed = new Matrix4();
165
+ /** Copy `i` of `copy` at `where` (a world matrix: position, turn, scale). */
166
+ export function placeCopy(copy: Copies, i: number, where: Matrix4): void {
167
+ copy.mesh.setMatrixAt(i, placed.multiplyMatrices(where, copy.base));
168
+ }
169
+
170
+ /**
171
+ * Repaint a model's flat colours. `pick(r, g, b)` (sRGB, 0 to 255) returns the colour to paint instead, or null to
172
+ * keep it. It reaches each material's own colour and its base-colour picture when that is a small palette picture (at
173
+ * most `maxPx` a side: an optimised flat model's); a painted texture bigger than that is left as it is. Materials and
174
+ * pictures change in place, so call it once on the loaded model (`(await models.load(url)).scene`), before copying.
175
+ * Returns how many materials changed.
176
+ */
177
+ export function repaint(model: Object3D, pick: (r: number, g: number, b: number) => [number, number, number] | null, { maxPx = 64 } = {}): number {
178
+ let changed = 0;
179
+ const seen = new Set<Material>();
180
+ const c = new Color();
181
+ model.traverse((o) => {
182
+ const m = o as Mesh;
183
+ if (!m.isMesh) return;
184
+ for (const mat of (Array.isArray(m.material) ? m.material : [m.material]) as MeshStandardMaterial[]) {
185
+ if (seen.has(mat)) continue;
186
+ seen.add(mat);
187
+ const tex = mat.map;
188
+ if (!tex) {
189
+ if (!mat.color) continue;
190
+ c.copy(mat.color).convertLinearToSRGB();
191
+ const to = pick(Math.round(c.r * 255), Math.round(c.g * 255), Math.round(c.b * 255));
192
+ if (to) { mat.color.setRGB(to[0] / 255, to[1] / 255, to[2] / 255, SRGBColorSpace); changed += 1; }
193
+ continue;
194
+ }
195
+ const img = tex.image as (CanvasImageSource & { width?: number; height?: number }) | undefined;
196
+ const w = Number(img?.width ?? 0); const h = Number(img?.height ?? 0);
197
+ if (!img || !w || !h || w > maxPx || h > maxPx || typeof document === 'undefined') continue;
198
+ const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h;
199
+ const g = canvas.getContext('2d', { willReadFrequently: true });
200
+ if (!g) continue;
201
+ g.drawImage(img, 0, 0);
202
+ const px = g.getImageData(0, 0, w, h);
203
+ let n = 0;
204
+ for (let i = 0; i < px.data.length; i += 4) {
205
+ const to = pick(px.data[i] as number, px.data[i + 1] as number, px.data[i + 2] as number);
206
+ if (to) { px.data[i] = to[0]; px.data[i + 1] = to[1]; px.data[i + 2] = to[2]; n += 1; }
207
+ }
208
+ if (!n) continue;
209
+ g.putImageData(px, 0, 0);
210
+ // The new picture samples exactly as the old one did (its orientation, wrapping and filters).
211
+ const t = new CanvasTexture(canvas);
212
+ Object.assign(t, { colorSpace: tex.colorSpace, flipY: tex.flipY, wrapS: tex.wrapS, wrapT: tex.wrapT, magFilter: tex.magFilter, minFilter: tex.minFilter, generateMipmaps: tex.generateMipmaps, channel: tex.channel });
213
+ mat.map = t; mat.needsUpdate = true;
214
+ changed += 1;
215
+ }
216
+ });
217
+ return changed;
218
+ }
219
+
220
+ /** Every geometry, material and picture under `obj`, freed. */
221
+ /** What `stylize` reads from a game's style.json. */
222
+ export interface StyleLike {
223
+ render?: string;
224
+ materials?: { model?: string; outline?: boolean };
225
+ palette?: { bg?: string; ink?: string };
226
+ }
227
+
228
+ let toonRamp: DataTexture | null = null;
229
+ function rampOf(): DataTexture {
230
+ if (toonRamp) return toonRamp;
231
+ const data = new Uint8Array([Math.round(0.2 * 255), Math.round(0.53 * 255), Math.round(0.87 * 255)]);
232
+ toonRamp = new DataTexture(data, 3, 1, RedFormat);
233
+ toonRamp.minFilter = NearestFilter; toonRamp.magFilter = NearestFilter; toonRamp.needsUpdate = true;
234
+ return toonRamp;
235
+ }
236
+
237
+ /** One material in the style's material model (its colour, picture, vertex colours and transparency kept). */
238
+ function styledMaterial(mat: Material, model: string): Material {
239
+ const src = mat as MeshStandardMaterial;
240
+ const base = { color: src.color ? src.color.clone() : new Color('#cccccc'), map: (src.map ?? null) as Texture | null, vertexColors: Boolean(src.vertexColors), transparent: src.transparent, opacity: src.opacity, side: src.side, alphaTest: src.alphaTest };
241
+ let out: Material;
242
+ if (model === 'toon') out = new MeshToonMaterial({ ...base, gradientMap: rampOf() });
243
+ else if (model === 'flat' || model === 'pixel') out = new MeshLambertMaterial({ ...base, flatShading: true });
244
+ else if (model === 'hand-painted') {
245
+ const m = new MeshStandardMaterial({ ...base, roughness: 0.95, metalness: 0 });
246
+ // A painted look: the top of every surface a little lighter, the bottom a little cooler.
247
+ m.onBeforeCompile = (sh) => {
248
+ sh.vertexShader = sh.vertexShader.replace('#include <common>', '#include <common>\nvarying float vH;').replace('#include <worldpos_vertex>', '#include <worldpos_vertex>\nvH = (modelMatrix * vec4(transformed, 1.0)).y;');
249
+ sh.fragmentShader = sh.fragmentShader.replace('#include <common>', '#include <common>\nvarying float vH;').replace('#include <color_fragment>', '#include <color_fragment>\ndiffuseColor.rgb *= mix(vec3(0.82, 0.86, 0.98), vec3(1.12, 1.06, 0.96), clamp(vH * 0.6 + 0.4, 0.0, 1.0));');
250
+ };
251
+ out = m;
252
+ } else return mat;
253
+ out.name = mat.name;
254
+ return out;
255
+ }
256
+
257
+ /**
258
+ * The outline's shell: the mesh's positions only, its corners welded, with smooth normals, so a flat-shaded low-poly
259
+ * model's line stays one unbroken shell instead of a black flake on every face. One per geometry.
260
+ */
261
+ const hullGeometries = new WeakMap<BufferGeometry, BufferGeometry | null>();
262
+ function hullGeometry(g: BufferGeometry): BufferGeometry | null {
263
+ if (hullGeometries.has(g)) return hullGeometries.get(g) ?? null;
264
+ let h: BufferGeometry | null;
265
+ const only = new BufferGeometry();
266
+ // Plain float positions (an optimised model's are quantized, often interleaved), read through the accessor.
267
+ const src = g.getAttribute('position');
268
+ const xyz = new Float32Array(src.count * 3);
269
+ for (let i = 0; i < src.count; i++) { xyz[i * 3] = src.getX(i); xyz[i * 3 + 1] = src.getY(i); xyz[i * 3 + 2] = src.getZ(i); }
270
+ only.setAttribute('position', new Float32BufferAttribute(xyz, 3));
271
+ if (g.index) only.setIndex(Array.from(g.index.array as ArrayLike<number>));
272
+ h = mergeVertices(only);
273
+ if (openShell(h) || paperThin(h)) h = null; else h.computeVertexNormals();
274
+ hullGeometries.set(g, h);
275
+ return h;
276
+ }
277
+
278
+ /**
279
+ * How thick a closed shell is, about (3 x volume / area: a slab of thickness t gives 1.5 t), in its own units.
280
+ */
281
+ function thicknessOf(h: BufferGeometry): number {
282
+ const p = h.getAttribute('position'); const a = (h.index as NonNullable<BufferGeometry['index']>).array as ArrayLike<number>;
283
+ let vol = 0; let area = 0;
284
+ for (let i = 0; i + 2 < a.length; i += 3) {
285
+ const i0 = a[i] as number; const i1 = a[i + 1] as number; const i2 = a[i + 2] as number;
286
+ const ax = p.getX(i0); const ay = p.getY(i0); const az = p.getZ(i0);
287
+ const bx = p.getX(i1); const by = p.getY(i1); const bz = p.getZ(i1);
288
+ const cx = p.getX(i2); const cy = p.getY(i2); const cz = p.getZ(i2);
289
+ vol += (ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx)) / 6;
290
+ const ux = bx - ax; const uy = by - ay; const uz = bz - az; const vx = cx - ax; const vy = cy - ay; const vz = cz - az;
291
+ area += Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx) / 2;
292
+ }
293
+ return area > 0 ? (3 * Math.abs(vol)) / area : 0;
294
+ }
295
+
296
+ /** Two cards back to back (leaves, grass): closed, but with no inside, so a shell would z-fight it black. */
297
+ function paperThin(h: BufferGeometry): boolean {
298
+ h.computeBoundingBox();
299
+ const size = (h.boundingBox as NonNullable<BufferGeometry['boundingBox']>).getSize(new Vector3());
300
+ return thicknessOf(h) < 0.01 * Math.max(size.x, size.y, size.z);
301
+ }
302
+
303
+ /** An open surface (cards of leaves, grass, a sail): many edges belong to one triangle only. No outline shell for it. */
304
+ function openShell(h: BufferGeometry): boolean {
305
+ const idx = h.index;
306
+ if (!idx) return true;
307
+ const edges = new Map<string, number>();
308
+ const a = idx.array as ArrayLike<number>;
309
+ for (let i = 0; i + 2 < a.length; i += 3) {
310
+ for (const [u, v] of [[a[i], a[i + 1]], [a[i + 1], a[i + 2]], [a[i + 2], a[i]]] as [number, number][]) {
311
+ const k = u < v ? `${u}_${v}` : `${v}_${u}`;
312
+ edges.set(k, (edges.get(k) ?? 0) + 1);
313
+ }
314
+ }
315
+ let open = 0;
316
+ for (const n of edges.values()) if (n === 1) open += 1;
317
+ return edges.size > 0 && open / edges.size > 0.15;
318
+ }
319
+
320
+ /**
321
+ * The ink shell's vertex step: pushed out along its normal by `width` WORLD units (metres in a game), after every
322
+ * transform (the model's scale, a quantized mesh's node, an instance's matrix), so a line is as thick on a flower as on
323
+ * a tree and nothing about the model's own units matters.
324
+ */
325
+ function inkShader(width: number): (sh: { vertexShader: string }) => void {
326
+ const w = Math.max(0, width).toExponential(4);
327
+ return (sh) => {
328
+ sh.vertexShader = sh.vertexShader.replace('#include <project_vertex>', [
329
+ 'vec4 mvPosition = vec4( transformed, 1.0 );',
330
+ 'vec3 inkN = normal;',
331
+ '#ifdef USE_INSTANCING',
332
+ 'mvPosition = instanceMatrix * mvPosition; inkN = mat3( instanceMatrix ) * inkN;',
333
+ '#endif',
334
+ 'vec4 inkW = modelMatrix * mvPosition;',
335
+ `inkW.xyz += normalize( mat3( modelMatrix ) * inkN ) * ${w};`,
336
+ 'mvPosition = viewMatrix * inkW;',
337
+ 'gl_Position = projectionMatrix * mvPosition;',
338
+ ].join('\n'));
339
+ };
340
+ }
341
+
342
+ const MODEL_OF_RENDER: Record<string, string> = { toon: 'toon', 'lowpoly-flat': 'flat', voxel: 'flat', 'pixel-hd2d': 'pixel', 'hand-painted': 'hand-painted', pbr: 'pbr', realistic: 'pbr' };
343
+
344
+ /**
345
+ * Draw a loaded model the way the game's art direction says: its material model (style.json `materials.model`, else
346
+ * the render style's) on every mesh, and with `materials.outline` an ink line (an inverted hull: a back-faced copy
347
+ * pushed out along the normals, `line` metres in the world, as thick on a bush as on a tree, finer on something small:
348
+ * pass `scale`, the scale the game draws it at). Skinned meshes, open cards and paper-thin shells get the material,
349
+ * not the line. Call it once on the loaded model, after `repaint`; `instancedCopies` copies the line with it. Returns how many
350
+ * meshes changed.
351
+ */
352
+ export function stylize(model: Object3D, style: StyleLike, { line = 0.05, scale = 1, ink }: { line?: number; scale?: number; ink?: string } = {}): number {
353
+ const kind = style.materials?.model ?? MODEL_OF_RENDER[String(style.render ?? '')] ?? 'pbr';
354
+ const swapped = new Map<Material, Material>();
355
+ const meshes: Mesh[] = [];
356
+ model.traverse((o) => { const m = o as Mesh; if (m.isMesh && m.name !== 'hull') meshes.push(m); });
357
+ for (const m of meshes) {
358
+ const swap = (x: Material): Material => { let y = swapped.get(x); if (!y) { y = styledMaterial(x, kind); swapped.set(x, y); } return y; };
359
+ m.material = Array.isArray(m.material) ? m.material.map(swap) : swap(m.material);
360
+ }
361
+ if (style.materials?.outline) {
362
+ const colour = new Color(ink ?? (style.palette?.bg ? `#${new Color(style.palette.bg).lerp(new Color('#000000'), 0.7).getHexString()}` : '#1a1410'));
363
+ // A small thing (a flower, a gem) gets a finer line: never more than 6% of its size as the game draws it (`scale`).
364
+ const box = new Box3().setFromObject(model).getSize(new Vector3());
365
+ const width = Math.min(line, 0.06 * Math.max(box.x, box.y, box.z) * scale);
366
+ const inkMaterial = new MeshBasicMaterial({ color: colour, side: BackSide });
367
+ inkMaterial.onBeforeCompile = inkShader(width);
368
+ inkMaterial.customProgramCacheKey = () => `ink:${width}`;
369
+ for (const m of meshes) {
370
+ // No line on a skinned mesh; cards of leaves and grass (open, or two faces back to back) get none from hullGeometry.
371
+ if ((m as unknown as { isSkinnedMesh?: boolean }).isSkinnedMesh) continue;
372
+ const shell = hullGeometry(m.geometry);
373
+ if (!shell) continue;
374
+ // A part too thin for the line (a flower's stem, a post) goes without: three widths of ink would make it a stick.
375
+ shell.computeBoundingBox();
376
+ const local = (shell.boundingBox as Box3).getSize(new Vector3());
377
+ const world = new Box3().setFromObject(m, true).getSize(new Vector3());
378
+ const units = Math.max(world.x, world.y, world.z) / Math.max(1e-9, local.x, local.y, local.z);
379
+ if (thicknessOf(shell) * units * scale < 3 * width) continue;
380
+ const hull = new Mesh(shell, inkMaterial);
381
+ hull.name = 'hull';
382
+ hull.castShadow = false; hull.receiveShadow = false;
383
+ m.add(hull);
384
+ }
385
+ }
386
+ return meshes.length;
387
+ }
388
+
389
+ export function disposeObject(obj: Object3D): void {
390
+ obj.traverse((o) => {
391
+ const m = o as Mesh;
392
+ if (m.geometry) m.geometry.dispose();
393
+ const mats = (Array.isArray(m.material) ? m.material : m.material ? [m.material] : []) as Material[];
394
+ for (const mat of mats) {
395
+ for (const v of Object.values(mat as unknown as Record<string, unknown>)) if (v && typeof v === 'object' && (v as Texture).isTexture) (v as Texture).dispose();
396
+ mat.dispose();
397
+ }
398
+ });
399
+ }
400
+
401
+ export function createModels(opts: ModelsOptions = {}): Models {
402
+ const limits = { ...LIMITS.game, ...(opts.limits ?? {}) } as ModelLimits;
403
+ const allow = (opts.allowOrigins ?? []).map((o) => new URL(o).origin);
404
+ const dev = opts.dev ?? devHost();
405
+ const warn = (text: string): void => { if (dev) (opts.onWarn ?? ((t: string) => console.warn(`[models] ${t}`)))(text); };
406
+ const loader = new GLTFLoader();
407
+ loader.setMeshoptDecoder(MeshoptDecoder);
408
+ if (opts.ktx2) loader.setKTX2Loader(opts.ktx2 as never);
409
+ const cache = new Map<string, Promise<LoadedModel>>();
410
+ const made: Object3D[] = [];
411
+ const refused: { url: string; why: string }[] = [];
412
+ const loaded: LoadedModel[] = [];
413
+
414
+ async function loadOnce(url: string, lim: ModelLimits, budget: Required<ModelBudget>): Promise<LoadedModel> {
415
+ let href: string;
416
+ try { href = resolveUrl(url, allow); } catch (error) { refused.push({ url, why: (error as Error).message }); throw error; }
417
+ let bytes: Uint8Array;
418
+ try { bytes = await fetchCapped(href, lim.bytes); } catch (error) { refused.push({ url, why: (error as Error).message }); throw error; }
419
+ const check = checkGlb(bytes, lim);
420
+ if (!check.ok) { const why = refusal(url, check); refused.push({ url, why }); throw new Error(why); }
421
+ if (check.info.extensions.required.includes('KHR_texture_basisu') && !opts.ktx2) {
422
+ const why = `refused ${url}: it has KTX2 pictures and this game set up no KTX2 loader (createModels({ ktx2 }))`;
423
+ refused.push({ url, why });
424
+ throw new Error(why);
425
+ }
426
+ const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
427
+ const gltf = await new Promise<GLTF>((resolve, reject) => loader.parse(buffer, href.replace(/[^/]*$/, ''), resolve, reject));
428
+ const scene = gltf.scene;
429
+ let triangles = 0; let textures = 0; let tex = 0; let materials = 0;
430
+ const seen = new Set<string>();
431
+ scene.traverse((o) => {
432
+ const m = o as Mesh;
433
+ if (!m.isMesh) return;
434
+ const g = m.geometry;
435
+ const count = g.index ? g.index.count : (g.attributes.position?.count ?? 0);
436
+ triangles += Math.floor(count / 3) * Number((m as unknown as { count?: number }).count ?? 1);
437
+ for (const mat of (Array.isArray(m.material) ? m.material : [m.material]) as Material[]) {
438
+ if (seen.has(mat.uuid)) continue;
439
+ seen.add(mat.uuid); materials += 1;
440
+ for (const v of Object.values(mat as unknown as Record<string, unknown>)) {
441
+ const t = v as Texture;
442
+ if (t && typeof t === 'object' && t.isTexture && !seen.has(t.uuid)) { seen.add(t.uuid); textures += 1; tex += textureBytes(t); }
443
+ }
444
+ }
445
+ });
446
+ const warnings = [...check.warnings];
447
+ if (triangles > budget.triangles) warnings.push(`${triangles.toLocaleString('en-US')} triangles (its budget is ${budget.triangles.toLocaleString('en-US')})`);
448
+ if (materials > budget.materials) warnings.push(`${materials} materials (its budget is ${budget.materials})`);
449
+ if (bytes.byteLength > budget.bytes) warnings.push(`${Math.ceil(bytes.byteLength / 1024)} KB (its budget is ${Math.ceil(budget.bytes / 1024)} KB)`);
450
+ const px = budget.texturePx * budget.texturePx * 4 * (4 / 3) * Math.max(1, textures);
451
+ if (tex > px) warnings.push(`${(tex / 1024 / 1024).toFixed(1)} MB of picture memory (pictures over ${budget.texturePx} px)`);
452
+ for (const w of warnings) warn(`${url}: ${w}`);
453
+ const model: LoadedModel = { url, scene, animations: gltf.animations ?? [], bytes: bytes.byteLength, triangles, textures, textureBytes: tex, warnings };
454
+ loaded.push(model);
455
+ made.push(scene);
456
+ return model;
457
+ }
458
+
459
+ const api: Models = {
460
+ load(url, o = {}) {
461
+ const key = url;
462
+ let p = cache.get(key);
463
+ if (!p) {
464
+ p = loadOnce(url, { ...limits, ...(o.limits ?? {}) } as ModelLimits, { ...PROP_BUDGET, ...(opts.budget ?? {}), ...(o.budget ?? {}) });
465
+ cache.set(key, p);
466
+ p.catch(() => cache.delete(key));
467
+ }
468
+ return p;
469
+ },
470
+ async instance(url, o) {
471
+ const m = await api.load(url, o);
472
+ let skinned = false;
473
+ m.scene.traverse((x) => { if ((x as unknown as { isSkinnedMesh?: boolean }).isSkinnedMesh) skinned = true; });
474
+ return skinned ? cloneSkinned(m.scene) : m.scene.clone(true);
475
+ },
476
+ placeholder(size = { x: 0.5, y: 0.5, z: 0.5 }, colour = 0x8a8f99) {
477
+ const geo = new BoxGeometry(size.x, size.y, size.z);
478
+ geo.translate(0, size.y / 2, 0);
479
+ const box = new Mesh(geo, new MeshStandardMaterial({ color: colour, roughness: 0.9 }));
480
+ box.name = 'placeholder';
481
+ made.push(box);
482
+ return box;
483
+ },
484
+ stats() {
485
+ return {
486
+ models: loaded.length,
487
+ triangles: loaded.reduce((n, m) => n + m.triangles, 0),
488
+ textureBytes: loaded.reduce((n, m) => n + m.textureBytes, 0),
489
+ bytes: loaded.reduce((n, m) => n + m.bytes, 0),
490
+ refused: refused.slice(),
491
+ };
492
+ },
493
+ dispose() {
494
+ for (const o of made) disposeObject(o);
495
+ made.length = 0; loaded.length = 0; cache.clear();
496
+ },
497
+ };
498
+ try { (globalThis as unknown as { __homieModels?: () => ModelStats }).__homieModels = () => api.stats(); } catch { /* not a browser */ }
499
+ return api;
500
+ }