incanto 0.46.0 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/incanto-check.mjs +5 -1
- package/bin/incanto-feel.mjs +5 -1
- package/bin/incanto-frame.mjs +115 -7
- package/bin/incanto-logs.mjs +127 -0
- package/bin/incanto-model.mjs +79 -10
- package/bin/incanto-playtest.mjs +5 -1
- package/bin/incanto-verify.mjs +245 -0
- package/dist/3d.d.ts +89 -4
- package/dist/3d.js +90 -3
- package/dist/{create-game-BtCNdkjI.js → create-game-akjPkFv5.js} +99 -3
- package/dist/{frame-report-Ct8XgsmV.d.ts → frame-report-DZ70IY26.d.ts} +25 -0
- package/dist/{frame-report-Lr3VO24R.js → frame-report-njybhZon.js} +130 -3
- package/dist/index.d.ts +101 -1
- package/dist/index.js +3 -2
- package/dist/log-report-lxrQY9cH.js +0 -0
- package/dist/react.js +1 -1
- package/dist/{src-B43b6bZo.js → src-Bl1Kire1.js} +1 -1
- package/dist/{test-BboGNQr-.js → test-E4-otKqK.js} +51 -4
- package/dist/test.d.ts +55 -1
- package/dist/test.js +2 -2
- package/dist/vite.d.ts +13 -2
- package/dist/vite.js +65 -10
- package/editor/assets/{agent8-Dl5ZZLu-.js → agent8-_007gPF8.js} +1 -1
- package/editor/assets/{debug-j9Pi6RvO.js → debug-0DI_MJaq.js} +1 -1
- package/editor/assets/{index-Y-93awAL.js → index-B-6eYZEi.js} +92 -92
- package/editor/index.html +1 -1
- package/package.json +4 -2
- package/skills/incanto-3d-models.md +30 -0
- package/skills/incanto-verifying-your-game.md +113 -1
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/village-quest-3d/package.json +1 -1
package/bin/incanto-check.mjs
CHANGED
|
@@ -106,4 +106,8 @@ if (args.json) {
|
|
|
106
106
|
}
|
|
107
107
|
console.log(`\n${results.length - failed.length}/${results.length} scene(s) valid`);
|
|
108
108
|
}
|
|
109
|
-
process.exit(
|
|
109
|
+
// `exitCode`, never `process.exit()`: stdout to a PIPE is written
|
|
110
|
+
// asynchronously, and exiting discards whatever has not flushed. A --json
|
|
111
|
+
// report read by another program came back truncated — silently, and only
|
|
112
|
+
// when piped, which is the only way a program reads it.
|
|
113
|
+
process.exitCode = failed.length === 0 ? 0 : 1;
|
package/bin/incanto-feel.mjs
CHANGED
|
@@ -109,4 +109,8 @@ if (!args.json) {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
process.exit(
|
|
112
|
+
// `exitCode`, never `process.exit()`: stdout to a PIPE is written
|
|
113
|
+
// asynchronously, and exiting discards whatever has not flushed. A --json
|
|
114
|
+
// report read by another program came back truncated — silently, and only
|
|
115
|
+
// when piped, which is the only way a program reads it.
|
|
116
|
+
process.exitCode = report.player ? 0 : 1;
|
package/bin/incanto-frame.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
/**
|
|
4
4
|
* incanto-frame — what is actually on screen, as numbers.
|
|
5
5
|
*
|
|
@@ -26,6 +26,7 @@ const { listeningPorts, parseProcNetTcp } = await import(
|
|
|
26
26
|
pathToFileURL(join(PKG, 'dist', 'vite.js')).href
|
|
27
27
|
);
|
|
28
28
|
const { frameText, diffText } = await import(pathToFileURL(join(PKG, 'dist', '3d.js')).href);
|
|
29
|
+
const { parseDrive } = await import(pathToFileURL(join(PKG, 'dist', 'index.js')).href);
|
|
29
30
|
|
|
30
31
|
const args = process.argv.slice(2);
|
|
31
32
|
const asJson = args.includes('--json');
|
|
@@ -37,6 +38,10 @@ const flag = (name) => {
|
|
|
37
38
|
return next && !next.startsWith('-') ? next : '';
|
|
38
39
|
};
|
|
39
40
|
const gridArg = flag('--grid');
|
|
41
|
+
const outArg = flag('--out');
|
|
42
|
+
const sizeArg = flag('--size');
|
|
43
|
+
const driveArg = flag('--do');
|
|
44
|
+
const driveFile = flag('--do-file');
|
|
40
45
|
const remember = flag('--remember');
|
|
41
46
|
const diff = flag('--diff');
|
|
42
47
|
const threshold = flag('--threshold');
|
|
@@ -45,6 +50,10 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
45
50
|
|
|
46
51
|
--json the full report as JSON instead of prose
|
|
47
52
|
--grid WxH cells across and down (default 16x9)
|
|
53
|
+
--out FILE also write the frame as a PNG you can look at
|
|
54
|
+
--size N longest side of that PNG (default 512)
|
|
55
|
+
--do SCRIPT drive the game first, THEN capture (see below)
|
|
56
|
+
--do-file FILE the same script, from a file
|
|
48
57
|
--port N skip discovery and use this port
|
|
49
58
|
|
|
50
59
|
--remember [L] keep this frame as the baseline named L (default "last")
|
|
@@ -52,7 +61,25 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
52
61
|
--threshold N a cell has changed when it moves this far, 0..255 (default 3)
|
|
53
62
|
|
|
54
63
|
Run your game's dev server and open the preview page first: the pixels live in
|
|
55
|
-
the browser, not in the dev server.
|
|
64
|
+
the browser, not in the dev server. Nothing headless is involved and no browser
|
|
65
|
+
is installed: the page that is already open does the rendering.
|
|
66
|
+
|
|
67
|
+
The report says whether anything was drawn, where the subject is and how much of
|
|
68
|
+
the frame it fills. When that is not enough, look:
|
|
69
|
+
|
|
70
|
+
incanto-frame --out shot.png # then open it, or read it
|
|
71
|
+
|
|
72
|
+
Every interesting state in a game is downstream of input, so --do drives the
|
|
73
|
+
running game before capturing — the same words incanto-play takes:
|
|
74
|
+
|
|
75
|
+
incanto-frame --do "vector move 0 1; step 3000; vector move 0 0" --out bridge.png
|
|
76
|
+
incanto-frame --do "press jump; step 400" --out midair.png
|
|
77
|
+
|
|
78
|
+
press/release ACTION · vector ACTION X Y · key CODE down|up
|
|
79
|
+
pointer DX DY · step MS (semicolons or newlines separate)
|
|
80
|
+
|
|
81
|
+
step waits on the real clock: this is the live game, with its own loop.
|
|
82
|
+
|
|
56
83
|
|
|
57
84
|
The report of ONE frame has a floor: a one-pixel seam is a fraction of a level
|
|
58
85
|
once a cell is averaged, and nothing can tell it apart from a thin rope in the
|
|
@@ -100,6 +127,24 @@ function bsdListeningPorts() {
|
|
|
100
127
|
}
|
|
101
128
|
}
|
|
102
129
|
|
|
130
|
+
/** The script to run before capturing, from `--do` or `--do-file`. */
|
|
131
|
+
const driveScript = driveFile ? read(driveFile) : driveArg;
|
|
132
|
+
if (driveFile && driveScript === null) {
|
|
133
|
+
console.error(`cannot read ${driveFile}`);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
if (driveScript) {
|
|
137
|
+
const parsed = parseDrive(driveScript);
|
|
138
|
+
if (parsed.error) {
|
|
139
|
+
// Refuse here rather than in the page: a bad command that ran nothing would
|
|
140
|
+
// hand back the boot screen, and nothing about that frame would say so.
|
|
141
|
+
console.error(
|
|
142
|
+
`${parsed.error}\n commands: press/release ACTION · vector ACTION X Y · key CODE down|up · pointer DX DY · step MS`,
|
|
143
|
+
);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
103
148
|
const explicit = args.includes('--port') ? Number(args[args.indexOf('--port') + 1]) : null;
|
|
104
149
|
const discovered = explicit ? [explicit] : await listeningPorts({ read });
|
|
105
150
|
const ports = explicit ? discovered : [...new Set([...bsdListeningPorts(), ...discovered])];
|
|
@@ -138,19 +183,40 @@ if (!found) {
|
|
|
138
183
|
|
|
139
184
|
const params = new URLSearchParams();
|
|
140
185
|
if (gridArg !== null) params.set('grid', gridArg);
|
|
186
|
+
if (outArg) params.set('image', sizeArg || '512');
|
|
187
|
+
if (driveScript) {
|
|
188
|
+
params.set('drive', driveScript);
|
|
189
|
+
// Tell the server how long to wait: the script runs in real time.
|
|
190
|
+
const totalMs = parseDrive(driveScript).totalMs;
|
|
191
|
+
params.set('driveMs', String(totalMs));
|
|
192
|
+
}
|
|
141
193
|
if (remember !== null) params.set('remember', remember);
|
|
142
194
|
if (diff !== null) params.set('diff', diff);
|
|
143
195
|
if (threshold) params.set('threshold', threshold);
|
|
144
196
|
const q = params.toString();
|
|
145
197
|
const res = await fetch(`http://${found.host}:${found.port}/__incanto/frame${q ? `?${q}` : ''}`, {
|
|
146
|
-
|
|
198
|
+
// Outlast the server's own budget, or the CLI gives up on an answer that
|
|
199
|
+
// was on its way.
|
|
200
|
+
signal: AbortSignal.timeout(15_000 + (driveScript ? parseDrive(driveScript).totalMs * 2 : 0)),
|
|
147
201
|
});
|
|
148
202
|
const body = await res.json().catch(() => null);
|
|
149
203
|
|
|
204
|
+
if (body?.error === 'page-not-drawing') {
|
|
205
|
+
console.error(
|
|
206
|
+
`the page on :${found.port} is connected but not drawing.\n` +
|
|
207
|
+
' A frame is captured inside a render, and a browser stops rendering a tab it\n' +
|
|
208
|
+
' considers hidden — including a window covered by another one. Bring the\n' +
|
|
209
|
+
' preview to the front and run this again.',
|
|
210
|
+
);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
150
213
|
if (res.status === 504 || body?.error === 'no-page-connected') {
|
|
151
214
|
console.error(
|
|
152
|
-
`the dev server is running on :${found.port}, but no page
|
|
153
|
-
' Open the preview in a browser — the pixels are there, not in the server
|
|
215
|
+
`the dev server is running on :${found.port}, but no page answered.\n` +
|
|
216
|
+
' Open the preview in a browser — the pixels are there, not in the server.\n' +
|
|
217
|
+
' If it IS open: a window covered by another one counts as hidden, and a\n' +
|
|
218
|
+
' hidden tab gets no animation frames — such a page never finishes loading\n' +
|
|
219
|
+
' and cannot draw. Bring it to the front.',
|
|
154
220
|
);
|
|
155
221
|
process.exit(1);
|
|
156
222
|
}
|
|
@@ -160,15 +226,53 @@ if (!res.ok || !body?.report) {
|
|
|
160
226
|
}
|
|
161
227
|
|
|
162
228
|
const report = body.report;
|
|
229
|
+
// A drive that advanced no frames moved nothing: the inputs landed on a game
|
|
230
|
+
// the browser had frozen, and the frame that came back is the one from before.
|
|
231
|
+
// Saying nothing here would be handing over a picture that quietly lies.
|
|
232
|
+
if (driveScript && report.droveFrames === 0) {
|
|
233
|
+
console.error(
|
|
234
|
+
'the drive ran but the game never advanced — 0 frames drawn.\n' +
|
|
235
|
+
' The page is hidden (a window covered by another counts) and the browser\n' +
|
|
236
|
+
' stops animating it, so the inputs landed on a frozen game and this frame\n' +
|
|
237
|
+
' is unchanged. Bring the preview to the front and try again.',
|
|
238
|
+
);
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
163
241
|
if (report.error) {
|
|
164
242
|
console.error(`the page could not capture a frame: ${report.error}`);
|
|
165
243
|
process.exit(1);
|
|
166
244
|
}
|
|
167
245
|
|
|
246
|
+
/** The picture, if one was asked for. Written before anything is printed, so a
|
|
247
|
+
* report that mentions the file is a report whose file exists. */
|
|
248
|
+
let wroteImage = null;
|
|
249
|
+
if (outArg) {
|
|
250
|
+
const dataUrl = typeof report.image === 'string' ? report.image : null;
|
|
251
|
+
const base64 = dataUrl?.slice(dataUrl.indexOf(',') + 1);
|
|
252
|
+
if (!base64) {
|
|
253
|
+
console.error(
|
|
254
|
+
'the page returned no image — it is running an engine older than this CLI,\n' +
|
|
255
|
+
' or its renderer cannot capture frames.',
|
|
256
|
+
);
|
|
257
|
+
} else {
|
|
258
|
+
writeFileSync(outArg, Buffer.from(base64, 'base64'));
|
|
259
|
+
wroteImage = outArg;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
168
263
|
if (asJson) {
|
|
169
|
-
|
|
264
|
+
// The base64 image is for the file, not for a terminal — the path is the
|
|
265
|
+
// useful part and a megabyte of it in a pipe is not.
|
|
266
|
+
const { image: _image, ...rest } = report;
|
|
267
|
+
process.stdout.write(
|
|
268
|
+
`${JSON.stringify({ ...body, report: rest, ...(wroteImage ? { image: wroteImage } : {}) }, null, 2)}\n`,
|
|
269
|
+
);
|
|
170
270
|
} else {
|
|
171
271
|
const lines = [frameText(report)];
|
|
272
|
+
if (typeof report.droveFrames === 'number') {
|
|
273
|
+
lines.push(`drove ${report.droveFrames} frames before capturing`);
|
|
274
|
+
}
|
|
275
|
+
if (wroteImage) lines.push(`wrote ${wroteImage} — open it, or read it`);
|
|
172
276
|
if (body.diffError === 'no-baseline') {
|
|
173
277
|
lines.push(
|
|
174
278
|
`no baseline named "${diff || 'last'}" — run \`incanto-frame --remember${diff ? ` ${diff}` : ''}\` first,\n` +
|
|
@@ -183,6 +287,10 @@ if (asJson) {
|
|
|
183
287
|
process.stdout.write(`${lines.join('\n')}\n`);
|
|
184
288
|
}
|
|
185
289
|
// A missing baseline is a usage error, not a picture: fail so a script notices.
|
|
186
|
-
process.exit(
|
|
290
|
+
// `exitCode`, never `process.exit()`: stdout to a PIPE is written
|
|
291
|
+
// asynchronously, and exiting discards whatever has not flushed. A --json
|
|
292
|
+
// report read by another program came back truncated — silently, and only
|
|
293
|
+
// when piped, which is the only way a program reads it.
|
|
294
|
+
process.exitCode = report.black || body.diffError ? 1 : 0;
|
|
187
295
|
|
|
188
296
|
void parseProcNetTcp; // re-exported for tests; referenced so bundlers keep it
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* incanto-logs — what the RUNNING game is saying.
|
|
4
|
+
*
|
|
5
|
+
* bunx incanto-logs
|
|
6
|
+
* bunx incanto-logs --json
|
|
7
|
+
*
|
|
8
|
+
* `incanto-frame` gets the pixels out of the browser; this gets the WORDS. The
|
|
9
|
+
* engine writes diagnostics for exactly this purpose — a model whose fit came
|
|
10
|
+
* out at 934x, an asset that 404'd, a node that quarantined itself — and until
|
|
11
|
+
* now every one of them reached a human only, in the debug overlay's logs
|
|
12
|
+
* panel. An agent could see a game and not hear it.
|
|
13
|
+
*
|
|
14
|
+
* Unlike a frame this needs no render, so it answers from a tab the browser has
|
|
15
|
+
* stopped drawing.
|
|
16
|
+
*
|
|
17
|
+
* Exit 1 when the game reports something wrong: an error logged, an error
|
|
18
|
+
* swallowed to keep it alive, or an asset that never loaded.
|
|
19
|
+
*/
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const { listeningPorts } = await import(pathToFileURL(join(PKG, 'dist', 'vite.js')).href);
|
|
27
|
+
const { logText } = await import(pathToFileURL(join(PKG, 'dist', 'index.js')).href);
|
|
28
|
+
|
|
29
|
+
const args = process.argv.slice(2);
|
|
30
|
+
const asJson = args.includes('--json');
|
|
31
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
32
|
+
console.error(`Usage: incanto-logs [--json] [--port N]
|
|
33
|
+
|
|
34
|
+
What the running game is saying: its warnings and errors, the errors it
|
|
35
|
+
swallowed to keep going, the assets that never loaded, and its frame rate.
|
|
36
|
+
|
|
37
|
+
Run your game's dev server and open the page first — the log buffer is in the
|
|
38
|
+
browser, not in the dev server. This needs no render, so a page the browser has
|
|
39
|
+
stopped drawing still answers.
|
|
40
|
+
|
|
41
|
+
Exit 1 when something is wrong.`);
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const read = (path) => {
|
|
46
|
+
try {
|
|
47
|
+
return readFileSync(path, 'utf-8');
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Ports listening on a machine with no `/proc` (macOS, BSD) — see incanto-frame. */
|
|
54
|
+
function bsdListeningPorts() {
|
|
55
|
+
try {
|
|
56
|
+
const { execFileSync } = createRequire(import.meta.url)('node:child_process');
|
|
57
|
+
const out = execFileSync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'], {
|
|
58
|
+
encoding: 'utf-8',
|
|
59
|
+
timeout: 1500,
|
|
60
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
61
|
+
});
|
|
62
|
+
const ports = new Set();
|
|
63
|
+
for (const line of out.split('\n')) {
|
|
64
|
+
const m = line.match(/:(\d+)\s*\(LISTEN\)/);
|
|
65
|
+
if (m) ports.add(Number(m[1]));
|
|
66
|
+
}
|
|
67
|
+
return [...ports];
|
|
68
|
+
} catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const explicit = args.includes('--port') ? Number(args[args.indexOf('--port') + 1]) : null;
|
|
74
|
+
const discovered = explicit ? [explicit] : await listeningPorts({ read });
|
|
75
|
+
const ports = explicit ? discovered : [...new Set([...bsdListeningPorts(), ...discovered])];
|
|
76
|
+
|
|
77
|
+
let found = null;
|
|
78
|
+
outer: for (const port of ports) {
|
|
79
|
+
for (const host of ['localhost', '127.0.0.1']) {
|
|
80
|
+
try {
|
|
81
|
+
const res = await fetch(`http://${host}:${port}/__incanto/ping`, {
|
|
82
|
+
signal: AbortSignal.timeout(300),
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok) continue;
|
|
85
|
+
const body = await res.json();
|
|
86
|
+
if (body?.incanto) {
|
|
87
|
+
found = { host, port };
|
|
88
|
+
break outer;
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// not listening, not ours, or not answering — next
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!found) {
|
|
97
|
+
console.error(
|
|
98
|
+
'no incanto dev server found.\n' +
|
|
99
|
+
' Start your game (the dev server) and try again — the log buffer lives in\n' +
|
|
100
|
+
' the running page, so there has to be one.',
|
|
101
|
+
);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const res = await fetch(`http://${found.host}:${found.port}/__incanto/logs`, {
|
|
106
|
+
signal: AbortSignal.timeout(10_000),
|
|
107
|
+
});
|
|
108
|
+
const body = await res.json().catch(() => null);
|
|
109
|
+
|
|
110
|
+
if (res.status === 504 || body?.error === 'no-page-connected') {
|
|
111
|
+
console.error(
|
|
112
|
+
`the dev server is running on :${found.port}, but no page answered.\n` +
|
|
113
|
+
' Open the preview in a browser — the log buffer is there, not in the server.',
|
|
114
|
+
);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
if (!res.ok || !body?.report || body.report.error) {
|
|
118
|
+
console.error(
|
|
119
|
+
`could not read the game's logs: ${body?.report?.error ?? body?.error ?? res.status}`,
|
|
120
|
+
);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log(asJson ? JSON.stringify(body.report, null, 2) : logText(body.report));
|
|
125
|
+
// `exitCode`, never `process.exit()` — stdout to a pipe is written
|
|
126
|
+
// asynchronously and exiting discards what has not flushed.
|
|
127
|
+
process.exitCode = body.report.ok ? 0 : 1;
|
package/bin/incanto-model.mjs
CHANGED
|
@@ -9,14 +9,25 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Bounding boxes come from glTF POSITION accessor min/max transformed through
|
|
11
11
|
* each node's TRS chain — for skinned meshes that is the BIND pose.
|
|
12
|
+
*
|
|
13
|
+
* A URL works anywhere a path does. Finding an asset is the agent8 library's
|
|
14
|
+
* job (its vector search matches meaning, which no filename filter can); this
|
|
15
|
+
* answers the question that search cannot — what will this file DO in a scene.
|
|
12
16
|
*/
|
|
13
17
|
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const { modelVerdict, verdictText } = await import(pathToFileURL(join(PKG, 'dist', '3d.js')).href);
|
|
14
23
|
|
|
15
24
|
function parseArgs(argv) {
|
|
16
|
-
const args = { file: undefined, json: false };
|
|
17
|
-
for (
|
|
25
|
+
const args = { file: undefined, json: false, key: undefined };
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const a = argv[i];
|
|
18
28
|
if (a === '--json') args.json = true;
|
|
19
29
|
else if (a === '--help' || a === '-h') args.help = true;
|
|
30
|
+
else if (a === '--key') args.key = argv[++i];
|
|
20
31
|
else if (!a.startsWith('-') && !args.file) args.file = a;
|
|
21
32
|
else {
|
|
22
33
|
console.error(`unknown argument: ${a}`);
|
|
@@ -26,19 +37,49 @@ function parseArgs(argv) {
|
|
|
26
37
|
return args;
|
|
27
38
|
}
|
|
28
39
|
|
|
40
|
+
const isUrl = (s) => /^https?:\/\//i.test(s ?? '');
|
|
41
|
+
|
|
42
|
+
/** A key from a url or path: `.../goblin_king.glb` → `goblin_king`. */
|
|
43
|
+
function keyFrom(source) {
|
|
44
|
+
const file = (source.split(/[?#]/)[0] ?? '').split('/').pop() ?? '';
|
|
45
|
+
return (
|
|
46
|
+
file
|
|
47
|
+
.replace(/\.[^.]+$/, '')
|
|
48
|
+
.replace(/[^a-zA-Z0-9_-]+/g, '_')
|
|
49
|
+
.replace(/_{2,}/g, '_')
|
|
50
|
+
.replace(/^_+|_+$/g, '')
|
|
51
|
+
.toLowerCase() || 'model'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
29
55
|
const args = parseArgs(process.argv.slice(2));
|
|
30
56
|
if (args.help || args.error || !args.file) {
|
|
31
|
-
console.log(`Usage: npx incanto-model <file.glb|.gltf|.vrm> [--json]
|
|
57
|
+
console.log(`Usage: npx incanto-model <file.glb|.gltf|.vrm|URL> [--json] [--key NAME]
|
|
32
58
|
|
|
33
59
|
Reports hierarchy, mesh stats, scene bounding box, animations, and VRM meta —
|
|
34
|
-
the numbers you need to size and place a model in a scene
|
|
60
|
+
the numbers you need to size and place a model in a scene — then says what the
|
|
61
|
+
file will DO in an incanto scene:
|
|
62
|
+
|
|
63
|
+
will the 3d/animations clips play on it (bone names decide, and a mismatch
|
|
64
|
+
keeps the bind pose with only a console warning)
|
|
65
|
+
is it a character's size, or authored in centimetres
|
|
66
|
+
does it stand on its own origin, or will a node at [0,0,0] put it in the sky
|
|
67
|
+
|
|
68
|
+
and prints the scene JSON to paste. A URL works anywhere a path does, so an
|
|
69
|
+
asset the agent8 library found can be checked before it is used.`);
|
|
35
70
|
process.exit(args.help && !args.error ? 0 : 1);
|
|
36
71
|
}
|
|
37
72
|
|
|
38
73
|
// ---- container parsing --------------------------------------------------------------
|
|
39
74
|
|
|
40
|
-
function
|
|
41
|
-
|
|
75
|
+
async function bytesOf(source) {
|
|
76
|
+
if (!isUrl(source)) return readFileSync(source);
|
|
77
|
+
const res = await fetch(source);
|
|
78
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${source}`);
|
|
79
|
+
return Buffer.from(await res.arrayBuffer());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readGltfJson(data) {
|
|
42
83
|
if (data.length >= 12 && data.readUInt32LE(0) === 0x46546c67) {
|
|
43
84
|
// GLB container: header(12) then chunks [len][type][payload]
|
|
44
85
|
let offset = 12;
|
|
@@ -186,11 +227,19 @@ function analyze(json) {
|
|
|
186
227
|
if (node.skin !== undefined) entry.skinned = true;
|
|
187
228
|
nodes.push(entry);
|
|
188
229
|
const mesh = node.mesh !== undefined ? meshes[node.mesh] : null;
|
|
230
|
+
// A SKINNED mesh is placed by its joint matrices, not by the node it hangs
|
|
231
|
+
// off — the node's own TRS is ignored at draw time and is routinely a
|
|
232
|
+
// hundredth-scale leftover from the export. Measured on the stock
|
|
233
|
+
// `base-model.glb`: transforming through the chain reported a 1.5-unit
|
|
234
|
+
// character as 0.003 units tall, because its root carries scale 0.01. Take
|
|
235
|
+
// the vertices as authored for those, and the transformed corners for
|
|
236
|
+
// everything else, where the chain IS the placement.
|
|
237
|
+
const identity = node.skin !== undefined;
|
|
189
238
|
if (mesh?.localMin && mesh.localMax) {
|
|
190
239
|
for (const x of [mesh.localMin[0], mesh.localMax[0]]) {
|
|
191
240
|
for (const y of [mesh.localMin[1], mesh.localMax[1]]) {
|
|
192
241
|
for (const z of [mesh.localMin[2], mesh.localMax[2]]) {
|
|
193
|
-
const p = transformPoint(mat, [x, y, z]);
|
|
242
|
+
const p = identity ? [x, y, z] : transformPoint(mat, [x, y, z]);
|
|
194
243
|
sceneMin = sceneMin ? sceneMin.map((v, i) => Math.min(v, p[i])) : [...p];
|
|
195
244
|
sceneMax = sceneMax ? sceneMax.map((v, i) => Math.max(v, p[i])) : [...p];
|
|
196
245
|
}
|
|
@@ -246,21 +295,30 @@ function analyze(json) {
|
|
|
246
295
|
// ---- output --------------------------------------------------------------------------
|
|
247
296
|
|
|
248
297
|
let report;
|
|
298
|
+
let verdict;
|
|
249
299
|
try {
|
|
250
|
-
const
|
|
300
|
+
const data = await bytesOf(args.file);
|
|
301
|
+
const json = readGltfJson(data);
|
|
251
302
|
if (!json.asset?.version) throw new Error('missing glTF asset header');
|
|
252
303
|
report = {
|
|
253
304
|
file: args.file,
|
|
254
|
-
format:
|
|
305
|
+
format: data.readUInt32LE(0) === 0x46546c67 ? 'glb' : 'gltf',
|
|
255
306
|
...analyze(json),
|
|
256
307
|
};
|
|
308
|
+
verdict = modelVerdict(
|
|
309
|
+
{ ...report, meshes: report.meshes.length },
|
|
310
|
+
{
|
|
311
|
+
url: isUrl(args.file) ? args.file : args.file,
|
|
312
|
+
key: args.key ?? keyFrom(args.file),
|
|
313
|
+
},
|
|
314
|
+
);
|
|
257
315
|
} catch (error) {
|
|
258
316
|
console.error(`not a glTF/GLB/VRM file: ${String(error.message ?? error)}`);
|
|
259
317
|
process.exit(1);
|
|
260
318
|
}
|
|
261
319
|
|
|
262
320
|
if (args.json) {
|
|
263
|
-
console.log(JSON.stringify(report, null, 2));
|
|
321
|
+
console.log(JSON.stringify({ ...report, verdict }, null, 2));
|
|
264
322
|
} else {
|
|
265
323
|
const out = [];
|
|
266
324
|
out.push(`${report.file} (${report.format}${report.vrm ? ` · VRM ${report.vrm.spec}` : ''})`);
|
|
@@ -292,5 +350,16 @@ if (args.json) {
|
|
|
292
350
|
for (const anim of report.animations) {
|
|
293
351
|
out.push(` anim ${anim.name}: ${anim.duration}s`);
|
|
294
352
|
}
|
|
353
|
+
// The verdict is the part you act on, so it goes last — closest to the
|
|
354
|
+
// prompt, where a reader stops.
|
|
355
|
+
out.push('', verdictText(verdict));
|
|
356
|
+
if (verdict.sceneJson) {
|
|
357
|
+
const { assets, node } = verdict.sceneJson;
|
|
358
|
+
out.push(
|
|
359
|
+
'',
|
|
360
|
+
'paste into your scene:',
|
|
361
|
+
JSON.stringify(node ? { assets, node } : { assets }, null, 2),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
295
364
|
console.log(out.join('\n'));
|
|
296
365
|
}
|
package/bin/incanto-playtest.mjs
CHANGED
|
@@ -133,4 +133,8 @@ if (!args.noReplays && !args.json) {
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
process.exit(
|
|
136
|
+
// `exitCode`, never `process.exit()`: stdout to a PIPE is written
|
|
137
|
+
// asynchronously, and exiting discards whatever has not flushed. A --json
|
|
138
|
+
// report read by another program came back truncated — silently, and only
|
|
139
|
+
// when piped, which is the only way a program reads it.
|
|
140
|
+
process.exitCode = report.runs.some((r) => r.outcome === 'won') ? 0 : 1;
|