@bitmagic/cli 0.1.59-dev.2 → 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 +99 -0
- package/dist/assets/materials.d.ts +165 -0
- package/dist/assets/materials.js +395 -0
- package/dist/assets/materials.js.map +1 -0
- package/dist/assets/vxl-summary.js +12 -5
- package/dist/assets/vxl-summary.js.map +1 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/assets.d.ts +25 -0
- package/dist/commands/assets.js +245 -2
- package/dist/commands/assets.js.map +1 -1
- package/dist/commands/generate.d.ts +4 -0
- package/dist/commands/generate.js +33 -2
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/theme.d.ts +3 -0
- package/dist/commands/theme.js +201 -0
- package/dist/commands/theme.js.map +1 -0
- package/dist/scaffold/project-files.d.ts +1 -0
- package/dist/scaffold/project-files.js +47 -1
- package/dist/scaffold/project-files.js.map +1 -1
- package/dist/scaffold/project.js +2 -1
- package/dist/scaffold/project.js.map +1 -1
- package/dist/theme/hud-theme-catalog.d.ts +236 -0
- package/dist/theme/hud-theme-catalog.js +386 -0
- package/dist/theme/hud-theme-catalog.js.map +1 -0
- package/dist/theme/theme-color-math.d.ts +21 -0
- package/dist/theme/theme-color-math.js +99 -0
- package/dist/theme/theme-color-math.js.map +1 -0
- package/dist/theme/theme-io.d.ts +49 -0
- package/dist/theme/theme-io.js +190 -0
- package/dist/theme/theme-io.js.map +1 -0
- package/dist/theme/theme-merge.d.ts +36 -0
- package/dist/theme/theme-merge.js +81 -0
- package/dist/theme/theme-merge.js.map +1 -0
- package/dist/theme/theme-report.d.ts +28 -0
- package/dist/theme/theme-report.js +142 -0
- package/dist/theme/theme-report.js.map +1 -0
- package/dist/theme/theme-stored.d.ts +21 -0
- package/dist/theme/theme-stored.js +50 -0
- package/dist/theme/theme-stored.js.map +1 -0
- package/dist/theme/theme-validate.d.ts +41 -0
- package/dist/theme/theme-validate.js +406 -0
- package/dist/theme/theme-validate.js.map +1 -0
- package/dist/verify/hud-theme.d.ts +5 -3
- package/dist/verify/hud-theme.js +5 -3
- package/dist/verify/hud-theme.js.map +1 -1
- package/package.json +4 -4
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
|