@bitmagic/cli 0.1.59-dev.3 → 0.1.59-dev.4

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
@@ -204,6 +204,7 @@ browser reloads itself on every change and the assets panel shows each generatio
204
204
  | `bitmagic generate <skybox\|background\|block-type\|sound\|image\|character\|animation\|model\|prop\|vehicle> ...` | Generate one asset directly into `src/work/world.json`. Costs sparks. `model` mints a **new** 3D object from a prompt, `prop` upgrades an existing placeholder **in place** (both produce voxels directly; `--mesh` takes the older mesh path), `vehicle` adds a new drivable one, `animation` takes a parameter spec rather than a prompt, `block-type` registers a custom voxel block — see below. `skybox` and `background` write the **same** field: pick one. |
205
205
  | `bitmagic assets add <file> [--name <n>] [--asset-id <id>] [--keep-glb] [--voxel-size <m>] [--max-voxel-size <m>] [--height <m>] [--hollow\|--solid] [--force]` | Upload **your own** file — `.glb`, `.vxl`, `.png/.jpg/.webp`, `.opus/.ogg/.m4a/.mp3`, `.json` — into the game's assets and write its entry into `src/work/world.json`. A `.glb` is voxelized in a headless browser (Chrome + a free port in 3000–3199, like `prop`) unless `--keep-glb`. Free — no sparks. See **Adding your own assets** below. |
206
206
  | `bitmagic assets list` | The assets in `world.json`: id, name, type, size, placed instances, and total references. Local, no network. |
207
+ | `bitmagic assets materials <assetId> [--set <file.json>] [--auto] [--clear]` | Show what an asset's voxels look like, or say what they are **made of** — so a sword's steel blade shines and its leather grip does not. With no flag it prints the asset's colour groups; `--set` applies your verdicts; `--auto` lets a model decide (what `generate prop` and `generate model` already do on their own); `--clear` removes them. Free — no sparks, nothing generated. See **Voxel materials** below. |
207
208
  | `bitmagic assets remove <assetId> [--force]` | Remove an asset from `world.json` (the uploaded file stays in storage). Refuses while anything in `world.json` still references it; `--force` removes the asset and its placed instances together. |
208
209
  | `bitmagic cover [--prompt "..."] [--force] [--no-start-screen]` | Generate the game's cover art from `GAME-DESIGN.md`: sets the start screen, saves `.bitmagic/cover.webp`, becomes the publish thumbnail. Costs sparks. |
209
210
  | `bitmagic reference <make\|accept\|show\|clear> ...` | Make a concept image of the game's world, iterate until it is right, and `accept` one — the accepted image becomes the default style reference for `generate skybox\|background\|block-type\|image`, `cover` and `forge`. Only `make` costs sparks (priced like an image). See **Reference image** below. |
@@ -774,6 +775,104 @@ whose vendored engine predates voxel masters takes the mesh path regardless, and
774
775
  If the bake fails after the source exists, the error prints the exact command to retry the browser
775
776
  step **without paying again** — `--master-url …` for voxels, `--glb-url …` for a mesh.
776
777
 
