@bitmagic/cli 0.1.39 → 0.1.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +27 -5
  2. package/dist/commands/forge.js +1 -1
  3. package/dist/commands/forge.js.map +1 -1
  4. package/dist/commands/verify.js +16 -3
  5. package/dist/commands/verify.js.map +1 -1
  6. package/dist/editor/asset-detail-panel.d.ts +22 -0
  7. package/dist/editor/asset-detail-panel.js +72 -0
  8. package/dist/editor/asset-detail-panel.js.map +1 -0
  9. package/dist/editor/asset-preview-page.d.ts +6 -0
  10. package/dist/editor/asset-preview-page.js +103 -0
  11. package/dist/editor/asset-preview-page.js.map +1 -0
  12. package/dist/editor/asset-viewer.d.ts +19 -0
  13. package/dist/editor/asset-viewer.js +218 -0
  14. package/dist/editor/asset-viewer.js.map +1 -0
  15. package/dist/editor/server.js +159 -3
  16. package/dist/editor/server.js.map +1 -1
  17. package/dist/editor/shell-page.js +412 -50
  18. package/dist/editor/shell-page.js.map +1 -1
  19. package/dist/forge/progress.d.ts +9 -1
  20. package/dist/forge/progress.js +8 -0
  21. package/dist/forge/progress.js.map +1 -1
  22. package/dist/forge/stream.js +6 -0
  23. package/dist/forge/stream.js.map +1 -1
  24. package/dist/project/jobs.d.ts +24 -1
  25. package/dist/project/jobs.js +77 -2
  26. package/dist/project/jobs.js.map +1 -1
  27. package/dist/scaffold/project-files.d.ts +6 -0
  28. package/dist/scaffold/project-files.js +7 -1
  29. package/dist/scaffold/project-files.js.map +1 -1
  30. package/dist/verify/browser.js +30 -0
  31. package/dist/verify/browser.js.map +1 -1
  32. package/dist/verify/classify.d.ts +4 -0
  33. package/dist/verify/classify.js +26 -0
  34. package/dist/verify/classify.js.map +1 -1
  35. package/dist/verify/events.d.ts +41 -0
  36. package/dist/verify/events.js +72 -0
  37. package/dist/verify/events.js.map +1 -0
  38. package/dist/verify/iterate.js +3 -1
  39. package/dist/verify/iterate.js.map +1 -1
  40. package/dist/verify/result.d.ts +5 -1
  41. package/dist/verify/result.js.map +1 -1
  42. package/dist/verify/url.d.ts +12 -0
  43. package/dist/verify/url.js +14 -0
  44. package/dist/verify/url.js.map +1 -0
  45. package/package.json +3 -2
@@ -48,8 +48,39 @@
48
48
  * nothing. Polling `CHECK_SCENE_CHANGES` is what the Creator does too, just on tab switch
49
49
  * instead of on a timer, and it covers drags, deletes, adds and inspector edits with one path.
50
50
  */
51
+ import { decodeForgeMapHeights, renderForgeMapRgba, } from '@bitmagic/world-forger/pipeline/forge-map-render.js';
52
+ import { FORGE_MAP_PALETTE } from '@bitmagic/world-forger/pipeline/layout-map.js';
53
+ import { ASSET_DETAIL_CSS, ASSET_DETAIL_HTML } from './asset-detail-panel.js';
51
54
  /** How often the shell asks the engine whether anything changed. A `Set` read; effectively free. */
52
55
  const POLL_INTERVAL_MS = 500;
56
+ /**
57
+ * What the map is drawn at, in device pixels — about twice the assets panel's inner width.
58
+ *
59
+ * Deliberately larger than it displays. A 128-sample raster drawn 1:1 and then stretched to the
60
+ * panel gives single-pixel object dots blown up to blocks; drawn at 2-3x and scaled DOWN by the
61
+ * browser, the same dots land as clean marks and the terrain keeps its shading.
62
+ */
63
+ const MAP_CANVAS_WIDTH = 464;
64
+ /**
65
+ * The forge map's renderer, as SOURCE, so this page and the Creator draw the same picture.
66
+ *
67
+ * This page has no module loader — it is a string this file assembles — so it cannot import the
68
+ * way the Creator does. The alternative to shipping the function's own text is a hand-written
69
+ * second implementation, and the first time the hillshade is tuned the two maps stop agreeing
70
+ * about what a level looks like.
71
+ *
72
+ * Two things make this safe rather than clever: `cli` builds with plain `tsc` and never minifies,
73
+ * and `forge-map-render.ts` is written to have no free identifiers (its header says so and says
74
+ * why), so its text is complete on its own. A test round-trips the interpolated copy against the
75
+ * imported one, so a change that breaks the trick fails there rather than in a creator's browser.
76
+ */
77
+ function forgeMapRendererSource() {
78
+ return [
79
+ `const FORGE_MAP_PALETTE = ${JSON.stringify(FORGE_MAP_PALETTE)};`,
80
+ `const decodeForgeMapHeights = ${decodeForgeMapHeights.toString()};`,
81
+ `const renderForgeMapRgba = ${renderForgeMapRgba.toString()};`,
82
+ ].join('\n ');
83
+ }
53
84
  /**
54
85
  * How recently a scene save must have landed for the creator to count as "still working".
55
86
  *
@@ -121,6 +152,8 @@ export function renderEditorShell(options) {
121
152
  #assets-head .count { margin-left: auto; color: #6a6a7a; letter-spacing: 0; }
122
153
  #assets-empty { padding: 14px 10px; color: #6a6a7a; }
123
154
  .row { display: flex; gap: 9px; padding: 8px 10px; border-bottom: 1px solid #1e1e26; }
155
+ .row.clickable { cursor: pointer; }
156
+ .row.clickable:hover, .row.clickable:focus-visible { background: #1b1b23; outline: none; }
124
157
  .row .thumb { width: 46px; height: 46px; flex: none; border-radius: 4px; background: #1e1e26;
125
158
  display: flex; align-items: center; justify-content: center; font-size: 18px;
126
159
  color: #55556a; overflow: hidden; }
@@ -139,14 +172,11 @@ export function renderEditorShell(options) {
139
172
  .row.job .thumb { color: #d9a441; }
140
173
  .row.job .message { color: #9a9aab; font-size: 11px; overflow: hidden; text-overflow: ellipsis;
141
174
  white-space: nowrap; }
142
- .row.job.failed .thumb { color: #e0564f; }
143
- .row.job.failed .message { color: #e0938e; white-space: normal; }
144
175
  /* The forge's five steps. A whole run is half an hour, so this is what the panel is for. */