778
+ ## Voxel materials
779
+
780
+ A generated voxel asset carries colours and nothing else, so every voxel shades identically: a
781
+ sword's steel blade responds to light exactly like its leather grip, and both read as flat paint.
782
+ Material classes fix that by saying what each colour is **made of**.
783
+
784
+ **You usually do not have to do anything.** `bitmagic generate prop` and `bitmagic generate model`
785
+ classify the asset right after the bake: a light model reads the same colour table shown below
786
+ together with the asset's prompt, and its verdicts are written into the `.vxl` before the command
787
+ returns (`Materials: metal, leather` in the output; `materials.classifier: "ai"` on the asset's
788
+ `world.json` entry). Pass `--no-materials` to skip it. A classification that cannot run — no model
789
+ credential, an engine too old to write classes — is a note, never a failed generation; the asset is
790
+ baked and matte, and `bitmagic assets materials <id> --auto` retries it later.
791
+
792
+ When the automatic answer is wrong, or for an asset added from a file (which records no prompt, so
793
+ nothing is classified automatically), `bitmagic assets materials` is two steps, and the CLI does not
794
+ guess at the middle one — **you do**, because you are the one who knows what the asset is meant to
795
+ be.
796
+
797
+ ```bash
798
+ bitmagic assets materials sword-01 # what does this asset look like?
799
+ bitmagic assets materials sword-01 --set m.json # here is what those colours are
800
+ bitmagic assets materials sword-01 --auto # or let the model decide again
801
+ ```
802
+
803
+ The first command prints the asset's colour groups:
804
+
805
+ ```
806
+ "Longsword" signature 4f2a91cd (version 1)
807
+
808
+ 20 voxels, 5 palette entries, bbox 1x20x1 voxels
809
+
810
+ id colour share height(min-max) extent(x,y,z) radial shape parts
811
+ g0 #dddddd 70.0% 0.33-0.98 0.00,0.65,0.00 0.00 thin-long 1
812
+ g1 #998866 20.0% 0.03-0.18 0.00,0.15,0.00 0.00 blob 1
813
+ g2 #eecc66 10.0% 0.23-0.28 0.00,0.05,0.00 0.00 blob 1
814
+ ```
815
+
816
+ Read with the asset's own prompt — printed underneath — that is enough to name the materials: g0 is
817
+ the steel blade (most of the model, upper two thirds, long and thin), g1 the leather grip at the
818
+ bottom, g2 a brass pommel between them. `share` is the fraction of the asset, `height` where it
819
+ sits from floor to top, `extent` its size along each axis relative to the whole, `radial` how far
820
+ from the centre axis, and `parts` how many separate pieces the colour forms — a colour scattered
821
+ across the whole model in many parts is dithering, not a material.
822
+
823
+ Then write the verdicts:
824
+
825
+ ```json
826
+ {
827
+ "signatureVersion": 1,
828
+ "signatureHash": "4f2a91cd",
829
+ "verdicts": [
830
+ { "id": "g0", "class": "metal", "confidence": 0.95 },
831
+ { "id": "g1", "class": "leather", "confidence": 0.9 },
832
+ { "id": "g2", "class": "gold", "confidence": 0.6 }
833
+ ]
834
+ }
835
+ ```
836
+
837
+ The `signatureHash` binds the answer to the asset it describes: re-bake or re-voxelize the asset
838
+ and the hash changes, so a stale set of verdicts is refused rather than applied to geometry it
839
+ never saw.
840
+
841
+ **Classes:** `matte` (the default — no special shading), `metal`, `gold`, `chrome`, `gem`, `glass`,
842
+ `wood`, `stone`, `cloth`, `fur`, `leather`, `plastic`, `paint`. The first five reflect the sky; the
843
+ rest get a highlight from the scene's own lights, which keeps their colours exactly as they are.
844
+ `fur` is the odd one out — it reflects nothing, but picks up a soft halo towards its silhouette the
845
+ way a pelt or plush does. Use it for exactly that: a teddy bear, an animal, a fur trim. Anything
846
+ merely soft is `cloth`.
847
+
848
+ **Rules worth following:**
849
+
850
+ - `matte` is the right answer for most groups, and costs nothing. Leave a group out entirely rather
851
+ than inventing a material for it.
852
+ - The asset's prompt is the strongest evidence there is — it names the materials, and your job is
853
+ only to decide which colour group is which.
854
+ - A material the prompt does **not** name needs strong evidence from the geometry. A precious
855
+ material (`gold`, `gem`, `glass`) claimed over more than a quarter of the asset is refused
856
+ outright unless the prompt says so: a wooden crate is never 40% gem, and a wrongly shiny surface
857
+ is what a player notices.
858
+ - Use a low confidence rather than guessing. Below 0.45 a verdict is dropped, which is a better
859
+ outcome than a surface that shines when it should not.
860
+ - An asset that records no prompt or description has nothing saying what it is. Prefer leaving it
861
+ alone.
862
+
863
+ **Budget.** Four materials per asset. Each one is an extra draw call on every placed instance, so
864
+ over budget the smallest is folded into a related class (`gold` into `metal`, `gem` into `glass`)
865
+ rather than dropped — every fold is reported. Very small groups are dropped too, except for the
866
+ precious classes, where a fraction of a per cent is exactly the point.
867
+
868
+ Materials are written into the `.vxl` under the **same asset id**, so every placed instance changes
869
+ with it. `--clear` puts an asset back to the plain look. A re-bake (`bitmagic assets revoxelize`)
870
+ produces a fresh `.vxl` whose palette the old verdicts no longer describe, so it classifies again
871
+ on its own — `--no-materials` there leaves the re-baked asset plain. Nothing here generates anything or costs
872
+ sparks; what it costs is the headless browser bake, since only the engine can read and write a
873
+ `.vxl`. An asset whose base voxels glow (from the older per-colour glow that materials replaced) is
874
+ refused rather than having that glow silently traded for a shine.
875
+
777
876
  ## Drivable vehicles
778
877
 
779
878
  `bitmagic generate vehicle` designs a vehicle and registers it as a **new** asset — unlike