145
176
  .steps { margin-top: 5px; font-size: 11px; }
146
177
  .step { display: flex; align-items: baseline; gap: 5px; color: #55556a; line-height: 1.6; }
147
178
  .step.running { color: #d9d9e2; }
148
179
  .step.done { color: #7c8f7f; }
149
- .step.stopped { color: #e0938e; }
150
180
  .step .glyph { width: 9px; flex: none; text-align: center; }
151
181
  .step .label { flex: none; }
152
182
  .step .tail { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis;
@@ -155,6 +185,12 @@ export function renderEditorShell(options) {
155
185
  .bar { height: 3px; margin: 3px 0 4px 14px; border-radius: 2px; background: #26262e;
156
186
  overflow: hidden; }
157
187
  .bar .fill { height: 100%; background: #d9a441; }
188
+ /* The level's shape, drawn from the forge artifact — twenty-plus minutes before it is playable.
189
+ image-rendering: pixelated because the canvas is drawn at an integer multiple of the raster
190
+ and any further scaling should keep the object dots as dots. */
191
+ .job-map { margin-top: 7px; }
192
+ .job-map canvas { display: block; width: 100%; height: auto; border-radius: 4px;
193
+ border: 1px solid #26262e; image-rendering: pixelated; }
158
194
  /* What the level being built contains — known minutes before the level itself exists. */
159
195
  .job-summary { margin-top: 6px; padding-top: 6px; border-top: 1px solid #1e1e26;
160
196
  color: #8a8a99; font-size: 11px; line-height: 1.6; }
@@ -177,6 +213,7 @@ export function renderEditorShell(options) {
177
213
  background: #101014; border: 1px solid #26262e; }
178
214
  #hq-note.show { display: block; }
179
215
  #hq-note code { display: block; margin-top: 6px; color: #9fd0a8; word-break: break-all; }
216
+ ${ASSET_DETAIL_CSS}
180
217
  </style>
181
218
  </head>
182
219
  <body>
@@ -235,12 +272,16 @@ export function renderEditorShell(options) {
235
272
  </div>
236
273
  </div>
237
274
  </div>
275
+ ${ASSET_DETAIL_HTML}
238
276
  <script>
239
277
  (function () {
240
278
  'use strict';
241
279
  var GAME_URL = ${JSON.stringify(gameUrl)};
242
280
  var POLL_MS = ${POLL_INTERVAL_MS};
243
281
  var ACTIVE_EDIT_MS = ${ACTIVE_EDIT_WINDOW_MS};
282
+ var MAP_CANVAS_WIDTH = ${MAP_CANVAS_WIDTH};
283
+ // The forge map's renderer, shared verbatim with the Creator — see forgeMapRendererSource().
284
+ ${forgeMapRendererSource()}
244
285
  var TAB_KEY = 'bitmagic.dev.tab';
245
286
  var AUTO_KEY = 'bitmagic.dev.autoReload';
246
287
  var ASSETS_KEY = 'bitmagic.dev.assets';
@@ -330,6 +371,13 @@ export function renderEditorShell(options) {
330
371
  var message = event.data;
331
372
  if (!message || typeof message.type !== 'string') return;
332
373
 
374
+ // The asset overlay's viewer is a second sender on this window. Its replies are routed by
375
+ // source, before the waiter list below gets to match anything on type alone.
376
+ if (assetFrame && event.source === assetFrame.contentWindow) {
377
+ onAssetPreviewMessage(message);
378
+ return;
379
+ }
380
+
333
381
  waiters = waiters.filter(function (waiter) {
334
382
  if (waiter.type !== message.type) return true;
335
383
  clearTimeout(waiter.timer);
@@ -836,7 +884,7 @@ export function renderEditorShell(options) {
836
884
  return typeof value === 'number' && isFinite(value) ? value.toLocaleString() : '';
837
885
  }
838
886
 
839
- var STEP_GLYPHS = { pending: '○', running: '⟳', done: '✓', stopped: '✕' };
887
+ var STEP_GLYPHS = { pending: '○', running: '⟳', done: '✓' };
840
888
 
841
889
  /**
842
890
  * The trailing text for one step: its duration once finished, its counts while it runs.
@@ -861,14 +909,11 @@ export function renderEditorShell(options) {
861
909
  * snapshot (cli/src/forge/progress.ts), so this draws one list and never has to know which
862
910
  * machine a row came from.
863
911
  */
864
- function stepsBlock(progress, jobFailed) {
912
+ function stepsBlock(progress) {
865
913
  var block = el('div', 'steps');
866
914
  for (var i = 0; i < progress.steps.length; i++) {
867
915
  var step = progress.steps[i];
868
- // A failed job leaves its running step running forever — the process died before it could
869
- // say otherwise. The job's own status is the authority, so the row that was in flight is the
870
- // one that stopped.
871
- var status = jobFailed && step.status === 'running' ? 'stopped' : step.status;
916
+ var status = step.status;
872
917
  var line = el('div', 'step ' + status);
873
918
  line.appendChild(el('span', 'glyph' + (status === 'running' ? ' spin' : ''), STEP_GLYPHS[status]));
874
919
  line.appendChild(el('span', 'label', step.label + (step.resumed ? ' (resumed)' : '')));
@@ -888,6 +933,53 @@ export function renderEditorShell(options) {
888
933
  return block;
889
934
  }
890
935
 
936
+ /**
937
+ * A forge's map, fetched once per job and drawn into a canvas.
938
+ *
939
+ * Fetched rather than carried on the job record, because the record is re-read at 1 Hz for the
940
+ * whole run and the map never changes after it arrives. Cached by job id so the panel's rebuild
941
+ * (which throws every row away and makes new ones) does not re-request it every second.
942
+ */
943
+ var mapCache = {};
944
+ var mapFetching = {};
945
+
946
+ function drawMap(canvas, map) {
947
+ var scale = Math.max(1, Math.floor(MAP_CANVAS_WIDTH / Math.max(map.width, map.height)));
948
+ var render = renderForgeMapRgba(map, decodeForgeMapHeights(map), FORGE_MAP_PALETTE, scale);
949
+ canvas.width = render.width;
950
+ canvas.height = render.height;
951
+ var ctx = canvas.getContext('2d');
952
+ if (!ctx) return;
953
+ var image = ctx.createImageData(render.width, render.height);
954
+ image.data.set(render.rgba);
955
+ ctx.putImageData(image, 0, 0);
956
+ }
957
+
958
+ function mapBlock(jobId) {
959
+ var block = el('div', 'job-map');
960
+ var canvas = document.createElement('canvas');
961
+ block.appendChild(canvas);
962
+ var cached = mapCache[jobId];
963
+ if (cached) {
964
+ drawMap(canvas, cached);
965
+ return block;
966
+ }
967
+ if (!mapFetching[jobId]) {
968
+ mapFetching[jobId] = true;
969
+ fetch('/api/jobs/' + encodeURIComponent(jobId) + '/layout')
970
+ .then(function (r) { return r.ok ? r.json() : null; })
971
+ .then(function (payload) {
972
+ if (!payload || !payload.layout) return;
973
+ mapCache[jobId] = payload.layout;
974
+ // Re-render rather than drawing into this canvas: by the time the fetch lands the 1 Hz
975
+ // ticker has almost certainly replaced the row this element belongs to.
976
+ void refreshAssets();
977
+ })
978
+ .catch(function () { /* no map is a panel without a picture, never an error to show */ });
979
+ }
980
+ return block;
981
+ }
982
+
891
983
  /** What the level contains — the answer that exists long before the level does. */
892
984
  function summaryBlock(summary) {
893
985
  var headline = [];
@@ -911,50 +1003,81 @@ export function renderEditorShell(options) {
911
1003
  return block;
912
1004
  }
913
1005
 
1006
+ /**
1007
+ * A generation in flight. Only ever in flight: the sidecar serves running jobs and nothing else,
1008
+ * because a failure is a tool diagnostic for whoever ran the command — see project/jobs.ts. A job
1009
+ * that fails leaves this list the same way one that succeeds does, by no longer being in it.
1010
+ */
914
1011
  function jobRow(job) {
915
- var failed = job.status === 'failed';
916
- var row = el('div', 'row job' + (failed ? ' failed' : ''));
1012
+ var row = el('div', 'row job');
917
1013
  var thumb = el('div', 'thumb');
918
- thumb.appendChild(el('span', failed ? '' : 'spin', failed ? '!' : '⟳'));
1014
+ thumb.appendChild(el('span', 'spin', '⟳'));
919
1015
  row.appendChild(thumb);
920
1016
  var body = el('div', 'body');
921
1017
  body.appendChild(el('div', 'name', job.label || job.kind));
922
- var meta = el('div', 'meta', job.kind + (failed ? '' : ' · ' + formatElapsed(job.startedAt)));
923
- body.appendChild(meta);
1018
+ body.appendChild(el('div', 'meta', job.kind + ' · ' + formatElapsed(job.startedAt)));
924
1019
  var progress = job.progress;
925
1020
  var hasSteps = progress && Array.isArray(progress.steps) && progress.steps.length > 0;
926
- // The single-line message stays the whole story for every kind that has no steps, and stays
927
- // the FAILURE story even for a forge: the step list says which step stopped, never why.
928
- if (failed || !hasSteps) {
929
- body.appendChild(el('div', 'message', failed ? (job.error || 'Failed') : (job.message || '')));
930
- }
1021
+ // A forge is the one kind with more than a sentence to say. Everything else keeps the single
1022
+ // line, which for those is the whole story.
931
1023
  if (hasSteps) {
932
- body.appendChild(stepsBlock(progress, failed));
1024
+ body.appendChild(stepsBlock(progress));
933
1025
  var summary = progress.summary ? summaryBlock(progress.summary) : null;
934
1026
  if (summary) body.appendChild(summary);
1027
+ if (job.hasLayout) body.appendChild(mapBlock(job.jobId));
1028
+ } else {
1029
+ body.appendChild(el('div', 'message', job.message || ''));
935
1030
  }
936
1031
  row.appendChild(body);
937
1032
  return row;
938
1033
  }
939
1034
 
940
1035
  /**
941
- * The URL the engine should draw a thumbnail from, or null when there is nothing to draw.
1036
+ * How the engine should draw this asset's thumbnail, or null when there is nothing to draw.
1037
+ *
1038
+ * 'sourceGlbUrl' before 'url' because many CLI-lane meshes are voxelized: 'url' is then a .vxl,
1039
+ * and the GLB the forger started from renders in one message with nothing to fetch here.
942
1040
  *
943
- * 'sourceGlbUrl' before 'url' because most CLI-lane meshes are voxelized: 'url' is then a .vxl,
944
- * which the GLB preview renderer cannot read, while 'sourceGlbUrl' is the original the forger or
945
- * the generator started from. An asset with neither a sound, an animation, a level — keeps its
946
- * glyph, which is the honest answer for something with no appearance.
1041
+ * A .vxl with NO GLB source used to fall through to a glyph, and that was most of the panel —
1042
+ * across the sampled project worlds it is 153 such assets against 144 with a GLB source, and
1043
+ * every fresh project starts in the bad half: templates/standard-3d ships nine assets, all vxl,
1044
+ * none with a sourceGlbUrl or a screenshotUrl. So the whole assets panel of a new project was
1045
+ * grey boxes. Those now go the voxel route, which the engine has been able to draw the entire
1046
+ * time (VoxelMessageHandlers.handleGenerateVoxelPreview) and which the web Creator has used since
1047
+ * before this lane existed.
1048
+ *
1049
+ * An asset with neither — a sound, an animation, a level — keeps its glyph, which is the honest
1050
+ * answer for something with no appearance.
947
1051
  */
948
- function previewSourceFor(asset) {
949
- var url = asset.sourceGlbUrl || asset.url;
950
- if (typeof url !== 'string' || url === '') return null;
1052
+ var MAX_VXL_PREVIEW_BYTES = 8 * 1024 * 1024;
1053
+
1054
+ function previewJobFor(asset) {
1055
+ var glb = asset.sourceGlbUrl || asset.url;
951
1056
  // Double-escaped because this whole page is a template literal: a single backslash is consumed
952
1057
  // by the literal, and the browser would receive the invalid regex /.(glb|gltf)(?|$)/.
953
- return /\\.(glb|gltf)(\\?|$)/i.test(url) ? url : null;
1058
+ if (typeof glb === 'string' && /\\.(glb|gltf)(\\?|$)/i.test(glb)) return { kind: 'glb', url: glb };
1059
+ var url = asset.url;
1060
+ if (typeof url !== 'string' || !/\\.vxl(\\?|$)/i.test(url)) return null;
1061
+ // The voxel message carries the FILE, so this one costs a fetch and a structured clone of the
1062
+ // whole thing. The templates' files are 0.5-2.5 KB; a multi-megabyte one is a hitch on the
1063
+ // render thread in exchange for a 46px tile, and the glyph is the better trade.
1064
+ if (typeof asset.size === 'number' && asset.size > MAX_VXL_PREVIEW_BYTES) return null;
1065
+ return { kind: 'vxl', url: url };
954
1066
  }
955
1067
 
956
1068
  function assetRow(asset, isFresh, hasPreview) {
957
- var row = el('div', 'row' + (isFresh ? ' fresh' : ''));
1069
+ var row = el('div', 'row clickable' + (isFresh ? ' fresh' : ''));
1070
+ // A row is a button: the panel is a list of things you can look at. Job rows deliberately are
1071
+ // not — there is nothing to show for an asset that does not exist yet.
1072
+ row.setAttribute('role', 'button');
1073
+ row.setAttribute('tabindex', '0');
1074
+ row.title = 'Open a preview of this asset';
1075
+ row.addEventListener('click', function () { openAssetDetail(asset, hasPreview); });
1076
+ row.addEventListener('keydown', function (event) {
1077
+ if (event.key !== 'Enter' && event.key !== ' ') return;
1078
+ event.preventDefault();
1079
+ openAssetDetail(asset, hasPreview);
1080
+ });
958
1081
  var thumb = el('div', 'thumb');
959
1082
  var type = String(asset.type || '').toLowerCase();
960
1083
  if (type === 'image' && typeof asset.url === 'string' && asset.url !== '') {
@@ -972,7 +1095,7 @@ export function renderEditorShell(options) {
972
1095
  thumb.appendChild(cached);
973
1096
  } else {
974
1097
  thumb.appendChild(el('span', '', glyphFor(asset.type)));
975
- if (previewSourceFor(asset) !== null) queuePreview(asset);
1098
+ if (previewJobFor(asset) !== null) queuePreview(asset);
976
1099
  }
977
1100
  row.appendChild(thumb);
978
1101
 
@@ -1035,26 +1158,71 @@ export function renderEditorShell(options) {
1035
1158
  }
1036
1159
 
1037
1160
  /**
1038
- * Ask the game to draw the thumbnails the cache is missing — one at a time, and never twice for
1161
+ * Ask the game to draw the thumbnails the cache is missing — one at a time, and at most twice for
1039
1162
  * the same asset in one session.
1040
1163
  *
1041
- * Serial on purpose. Each render is a GLB fetch and a draw in the SAME renderer the creator is
1042
- * playing in, and a forged city has hundreds of assets: firing them all at once would stutter the
1043
- * game to decorate a side panel. 'attempted' covers the failures too, so an asset whose GLB 404s
1044
- * is asked for once rather than on every render pass forever.
1164
+ * Serial on purpose. Each render is a fetch and a draw in the SAME renderer the creator is playing
1165
+ * in, and a forged city has hundreds of assets: firing them all at once would stutter the game to
1166
+ * decorate a side panel.
1167
+ *
1168
+ * Two rather than one attempt, and the second is not defensive padding. GENERATE_VOXEL_PREVIEW is
1169
+ * in the engine's REQUIRES_GAME_LOADED set (the GLB one is not), so a request arriving while a
1170
+ * game is loading is QUEUED and silently never answered. Marked attempted-once, that asset would
1171
+ * keep its glyph for the rest of the session; the retry costs one extra message and the cap keeps
1172
+ * a genuinely broken asset from asking forever.
1045
1173
  */
1174
+ var MAX_PREVIEW_ATTEMPTS = 2;
1046
1175
  var previewQueue = [];
1047
- var previewAttempted = {};
1176
+ var previewAttempts = {};
1048
1177
  var previewRunning = false;
1049
1178
  var previewRequestId = 0;
1050
1179
 
1051
1180
  function queuePreview(asset) {
1052
- if (previewAttempted[asset.id]) return;
1053
- previewAttempted[asset.id] = true;
1181
+ var attempts = previewAttempts[asset.id] || 0;
1182
+ if (attempts >= MAX_PREVIEW_ATTEMPTS) return;
1183
+ previewAttempts[asset.id] = attempts + 1;
1054
1184
  previewQueue.push(asset);
1055
1185
  void drainPreviewQueue();
1056
1186
  }
1057
1187
 
1188
+ /**
1189
+ * Both preview messages use the FLAT envelope and both are answered by requestId, so neither can
1190
+ * go through the request() helper — it matches on type alone.
1191
+ */
1192
+ function requestGlbPreview(url, requestId) {
1193
+ postFlat({ type: 'GENERATE_GLB_PREVIEW', requestId: requestId, glbUrl: url });
1194
+ return await_('GLB_PREVIEW_RESPONSE', 15000);
1195
+ }
1196
+
1197
+ /**
1198
+ * The voxel counterpart, ported from the web Creator's useAssetsEditor.requestVoxelPreview.
1199
+ *
1200
+ * The bytes come through the sidecar rather than straight from the asset host: this message
1201
+ * carries the file itself, and a browser fetch would make voxel thumbnails depend on that host's
1202
+ * CORS headers — see /api/assets/source.
1203
+ */
1204
+ async function requestVoxelPreview(url, requestId) {
1205
+ var bytes;
1206
+ try {
1207
+ var source = await fetch('/api/assets/source?url=' + encodeURIComponent(url));
1208
+ if (!source.ok) throw new Error('sidecar answered ' + source.status);
1209
+ bytes = new Uint8Array(await source.arrayBuffer());
1210
+ } catch (error) {
1211
+ // Worth a line: without one, an asset the sidecar cannot reach is a grey tile with no reason.
1212
+ console.warn('[bitmagic dev] no voxel preview for ' + url, error);
1213
+ return null;
1214
+ }
1215
+ postFlat({
1216
+ type: 'GENERATE_VOXEL_PREVIEW',
1217
+ requestId: requestId,
1218
+ // A plain number array, which is what the engine handler reads (new Uint8Array(data.vxlData)).
1219
+ // Not an ArrayBuffer: this page ships with the CLI and talks to whatever engine the project
1220
+ // has vendored, so the wire shape has to keep working against older ones.
1221
+ vxlData: Array.prototype.slice.call(bytes)
1222
+ });
1223
+ return await_('VOXEL_PREVIEW_RESPONSE', 15000);
1224
+ }
1225
+
1058
1226
  async function drainPreviewQueue() {
1059
1227
  if (previewRunning) return;
1060
1228
  previewRunning = true;
@@ -1065,20 +1233,24 @@ export function renderEditorShell(options) {
1065
1233
  // pass rather than being dropped — refreshAssets re-queues on every render.
1066
1234
  if (!loaded) return;
1067
1235
  var asset = previewQueue.shift();
1068
- var source = previewSourceFor(asset);
1069
- if (source === null) continue;
1236
+ var job = previewJobFor(asset);
1237
+ if (job === null) continue;
1070
1238
 
1071
1239
  previewRequestId += 1;
1072
- // Deliberately not the request() helper: this message uses the FLAT envelope, and the reply
1073
- // has to be matched on requestId (below) rather than on type alone.
1074
1240
  var requestId = 'preview-' + previewRequestId;
1075
- postFlat({ type: 'GENERATE_GLB_PREVIEW', requestId: requestId, glbUrl: source });
1076
- var response = await await_('GLB_PREVIEW_RESPONSE', 15000);
1077
- // Verified rather than assumed: a late answer from a request that already timed out would
1078
- // otherwise be cached under the NEXT asset's id, which is a wrong picture rather than a
1079
- // missing one much the worse failure.
1080
- if (!response || response.requestId !== requestId) continue;
1081
- if (!response.success || !response.previewBase64) continue;
1241
+ var response = job.kind === 'vxl'
1242
+ ? await requestVoxelPreview(job.url, requestId)
1243
+ : await requestGlbPreview(job.url, requestId);
1244
+
1245
+ // The requestId is verified rather than assumed: a late answer from a request that already
1246
+ // timed out would otherwise be cached under the NEXT asset's id, which is a wrong picture
1247
+ // rather than a missing one — much the worse failure.
1248
+ if (!response || response.requestId !== requestId || !response.success || !response.previewBase64) {
1249
+ // Back of the queue, behind everything else, and only while attempts remain. This is the
1250
+ // path a request swallowed by REQUIRES_GAME_LOADED takes.
1251
+ queuePreview(asset);
1252
+ continue;
1253
+ }
1082
1254
 
1083
1255
  var saved = await (await fetch('/api/assets/preview', {
1084
1256
  method: 'POST',
@@ -1126,6 +1298,196 @@ export function renderEditorShell(options) {
1126
1298
  }, 1000);
1127
1299
  }
1128
1300
 
1301
+ /**
1302
+ * The asset overlay — everything a 46px tile cannot show.
1303
+ *
1304
+ * The viewer itself is an iframe onto '/asset-preview', which the dev sidecar serves from this
1305
+ * same origin. It is not part of the game: the game keeps its own WebGL context in the frame
1306
+ * behind this dialog, and the two never share a renderer.
1307
+ */
1308
+ var assetOverlay = document.getElementById('asset-overlay');
1309
+ var assetFrame = document.getElementById('asset-frame');
1310
+ var assetImage = document.getElementById('asset-image');
1311
+ var assetAudio = document.getElementById('asset-audio');
1312
+ var assetNote = document.getElementById('asset-note');
1313
+ var assetFacts = document.getElementById('asset-facts');
1314
+ var assetResetButton = document.getElementById('asset-reset');
1315
+ var assetPausedByOverlay = false;
1316
+ var assetReturnFocus = null;
1317
+ var assetLoadTimer = null;
1318
+
1319
+ var IMAGE_TYPES = { image: 1, skybox: 1 };
1320
+ var AUDIO_TYPES = { audio: 1, sound: 1, 'sound-effect': 1 };
1321
+
1322
+ /**
1323
+ * What the VIEWER should load — which is not always what the thumbnail drew.
1324
+ *
1325
+ * previewJobFor prefers a voxelized asset's GLB source, because one message with no fetch is the
1326
+ * cheaper way to fill a 46px tile. Here the creator has asked to look at a specific asset, so it
1327
+ * shows the asset: a .vxl is drawn as voxels even when the mesh it was baked from is on record.
1328
+ */
1329
+ function detailJobFor(asset) {
1330
+ var url = asset.url;
1331
+ if (typeof url === 'string' && /\\.vxl(\\?|$)/i.test(url)) {
1332
+ if (typeof asset.size === 'number' && asset.size > MAX_VXL_PREVIEW_BYTES) return null;
1333
+ return { kind: 'vxl', url: url };
1334
+ }
1335
+ var glb = asset.sourceGlbUrl || url;
1336
+ if (typeof glb === 'string' && /\\.(glb|gltf)(\\?|$)/i.test(glb)) return { kind: 'glb', url: glb };
1337
+ return null;
1338
+ }
1339
+
1340
+ function fact(term, value) {
1341
+ if (!value) return;
1342
+ assetFacts.appendChild(el('dt', '', term));
1343
+ assetFacts.appendChild(el('dd', term === 'Id' ? 'mono' : '', value));
1344
+ }
1345
+
1346
+ /** Extent in world units, which is the number that says whether a prop is door-sized. */
1347
+ function dimensionsOf(asset) {
1348
+ var box = asset.boundingBox;
1349
+ if (!box || typeof box.minX !== 'number') return '';
1350
+ var round = function (n) { return Math.round(n * 10) / 10; };
1351
+ return round(box.maxX - box.minX) + ' × ' + round(box.maxY - box.minY) + ' × ' + round(box.maxZ - box.minZ);
1352
+ }
1353
+
1354
+ function showAssetNote(text) {
1355
+ assetNote.textContent = text;
1356
+ assetNote.classList.remove('hidden');
1357
+ }
1358
+
1359
+ function openAssetDetail(asset, hasPreview) {
1360
+ assetReturnFocus = document.activeElement;
1361
+ document.getElementById('asset-name').textContent = asset.name || asset.id || 'unnamed';
1362
+ document.getElementById('asset-sub').textContent =
1363
+ (asset.hqGeneratedAt ? 'High quality · ' : asset.placeholder === true ? 'Placeholder · ' : '')
1364
+ + (asset.type || 'unknown');
1365
+
1366
+ assetFacts.textContent = '';
1367
+ fact('Size', formatSize(asset.size));
1368
+ fact('Extent', dimensionsOf(asset));
1369
+ fact('Voxels', typeof asset.voxelCount === 'number' ? String(asset.voxelCount) : '');
1370
+ fact('Id', asset.id || '');
1371
+
1372
+ assetNote.classList.add('hidden');
1373
+ assetFrame.classList.add('hidden');
1374
+ assetImage.classList.add('hidden');
1375
+ assetAudio.classList.add('hidden');
1376
+ assetResetButton.classList.add('hidden');
1377
+ if (assetLoadTimer) clearTimeout(assetLoadTimer);
1378
+
1379
+ var type = String(asset.type || '').toLowerCase();
1380
+ var job = detailJobFor(asset);
1381
+
1382
+ if (IMAGE_TYPES[type] === 1 && typeof asset.url === 'string' && asset.url !== '') {
1383
+ // An image is already the thing. Loading a 3D viewer to show a picture would be theatre.
1384
+ assetImage.src = asset.url;
1385
+ assetImage.classList.remove('hidden');
1386
+ } else if (AUDIO_TYPES[type] === 1 && typeof asset.url === 'string' && asset.url !== '') {
1387
+ assetAudio.src = asset.url;
1388
+ assetAudio.classList.remove('hidden');
1389
+ } else if (job !== null) {
1390
+ assetResetButton.classList.remove('hidden');
1391
+ assetFrame.classList.remove('hidden');
1392
+ assetFrame.src = '/asset-preview?kind=' + job.kind + '&url=' + encodeURIComponent(job.url);
1393
+ // Not a deadline for the load — a big mesh over a slow CDN legitimately takes a while, and
1394
+ // replacing a working viewer with an apology would be worse than waiting. This only speaks
1395
+ // when the page has said nothing at all, which is what a module graph that failed to load
1396
+ // looks like: the page's own catch reports every error it can see, and cannot report that.
1397
+ assetLoadTimer = setTimeout(function () {
1398
+ showAssetNote('Still loading. If this does not clear, check the dev server output.');
1399
+ }, 12000);
1400
+ } else {
1401
+ showAssetThumbnailFallback(asset, hasPreview,
1402
+ 'There is nothing to draw for this asset type — it has no mesh and no picture.');
1403
+ }
1404
+
1405
+ assetOverlay.classList.add('show');
1406
+ document.getElementById('asset-close').focus();
1407
+
1408
+ // The game is a WebGL context and a physics step running behind a dialog nobody is looking at.
1409
+ // The Editor tab is already paused by applyTab, so this only fires on the Game tab.
1410
+ if (activeTab === 'game' && gameState === 'playing') {
1411
+ post('SET_PAUSE', { paused: true });
1412
+ assetPausedByOverlay = true;
1413
+ }
1414
+ }
1415
+
1416
+ /** When the viewer cannot show it, show the tile that was already rendered, and say why. */
1417
+ function showAssetThumbnailFallback(asset, hasPreview, reason) {
1418
+ assetFrame.classList.add('hidden');
1419
+ assetResetButton.classList.add('hidden');
1420
+ if (hasPreview) {
1421
+ assetImage.src = '/api/assets/preview/' + encodeURIComponent(asset.id) + '.png';
1422
+ assetImage.classList.remove('hidden');
1423
+ }
1424
+ showAssetNote(reason);
1425
+ }
1426
+
1427
+ function closeAssetDetail() {
1428
+ if (!assetOverlay.classList.contains('show')) return false;
1429
+ assetOverlay.classList.remove('show');
1430
+ if (assetLoadTimer) clearTimeout(assetLoadTimer);
1431
+ // about:blank rather than hiding it: an idle iframe would hold a WebGL context and a render
1432
+ // loop for the rest of the session, next to the game that needs both.
1433
+ assetFrame.src = 'about:blank';
1434
+ assetImage.removeAttribute('src');
1435
+ assetAudio.pause();
1436
+ assetAudio.removeAttribute('src');
1437
+ if (assetPausedByOverlay) {
1438
+ post('SET_PAUSE', { paused: false });
1439
+ assetPausedByOverlay = false;
1440
+ }
1441
+ if (assetReturnFocus && assetReturnFocus.focus) assetReturnFocus.focus();
1442
+ assetReturnFocus = null;
1443
+ return true;
1444
+ }
1445
+
1446
+ /**
1447
+ * Replies from the preview iframe, matched by SOURCE rather than by type.
1448
+ *
1449
+ * The message listener below resolves its waiters on type alone, so a second sender on this
1450
+ * window could answer a request meant for the game. Today their vocabularies do not overlap;
1451
+ * this is what keeps that from being a silent bug the day one of them gains a name the other
1452
+ * already uses.
1453
+ */
1454
+ function onAssetPreviewMessage(message) {
1455
+ if (message.type === 'ASSET_PREVIEW_READY') {
1456
+ if (assetLoadTimer) clearTimeout(assetLoadTimer);
1457
+ assetNote.classList.add('hidden');
1458
+ if (typeof message.triangles === 'number' && message.triangles > 0) {
1459
+ fact('Triangles', message.triangles.toLocaleString());
1460
+ }
1461
+ } else if (message.type === 'ASSET_PREVIEW_FAILED') {
1462
+ if (assetLoadTimer) clearTimeout(assetLoadTimer);
1463
+ assetFrame.src = 'about:blank';
1464
+ showAssetNote(message.error || 'The preview could not be loaded.');
1465
+ }
1466
+ }
1467
+
1468
+ assetResetButton.addEventListener('click', function () {
1469
+ if (assetFrame.contentWindow) {
1470
+ assetFrame.contentWindow.postMessage({ type: 'RESET_VIEW' }, window.location.origin);
1471
+ }
1472
+ });
1473
+ document.getElementById('asset-copy').addEventListener('click', function () {
1474
+ var id = assetFacts.querySelector('dd.mono');
1475
+ if (id && navigator.clipboard) void navigator.clipboard.writeText(id.textContent);
1476
+ });
1477
+ document.getElementById('asset-close').addEventListener('click', closeAssetDetail);
1478
+ assetOverlay.addEventListener('mousedown', function (event) {
1479
+ // mousedown, not click: a drag that starts inside the viewer and ends on the backdrop is a
1480
+ // creator turning the model, not a creator dismissing the dialog.
1481
+ if (event.target === assetOverlay) closeAssetDetail();
1482
+ });
1483
+
1484
+ // The page had no keyboard handling at all before this, so #hq-overlay gains Escape with it.
1485
+ document.addEventListener('keydown', function (event) {
1486
+ if (event.key !== 'Escape') return;
1487
+ if (closeAssetDetail()) return;
1488
+ hqOverlay.classList.remove('show');
1489
+ });
1490
+
1129
1491
  /**
1130
1492
  * Lend the cached thumbnails to the engine's own asset palette.
1131
1493
  *
@@ -1 +1 @@
1
- {"version":3,"file":"shell-page.js","sourceRoot":"","sources":["../../src/editor/shell-page.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,oGAAoG;AACpG,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAE7B;;;;;;GAMG;AACH,MAAM,qBAAqB,GAAG,IAAI,CAAC;AAEnC,kGAAkG;AAClG,SAAS,UAAU,CAAC,KAAa;IAC/B,OAAO,KAAK;SACT,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC;SACvB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;AAC3B,CAAC;AASD,MAAM,UAAU,iBAAiB,CAAC,OAA2B;IAC3D,MAAM,OAAO,GAAG,oBAAoB,OAAO,CAAC,QAAQ,2BAA2B,kBAAkB,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;IACpH,OAAO;;;;wBAIe,UAAU,CAAC,OAAO,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;uBA8G3B,UAAU,CAAC,OAAO,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mBAwD9B,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;kBACxB,gBAAgB;yBACT,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgnC7C,CAAC;AACF,CAAC"}
1
+ {"version":3,"file":"shell-page.js","sourceRoot":"","sources":["../../src/editor/shell-page.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,OAAO,EACL,qBAAqB,EAAE,kBAAkB,GAC1C,MAAM,qDAAqD,CAAC;AAC7D,OAAO,EAAE,iBAAiB,EAAE,MAAM,+CAA+C,CAAC;AAClF,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAE9E,oGAAoG;AACpG,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAE7B;;;;;;GAMG;AACH,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAE7B;;;;;;;;;;;;GAYG;AACH,SAAS,sBAAsB;IAC7B,OAAO;QACL,6BAA6B,IAAI,CAAC,SAAS,CAAC,iBAAiB,CAAC,GAAG;QACjE,iCAAiC,qBAAqB,CAAC,QAAQ,EAAE,GAAG;QACpE,8BAA8B,kBAAkB,CAAC,QAAQ,EAAE,GAAG;KAC/D,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AACjB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,qBAAqB,GAAG,IAAI,CAAC;AAEnC,kGAAkG;AAClG,SAAS,UAAU,CAAC,KAAa;IAC/B,OAAO,KAAK;SACT,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC;SACvB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;AAC3B,CAAC;AASD,MAAM,UAAU,iBAAiB,CAAC,OAA2B;IAC3D,MAAM,OAAO,GAAG,oBAAoB,OAAO,CAAC,QAAQ,2BAA2B,kBAAkB,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;IACpH,OAAO;;;;wBAIe,UAAU,CAAC,OAAO,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA8GhD,gBAAgB;;;;;;uBAMK,UAAU,CAAC,OAAO,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAqD/C,iBAAiB;;;;mBAIA,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;kBACxB,gBAAgB;yBACT,qBAAqB;2BACnB,gBAAgB;;IAEvC,sBAAsB,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAi7C3B,CAAC;AACF,CAAC"}
@@ -25,7 +25,7 @@
25
25
  * (`ForgeTransportOptions.onProgress`), so the counts arrive as ordinary step events and the
26
26
  * second, parallel path is gone.
27
27
  */
28
- import { type ForgeProgressEvent, type ForgeProgressSummary, type ForgeStepKey } from '@bitmagic/world-forger/pipeline/index.js';
28
+ import { type ForgeLayoutMap, type ForgeProgressEvent, type ForgeProgressSummary, type ForgeStepKey } from '@bitmagic/world-forger/pipeline/index.js';
29
29
  export type ForgeStepStatus = 'pending' | 'running' | 'done';
30
30
  export interface ForgeStepState {
31
31
  key: ForgeStepKey;
@@ -59,6 +59,14 @@ export interface ForgeProgressTrackerOptions {
59
59
  log: (message: string) => void;
60
60
  /** Job-record writer. Receives a fresh snapshot; may be omitted when nothing is watching. */
61
61
  report?: (snapshot: ForgeProgressSnapshot) => void;
62
+ /**
63
+ * The level's map, handed over the once it arrives.
64
+ *
65
+ * A separate sink rather than a field on the snapshot, and that is the point: the snapshot is
66
+ * rewritten on every tick, and ~25KB of raster in it would be rewritten with it for the rest of
67
+ * a thirty-minute run. Routing it out here makes "written once" structural.
68
+ */
69
+ onLayout?: (layout: ForgeLayoutMap) => void;
62
70
  now?: () => number;
63
71
  /** Overridden in tests. */
64
72
  logIntervalMs?: number;