@@ -0,0 +1,165 @@
1
+ import type { Environment } from '../config/environments.js';
2
+ import { withVoxelizeSession } from '../forge/voxelize-session.js';
3
+ import type { ProjectContext } from '../project/context.js';
4
+ /**
5
+ * Whether this project's vendored engine can answer these messages at all.
6
+ *
7
+ * A capability PROBE, not a version compare, exactly as `engineSupportsVxlMaster`
8
+ * does and for the reason its own doc gives: an engine with no handler never
9
+ * replies, so the request would stall for the full ten-minute bake timeout and
10
+ * then report a timeout, with nothing pointing at the real cause.
11
+ */
12
+ export declare function engineSupportsMaterialClasses(projectRoot: string): boolean;
13
+ /** One colour group of an asset, as the engine described it. */
14
+ export interface MaterialGroup {
15
+ id: string;
16
+ hex: string;
17
+ share: number;
18
+ shape: string;
19
+ }
20
+ /** What the engine reports about an asset's materials. */
21
+ export interface MaterialSignatureReply {
22
+ version: number;
23
+ hash: string;
24
+ voxelCount: number;
25
+ paletteSize: number;
26
+ groups: MaterialGroup[];
27
+ existingSlots: string[];
28
+ }
29
+ /** A single group's verdict, as written into a `--set` file. */
30
+ export interface MaterialVerdictInput {
31
+ id: string;
32
+ class: string;
33
+ confidence: number;
34
+ }
35
+ export interface MaterialsBaseOptions {
36
+ context: ProjectContext;
37
+ environment: Environment;
38
+ token: string;
39
+ assetId: string;
40
+ log: (message: string) => void;
41
+ fetchImpl?: typeof globalThis.fetch;
42
+ /** Injected in tests so the lane runs without Chrome. */
43
+ session?: typeof withVoxelizeSession;
44
+ }
45
+ export interface ReadMaterialsResult {
46
+ assetId: string;
47
+ assetName: string;
48
+ signature: MaterialSignatureReply;
49
+ /** The engine's own rendering of the signature — printed verbatim. */
50
+ table: string;
51
+ /** The asset's generation prompt or description, when it records one. */
52
+ promptText?: string;
53
+ }
54
+ /**
55
+ * The asset to work on, refused before the browser starts if it cannot be.
56
+ *
57
+ * Only a `vxl` asset has voxels to classify. A GLB kept as a mesh, an image, a
58
+ * sound: none of them have a palette, and saying so up front costs nothing while
59
+ * a browser launch costs seconds.
60
+ */
61
+ export declare function findVoxelAsset(world: Record<string, unknown>, assetId: string): {
62
+ asset: Record<string, unknown>;
63
+ url: string;
64
+ name: string;
65
+ };
66
+ /**
67
+ * The text this asset was generated from, if it records any.
68
+ *
69
+ * The single most valuable signal a classifier has — it names the materials
70
+ * outright — and also what the plausibility guard checks a precious-material
71
+ * claim against. Absent for an asset added from a file, which is exactly the case
72
+ * where classifying on colour alone would be guessing.
73
+ */
74
+ export declare function assetPromptText(asset: Record<string, unknown>): string | undefined;
75
+ /**
76
+ * Ask the engine what the asset is made of.
77
+ *
78
+ * Reads only — no upload, no world.json write, nothing spent. Safe to run as often
79
+ * as you like, which matters because it is the step an agent runs first.
80
+ */
81
+ export declare function readAssetMaterials(options: MaterialsBaseOptions): Promise<ReadMaterialsResult>;
82
+ export interface SetMaterialsOptions extends MaterialsBaseOptions {
83
+ /** The verdicts to apply, and the signature hash they were computed against. */
84
+ signatureVersion: number;
85
+ signatureHash: string;
86
+ verdicts: MaterialVerdictInput[];
87
+ }
88
+ export interface ClearMaterialsOptions extends MaterialsBaseOptions {
89
+ clear: true;
90
+ }
91
+ export interface SetMaterialsResult {
92
+ assetId: string;
93
+ assetName: string;
94
+ /** The material classes now on the asset. Empty after a --clear. */
95
+ slots: string[];
96
+ /** Classes that lost a share floor, the slot budget, or the plausibility guard. */
97
+ dropped: Array<{
98
+ class: string;
99
+ reason: string;
100
+ share: number;
101
+ }>;
102
+ /** Fraction of the asset left with the default look. */
103
+ baseShare: number;
104
+ /** True when nothing changed, so no upload and no world.json write happened. */
105
+ unchanged: boolean;
106
+ assetUrl?: string;
107
+ instanceCount: number;
108
+ }
109
+ /**
110
+ * Apply an assignment — or strip every class with `--clear` — and record the
111
+ * result in world.json.
112
+ *
113
+ * The engine hands back BYTES rather than uploading, the same split
114
+ * `REVOXELIZE_FROM_VXL_MASTER` uses: in a pro project the CLI owns world.json,
115
+ * and two writers on one asset thrash the reload watcher.
116
+ */
117
+ export declare function setAssetMaterials(options: SetMaterialsOptions | ClearMaterialsOptions): Promise<SetMaterialsResult>;
118
+ /** What api-server answers `POST /api/cli/v1/assets/materials/classify` with. */
119
+ export interface ClassifyReply {
120
+ signatureHash?: unknown;
121
+ verdicts?: unknown;
122
+ model?: unknown;
123
+ }
124
+ /** The model step, injectable so the lane is testable without api-server. */
125
+ export type ClassifyMaterials = (request: {
126
+ gameId: string;
127
+ assetName: string;
128
+ table: string;
129
+ signatureHash: string;
130
+ promptText?: string;
131
+ }) => Promise<ClassifyReply>;
132
+ export interface ClassifyMaterialsOptions extends MaterialsBaseOptions {
133
+ /** Test seam; the default posts to api-server. */
134
+ classify?: ClassifyMaterials;
135
+ }
136
+ export interface ClassifyMaterialsResult extends SetMaterialsResult {
137
+ /** The model that answered, for provenance. Absent when no verdicts came back. */
138
+ model?: string;
139
+ }
140
+ /**
141
+ * Read, classify and apply in ONE browser session: what `generate prop|model` run
142
+ * on the asset they just baked, and what `assets materials --auto` runs on demand.
143
+ *
144
+ * The middle step is api-server's light model rather than the creator's coding
145
+ * agent — the same prompt that agent would be shown, answered without a round
146
+ * trip through it, so a generated sword shines before anyone has looked at it.
147
+ * `assets materials --set` still overrides whatever this decided.
148
+ *
149
+ * An empty verdict list skips the APPLY entirely: the engine would leave the
150
+ * file byte-identical anyway, and not asking is one fewer thing to time out.
151
+ */
152
+ export declare function classifyAssetMaterials(options: ClassifyMaterialsOptions): Promise<ClassifyMaterialsResult>;
153
+ /**
154
+ * The tail of `generate prop|model` and `assets revoxelize`: classify the asset
155
+ * just baked, and say what happened.
156
+ *
157
+ * **Never throws.** The asset is baked, uploaded and in world.json by the time
158
+ * this runs, and a generation is paid for; a classification that fails —
159
+ * api-server without a model credential, a browser that would not come up a
160
+ * second time, a vendored engine too old to write classes — costs a note, never
161
+ * the asset. `bitmagic assets materials <id> --auto` re-runs it on demand.
162
+ */
163
+ export declare function classifyAssetMaterialsQuietly(options: ClassifyMaterialsOptions): Promise<{
164
+ slots: string[];
165
+ } | null>;
@@ -0,0 +1,395 @@
1
+ /**
2
+ * Read and set what an asset's voxels are MADE OF.
3
+ *
4
+ * A voxel asset's colours say nothing about its materials, so a generated sword's
5
+ * steel blade shades exactly like its leather grip. `bitmagic assets materials`
6
+ * closes that gap in two steps, and the split is the whole design:
7
+ *
8
+ * bitmagic assets materials <id> → print what the asset looks like
9
+ * bitmagic assets materials <id> --set f → say what those colours are
10
+ *
11
+ * Nothing here classifies anything, and the CLI deliberately does not try. A pro
12
+ * project has no language model of its own — `buildCliForgeDeps` hands the forge a
13
+ * `model` that throws on purpose, because the scene design runs on api-server —
14
+ * but it does have one sitting right there: the creator's own coding agent, which
15
+ * already knows what it asked for, has the world.json and the game's art
16
+ * direction in context, and costs the project nothing. So the CLI's job is to
17
+ * print a readable description and accept an answer back.
18
+ *
19
+ * That makes this lane's classifier better informed than a server-side one, not
20
+ * worse: it is the same agent that wrote the prompt.
21
+ *
22
+ * Writes under the SAME asset id, so every placed instance changes with it — the
23
+ * upgrade-in-place contract `generate prop` and `assets revoxelize` both make.
24
+ */
25
+ import * as fs from 'fs';
26
+ import * as path from 'path';
27
+ import { CliError } from '../errors.js';
28
+ import { VOXELIZE_TIMEOUT_MS, withVoxelizeSession } from '../forge/voxelize-session.js';
29
+ import { applyModificationsToWorld } from '../forge/apply-modifications.js';
30
+ import { projectWorldJsonPath } from '../forge/run-pipeline.js';
31
+ import { apiPost } from '../http/client.js';
32
+ import { uploadProjectFile } from '../http/signed-upload.js';
33
+ import { isJsonObject, readWorldJson } from '../project/world-json.js';
34
+ import { summarizeVxl } from './vxl-summary.js';
35
+ /**
36
+ * Whether this project's vendored engine can answer these messages at all.
37
+ *
38
+ * A capability PROBE, not a version compare, exactly as `engineSupportsVxlMaster`
39
+ * does and for the reason its own doc gives: an engine with no handler never
40
+ * replies, so the request would stall for the full ten-minute bake timeout and
41
+ * then report a timeout, with nothing pointing at the real cause.
42
+ */
43
+ export function engineSupportsMaterialClasses(projectRoot) {
44
+ return fs.existsSync(path.join(projectRoot, 'engine', 'engine', 'template', 'VxlMaterialAssetHandler.ts'));
45
+ }
46
+ /**
47
+ * The asset to work on, refused before the browser starts if it cannot be.
48
+ *
49
+ * Only a `vxl` asset has voxels to classify. A GLB kept as a mesh, an image, a
50
+ * sound: none of them have a palette, and saying so up front costs nothing while
51
+ * a browser launch costs seconds.
52
+ */
53
+ export function findVoxelAsset(world, assetId) {
54
+ const assets = Array.isArray(world.assets) ? world.assets : [];
55
+ const asset = assets.find((item) => isJsonObject(item) && item.id === assetId);
56
+ if (!isJsonObject(asset)) {
57
+ throw new CliError(`No asset with id "${assetId}" in this project's world.json. `
58
+ + 'Run `bitmagic assets list` to see the ids.');
59
+ }
60
+ const name = typeof asset.name === 'string' ? asset.name : assetId;
61
+ const url = asset.url;
62
+ if (asset.type !== 'vxl' || typeof url !== 'string' || url === '') {
63
+ throw new CliError(`"${name}" is not a voxel asset, so it has no voxels to give materials to. `
64
+ + 'Material classes apply to `vxl` assets — those produced by `bitmagic generate prop`, '
65
+ + '`generate model`, or `assets add <file.glb>` without `--keep-glb`.');
66
+ }
67
+ return { asset, url, name };
68
+ }
69
+ /**
70
+ * The text this asset was generated from, if it records any.
71
+ *
72
+ * The single most valuable signal a classifier has — it names the materials
73
+ * outright — and also what the plausibility guard checks a precious-material
74
+ * claim against. Absent for an asset added from a file, which is exactly the case
75
+ * where classifying on colour alone would be guessing.
76
+ */
77
+ export function assetPromptText(asset) {
78
+ const production = asset.production;
79
+ if (isJsonObject(production) && typeof production.prompt === 'string' && production.prompt !== '') {
80
+ return production.prompt;
81
+ }
82
+ if (typeof asset.description === 'string' && asset.description !== '')
83
+ return asset.description;
84
+ return undefined;
85
+ }
86
+ /** Placed instances pointing at this asset — the blast radius, reported not assumed. */
87
+ function countInstances(world, assetId) {
88
+ const objects = Array.isArray(world.environmentObjects) ? world.environmentObjects : [];
89
+ return objects.filter((item) => isJsonObject(item) && item.assetId === assetId).length;
90
+ }
91
+ function assertEngineSupport(context) {
92
+ if (engineSupportsMaterialClasses(context.root))
93
+ return;
94
+ throw new CliError('This project\'s vendored engine does not know about voxel material classes. '
95
+ + 'Run `bitmagic upgrade` to get a newer engine, then try again. world.json is unchanged.');
96
+ }
97
+ /**
98
+ * Ask the engine what the asset is made of.
99
+ *
100
+ * Reads only — no upload, no world.json write, nothing spent. Safe to run as often
101
+ * as you like, which matters because it is the step an agent runs first.
102
+ */
103
+ export async function readAssetMaterials(options) {
104
+ const { context, environment, token, assetId, log } = options;
105
+ assertEngineSupport(context);
106
+ const world = readWorldJson(projectWorldJsonPath(context.root));
107
+ const { asset, url, name } = findVoxelAsset(world, assetId);
108
+ const runSession = options.session ?? withVoxelizeSession;
109
+ const read = await runSession({
110
+ context, environment, token, log, subject: `"${name}"`,
111
+ retryHint: 'Nothing was written — this only reads the asset, so it is safe to retry.',
112
+ }, (session) => readSignatureInSession(session, { assetId, name, url, log }));
113
+ const promptText = assetPromptText(asset);
114
+ return {
115
+ assetId,
116
+ assetName: name,
117
+ ...read,
118
+ ...(promptText === undefined ? {} : { promptText }),
119
+ };
120
+ }
121
+ /** The one READ message, inside a session someone else opened. */
122
+ async function readSignatureInSession({ host, port }, target) {
123
+ const { assetId, name, url, log } = target;
124
+ log(`Reading "${name}"'s colours…`);
125
+ const result = await host.transport.sendAndWait({
126
+ type: 'READ_VXL_MATERIAL_SIGNATURE',
127
+ requestId: `materials-read-${assetId}-${port}`,
128
+ vxlUrl: url,
129
+ }, 'READ_VXL_MATERIAL_SIGNATURE_RESULT', VOXELIZE_TIMEOUT_MS, { maxRetries: 1 });
130
+ if (!result)
131
+ throw new CliError(`Reading "${name}" timed out.`);
132
+ if (result.success === false) {
133
+ const reason = typeof result.error === 'string' ? result.error : 'unknown error';
134
+ throw new CliError(`Reading "${name}" failed: ${reason}`);
135
+ }
136
+ const signature = result.signature;
137
+ if (!signature || typeof signature.hash !== 'string') {
138
+ throw new CliError(`The engine returned no material signature for "${name}".`);
139
+ }
140
+ return { signature, table: typeof result.table === 'string' ? result.table : '' };
141
+ }
142
+ /**
143
+ * Apply an assignment — or strip every class with `--clear` — and record the
144
+ * result in world.json.
145
+ *
146
+ * The engine hands back BYTES rather than uploading, the same split
147
+ * `REVOXELIZE_FROM_VXL_MASTER` uses: in a pro project the CLI owns world.json,
148
+ * and two writers on one asset thrash the reload watcher.
149
+ */
150
+ export async function setAssetMaterials(options) {
151
+ const { context, environment, token, assetId, log } = options;
152
+ assertEngineSupport(context);
153
+ const worldPath = projectWorldJsonPath(context.root);
154
+ const world = readWorldJson(worldPath);
155
+ const { asset, url, name } = findVoxelAsset(world, assetId);
156
+ const clearing = 'clear' in options;
157
+ const promptText = assetPromptText(asset);
158
+ const runSession = options.session ?? withVoxelizeSession;
159
+ const retryHint = `Nothing was written — "${name}" is unchanged, so this is safe to retry.`;
160
+ const reply = await runSession({
161
+ context, environment, token, log, subject: `"${name}"`, retryHint,
162
+ }, (session) => applyInSession(session, {
163
+ assetId, name, url, log, retryHint,
164
+ ...(promptText === undefined ? {} : { promptText }),
165
+ ...(clearing ? { clear: true } : {
166
+ assignment: {
167
+ signatureVersion: options.signatureVersion,
168
+ signatureHash: options.signatureHash,
169
+ verdicts: options.verdicts,
170
+ // 'agent' because in this lane the classifier IS the creator's own coding
171
+ // agent; nothing here decided anything.
172
+ classifier: 'agent',
173
+ },
174
+ }),
175
+ }));
176
+ return recordApplied({
177
+ options, world, worldPath, asset, assetId, name, retryHint, reply,
178
+ classifier: clearing ? 'manual' : 'agent',
179
+ });
180
+ }
181
+ /** The one APPLY message, inside a session someone else opened. */
182
+ async function applyInSession({ host, port }, target) {
183
+ const { assetId, name, url, log, retryHint } = target;
184
+ log(target.clear
185
+ ? `Removing "${name}"'s material classes…`
186
+ : `Setting "${name}"'s materials…`);
187
+ const result = await host.transport.sendAndWait({
188
+ type: 'APPLY_VXL_MATERIALS',
189
+ requestId: `materials-set-${assetId}-${port}`,
190
+ assetId,
191
+ assetName: name,
192
+ vxlUrl: url,
193
+ // The CLI uploads, so the engine returns bytes (see the doc above).
194
+ upload: false,
195
+ ...(target.clear ? { clear: true } : { assignment: target.assignment }),
196
+ ...(target.promptText === undefined ? {} : { promptText: target.promptText }),
197
+ }, 'APPLY_VXL_MATERIALS_RESULT', VOXELIZE_TIMEOUT_MS, { maxRetries: 1 });
198
+ if (!result)
199
+ throw new CliError(`Setting "${name}"'s materials timed out. ${retryHint}`);
200
+ if (result.success === false) {
201
+ const reason = typeof result.error === 'string' ? result.error : 'unknown error';
202
+ throw new CliError(`Setting "${name}"'s materials failed: ${reason}. ${retryHint}`);
203
+ }
204
+ return result;
205
+ }
206
+ /**
207
+ * After the engine answered: upload the bytes and record the result in world.json,
208
+ * or do neither when the file was left alone.
209
+ */
210
+ async function recordApplied(input) {
211
+ const { options, world, worldPath, asset, assetId, name, retryHint, reply } = input;
212
+ const { context, environment, token } = options;
213
+ const slots = Array.isArray(reply.slots) ? reply.slots.filter((s) => typeof s === 'string') : [];
214
+ const dropped = Array.isArray(reply.dropped) ? reply.dropped : [];
215
+ const baseShare = typeof reply.baseShare === 'number' ? reply.baseShare : 1;
216
+ const instanceCount = countInstances(world, assetId);
217
+ if (reply.unchanged === true) {
218
+ // Byte-identical output: no upload, no url churn, no world.json write. "Nothing
219
+ // was classified" has to be indistinguishable from "this never ran".
220
+ return {
221
+ assetId, assetName: name, slots, dropped, baseShare,
222
+ unchanged: true, instanceCount,
223
+ };
224
+ }
225
+ const raw = reply.vxlBytes;
226
+ if (!(raw instanceof Uint8Array) && !Array.isArray(raw)) {
227
+ throw new CliError(`The engine returned no voxels for "${name}". ${retryHint}`);
228
+ }
229
+ const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
230
+ // Read the header BEFORE uploading, as the re-bake path does: bytes that are not
231
+ // a readable `.vxl` must never reach storage under a name the engine will later
232
+ // try to load. This also catches the one failure this command could plausibly
233
+ // introduce — a version the project's own vxl-summary does not accept.
234
+ const summary = summarizeVxl(bytes);
235
+ if (summary === null) {
236
+ throw new CliError(`The engine returned bytes that are not a readable .vxl for "${name}". ${retryHint}`);
237
+ }
238
+ const upload = await uploadProjectFile(environment, token, {
239
+ gameId: context.metadata.gameId,
240
+ filename: `${context.metadata.gameId}-${name}-materials-${Date.now()}.vxl`,
241
+ contentType: 'application/octet-stream',
242
+ bytes,
243
+ }, { fetch: options.fetchImpl ?? globalThis.fetch });
244
+ // MERGED over the existing record, never substituted for it. An asset accumulates
245
+ // authored state this command knows nothing about — `fitBox`, `collision`,
246
+ // `light`, `voxelizeSettings` — and a fresh record would discard all of it. Only
247
+ // the file and its size change: geometry, bounds and voxel size are untouched by
248
+ // a material class, so re-asserting them here could only introduce a disagreement.
249
+ const modification = {
250
+ type: 'upsertRoot',
251
+ path: ['assets'],
252
+ predicate: (item) => isJsonObject(item) && item.id === assetId,
253
+ value: {
254
+ ...asset,
255
+ url: upload.url,
256
+ size: bytes.byteLength,
257
+ // Provenance only — the classes themselves live in the `.vxl`. `--clear`
258
+ // records the absence rather than deleting the field, so "this was
259
+ // deliberately put back to plain" stays distinguishable from "never
260
+ // classified".
261
+ materials: {
262
+ classifier: input.classifier,
263
+ slots,
264
+ at: new Date().toISOString(),
265
+ ...(input.model === undefined ? {} : { model: input.model }),
266
+ },
267
+ },
268
+ };
269
+ applyModificationsToWorld(worldPath, [modification], { subject: 'The asset\'s materials' });
270
+ return {
271
+ assetId,
272
+ assetName: name,
273
+ slots,
274
+ dropped,
275
+ baseShare,
276
+ unchanged: false,
277
+ assetUrl: upload.url,
278
+ instanceCount,
279
+ };
280
+ }
281
+ /**
282
+ * Read, classify and apply in ONE browser session: what `generate prop|model` run
283
+ * on the asset they just baked, and what `assets materials --auto` runs on demand.
284
+ *
285
+ * The middle step is api-server's light model rather than the creator's coding
286
+ * agent — the same prompt that agent would be shown, answered without a round
287
+ * trip through it, so a generated sword shines before anyone has looked at it.
288
+ * `assets materials --set` still overrides whatever this decided.
289
+ *
290
+ * An empty verdict list skips the APPLY entirely: the engine would leave the
291
+ * file byte-identical anyway, and not asking is one fewer thing to time out.
292
+ */
293
+ export async function classifyAssetMaterials(options) {
294
+ const { context, environment, token, assetId, log } = options;
295
+ assertEngineSupport(context);
296
+ const worldPath = projectWorldJsonPath(context.root);
297
+ const world = readWorldJson(worldPath);
298
+ const { asset, url, name } = findVoxelAsset(world, assetId);
299
+ const promptText = assetPromptText(asset);
300
+ const retryHint = `Nothing was written — "${name}" is unchanged, so this is safe to retry.`;
301
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
302
+ const classify = options.classify ?? ((request) => apiPost(environment, '/api/cli/v1/assets/materials/classify', token, request, { fetch: fetchImpl }));
303
+ const runSession = options.session ?? withVoxelizeSession;
304
+ const outcome = await runSession({
305
+ context, environment, token, log, subject: `"${name}"`, retryHint,
306
+ }, async (session) => {
307
+ const { signature, table } = await readSignatureInSession(session, { assetId, name, url, log });
308
+ log(`Deciding what "${name}" is made of…`);
309
+ const answer = await classify({
310
+ gameId: context.metadata.gameId,
311
+ assetName: name,
312
+ table,
313
+ signatureHash: signature.hash,
314
+ ...(promptText === undefined ? {} : { promptText }),
315
+ });
316
+ const verdicts = parseVerdictList(answer.verdicts);
317
+ const model = typeof answer.model === 'string' ? answer.model : undefined;
318
+ if (verdicts.length === 0)
319
+ return { reply: null, model };
320
+ const reply = await applyInSession(session, {
321
+ assetId, name, url, log, retryHint,
322
+ ...(promptText === undefined ? {} : { promptText }),
323
+ assignment: {
324
+ signatureVersion: signature.version,
325
+ signatureHash: signature.hash,
326
+ verdicts,
327
+ classifier: 'ai',
328
+ ...(model === undefined ? {} : { model }),
329
+ },
330
+ });
331
+ return { reply, model };
332
+ });
333
+ if (outcome.reply === null) {
334
+ return {
335
+ assetId, assetName: name, slots: [], dropped: [], baseShare: 1,
336
+ unchanged: true, instanceCount: countInstances(world, assetId),
337
+ ...(outcome.model === undefined ? {} : { model: outcome.model }),
338
+ };
339
+ }
340
+ const recorded = await recordApplied({
341
+ options, world, worldPath, asset, assetId, name, retryHint, reply: outcome.reply,
342
+ classifier: 'ai',
343
+ ...(outcome.model === undefined ? {} : { model: outcome.model }),
344
+ });
345
+ return { ...recorded, ...(outcome.model === undefined ? {} : { model: outcome.model }) };
346
+ }
347
+ /**
348
+ * The tail of `generate prop|model` and `assets revoxelize`: classify the asset
349
+ * just baked, and say what happened.
350
+ *
351
+ * **Never throws.** The asset is baked, uploaded and in world.json by the time
352
+ * this runs, and a generation is paid for; a classification that fails —
353
+ * api-server without a model credential, a browser that would not come up a
354
+ * second time, a vendored engine too old to write classes — costs a note, never
355
+ * the asset. `bitmagic assets materials <id> --auto` re-runs it on demand.
356
+ */
357
+ export async function classifyAssetMaterialsQuietly(options) {
358
+ const { context, assetId, log } = options;
359
+ if (!engineSupportsMaterialClasses(context.root))
360
+ return null;
361
+ try {
362
+ const result = await classifyAssetMaterials(options);
363
+ if (result.unchanged) {
364
+ log(`Materials: nothing on "${result.assetName}" needs special shading.`);
365
+ }
366
+ else {
367
+ log(`Materials: ${result.slots.join(', ')} — `
368
+ + `${Math.round(result.baseShare * 100)}% of "${result.assetName}" keeps the default look.`);
369
+ }
370
+ return { slots: result.slots };
371
+ }
372
+ catch (error) {
373
+ const message = error instanceof Error ? error.message : String(error);
374
+ log(`note: could not decide what the asset is made of (${message}). `
375
+ + `It is baked and in world.json; \`bitmagic assets materials ${assetId} --auto\` retries.`);
376
+ return null;
377
+ }
378
+ }
379
+ /** api-server's verdict list, with anything malformed dropped rather than refused. */
380
+ function parseVerdictList(value) {
381
+ if (!Array.isArray(value))
382
+ return [];
383
+ const verdicts = [];
384
+ for (const item of value) {
385
+ if (!isJsonObject(item))
386
+ continue;
387
+ const { id, confidence } = item;
388
+ const className = item.class;
389
+ if (typeof id !== 'string' || typeof className !== 'string' || typeof confidence !== 'number')
390
+ continue;
391
+ verdicts.push({ id, class: className, confidence });
392
+ }
393
+ return verdicts;
394
+ }
395
+ //# sourceMappingURL=materials.js.map