@bitmagic/cli 0.1.18 → 0.1.20
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 +58 -1
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/edit.d.ts +10 -0
- package/dist/commands/edit.js +147 -0
- package/dist/commands/edit.js.map +1 -0
- package/dist/commands/generate.d.ts +15 -0
- package/dist/commands/generate.js +87 -0
- package/dist/commands/generate.js.map +1 -1
- package/dist/editor/journal.d.ts +108 -0
- package/dist/editor/journal.js +214 -0
- package/dist/editor/journal.js.map +1 -0
- package/dist/editor/save.d.ts +57 -0
- package/dist/editor/save.js +144 -0
- package/dist/editor/save.js.map +1 -0
- package/dist/editor/server.d.ts +24 -0
- package/dist/editor/server.js +306 -0
- package/dist/editor/server.js.map +1 -0
- package/dist/editor/shell-page.d.ts +38 -0
- package/dist/editor/shell-page.js +455 -0
- package/dist/editor/shell-page.js.map +1 -0
- package/dist/generate/prop.d.ts +47 -0
- package/dist/generate/prop.js +208 -0
- package/dist/generate/prop.js.map +1 -0
- package/dist/generate/stream.d.ts +6 -0
- package/dist/generate/stream.js +7 -2
- package/dist/generate/stream.js.map +1 -1
- package/dist/scaffold/project-files.js +42 -0
- package/dist/scaffold/project-files.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor shell: the page that plays the Creator's role for a CLI-lane project.
|
|
3
|
+
*
|
|
4
|
+
* The whole feature rests on one fact — the visual editor is ALREADY in every scaffolded project.
|
|
5
|
+
* `VENDORED_DIRS` in `scaffold/project.ts` ships `engine/editor/` and `engine/debug/`, and
|
|
6
|
+
* `GameEngine` constructs `EditorManager` unconditionally, building the transform gizmo, the object
|
|
7
|
+
* inspector and the scene hierarchy into a hidden `#debug-container`. What the pro lane lacked was
|
|
8
|
+
* a parent frame speaking the Creator's `postMessage` protocol. This page is that frame, and
|
|
9
|
+
* nothing under `game/` changes to support it.
|
|
10
|
+
*
|
|
11
|
+
* Rendered from TypeScript rather than shipped as a `.html` asset because the package builds with
|
|
12
|
+
* plain `tsc`, which copies no static files. `smoke/harness.ts` and `scaffold/project-files.ts`
|
|
13
|
+
* both do the same.
|
|
14
|
+
*
|
|
15
|
+
* ── Four things that fail silently if changed ────────────────────────────────────────────────
|
|
16
|
+
*
|
|
17
|
+
* 1. `?source=creator` on the iframe URL. Without it `isCreatorMode` is false
|
|
18
|
+
* (`game/src/engine/CreatorMode.ts`), the engine never registers its message listener, and
|
|
19
|
+
* every message below is discarded with no error anywhere.
|
|
20
|
+
* 2. `GAME_TEMPLATE_READY` must arrive before `LOAD_GAME` is posted. The engine registers its
|
|
21
|
+
* listener only after `await initI18n()`, and a `LOAD_GAME` landing before that is DROPPED,
|
|
22
|
+
* not queued — the symptom is a game that never loads, pointing at the wrong culprit.
|
|
23
|
+
* 3. `REQUEST_ASSETS` / `ADD_OBJECT` / `MARK_OBJECT_MODIFIED` use a FLAT envelope
|
|
24
|
+
* (`{ type, assets }`), not the `{ type, data }` one the rest of the protocol uses. That is
|
|
25
|
+
* the Creator's existing shape (`useIframeMessages.ts:1149`, `:332`) and the engine reads the
|
|
26
|
+
* fields off the message directly.
|
|
27
|
+
* 4. The autosave poll, rather than an event. `TransformControlsManager`'s mouseUp reaches
|
|
28
|
+
* `EditorManager.commitTransformChange()`, which only mutates a `Set` — the engine posts
|
|
29
|
+
* nothing. Polling `CHECK_SCENE_CHANGES` is what the Creator does too, just on tab switch
|
|
30
|
+
* instead of on a timer, and it covers drags, deletes, adds and inspector edits with one path.
|
|
31
|
+
*/
|
|
32
|
+
/** How often the shell asks the engine whether anything changed. A `Set` read; effectively free. */
|
|
33
|
+
const POLL_INTERVAL_MS = 500;
|
|
34
|
+
/** Attribute-safe. `gameId` comes from `bitmagic.json`, but it lands inside an HTML attribute. */
|
|
35
|
+
function escapeAttr(value) {
|
|
36
|
+
return value
|
|
37
|
+
.replace(/&/g, '&')
|
|
38
|
+
.replace(/"/g, '"')
|
|
39
|
+
.replace(/</g, '<')
|
|
40
|
+
.replace(/>/g, '>');
|
|
41
|
+
}
|
|
42
|
+
export function renderEditorShell(options) {
|
|
43
|
+
const gameUrl = `http://localhost:${options.gamePort}/?source=creator&gameId=${encodeURIComponent(options.gameId)}`;
|
|
44
|
+
return `<!doctype html>
|
|
45
|
+
<html lang="en">
|
|
46
|
+
<head>
|
|
47
|
+
<meta charset="utf-8">
|
|
48
|
+
<title>bitmagic edit — ${escapeAttr(options.gameId)}</title>
|
|
49
|
+
<style>
|
|
50
|
+
:root { color-scheme: dark; }
|
|
51
|
+
* { box-sizing: border-box; }
|
|
52
|
+
/* Flex column rather than calc() heights: the banners come and go, and two of them at once
|
|
53
|
+
must not push the viewport into a scrollbar. */
|
|
54
|
+
body { margin: 0; height: 100vh; display: flex; flex-direction: column;
|
|
55
|
+
background: #101014; color: #e6e6ea; font: 13px/1.45 ui-sans-serif, system-ui, sans-serif; }
|
|
56
|
+
#bar { display: flex; align-items: center; gap: 12px; height: 36px; flex: none; padding: 0 12px;
|
|
57
|
+
background: #17171d; border-bottom: 1px solid #26262e; }
|
|
58
|
+
#bar .id { color: #8a8a99; font-family: ui-monospace, monospace; }
|
|
59
|
+
#bar .spacer { flex: 1; }
|
|
60
|
+
#status { display: flex; align-items: center; gap: 6px; }
|
|
61
|
+
#dot { width: 8px; height: 8px; border-radius: 50%; background: #4a4a57; }
|
|
62
|
+
#dot.saving { background: #d9a441; }
|
|
63
|
+
#dot.saved { background: #4caf72; }
|
|
64
|
+
#dot.error { background: #e0564f; }
|
|
65
|
+
button { background: #26262e; color: #e6e6ea; border: 1px solid #34343f; border-radius: 5px;
|
|
66
|
+
padding: 4px 10px; font: inherit; cursor: pointer; }
|
|
67
|
+
button:hover { background: #30303a; }
|
|
68
|
+
.banner { display: none; align-items: center; gap: 12px; flex: none; padding: 8px 12px;
|
|
69
|
+
background: #3a2c12; border-bottom: 1px solid #574018; color: #f0d9a8; }
|
|
70
|
+
.banner.show { display: flex; }
|
|
71
|
+
#terrain-banner { background: #3d1f1c; border-bottom-color: #6b2f28; color: #f3c3bd; }
|
|
72
|
+
#frame { display: block; flex: 1; width: 100%; border: 0; min-height: 0; }
|
|
73
|
+
#hq-overlay { display: none; position: fixed; inset: 0; background: rgba(8,8,12,.72);
|
|
74
|
+
align-items: center; justify-content: center; z-index: 10; }
|
|
75
|
+
#hq-overlay.show { display: flex; }
|
|
76
|
+
#hq-dialog { width: min(560px, 92vw); background: #17171d; border: 1px solid #34343f;
|
|
77
|
+
border-radius: 8px; padding: 18px; }
|
|
78
|
+
#hq-dialog h2 { margin: 0 0 6px; font-size: 15px; }
|
|
79
|
+
#hq-dialog p { margin: 0 0 12px; color: #9a9aab; }
|
|
80
|
+
#hq-prompt { width: 100%; min-height: 88px; resize: vertical; padding: 8px; border-radius: 6px;
|
|
81
|
+
background: #101014; color: #e6e6ea; border: 1px solid #34343f; font: inherit; }
|
|
82
|
+
#hq-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
|
83
|
+
#hq-confirm { background: #3352c4; border-color: #3f61d8; }
|
|
84
|
+
#hq-note { display: none; margin-top: 10px; padding: 10px; border-radius: 6px;
|
|
85
|
+
background: #101014; border: 1px solid #26262e; }
|
|
86
|
+
#hq-note.show { display: block; }
|
|
87
|
+
#hq-note code { display: block; margin-top: 6px; color: #9fd0a8; word-break: break-all; }
|
|
88
|
+
</style>
|
|
89
|
+
</head>
|
|
90
|
+
<body>
|
|
91
|
+
<div id="bar">
|
|
92
|
+
<strong>bitmagic edit</strong>
|
|
93
|
+
<span class="id">${escapeAttr(options.gameId)}</span>
|
|
94
|
+
<span class="spacer"></span>
|
|
95
|
+
<span id="status"><span id="dot"></span><span id="status-text">Loading…</span></span>
|
|
96
|
+
<button id="reload" type="button">Reload</button>
|
|
97
|
+
</div>
|
|
98
|
+
<div id="banner" class="banner">
|
|
99
|
+
<span>src/work/world.json changed on disk — this scene is out of date.</span>
|
|
100
|
+
<button id="banner-reload" type="button">Reload scene</button>
|
|
101
|
+
</div>
|
|
102
|
+
<div id="terrain-banner" class="banner">
|
|
103
|
+
<span><strong>Terrain edits are not saved yet.</strong>
|
|
104
|
+
Sculpting the ground needs an asset upload that <code>bitmagic edit</code> does not do — these
|
|
105
|
+
voxel changes will be lost. Discard them, or press Cancel in the terrain toolbar.</span>
|
|
106
|
+
<button id="terrain-discard" type="button">Discard terrain edits</button>
|
|
107
|
+
</div>
|
|
108
|
+
<iframe id="frame" allow="autoplay; fullscreen; xr-spatial-tracking; clipboard-write"></iframe>
|
|
109
|
+
<div id="hq-overlay">
|
|
110
|
+
<div id="hq-dialog">
|
|
111
|
+
<h2>Generate a high-quality <span id="hq-name"></span></h2>
|
|
112
|
+
<p>Describe what this object should be. The text is saved as the asset's description and
|
|
113
|
+
used as the generation prompt.</p>
|
|
114
|
+
<textarea id="hq-prompt" placeholder="e.g. a weathered stone archway covered in moss"></textarea>
|
|
115
|
+
<div id="hq-actions">
|
|
116
|
+
<button id="hq-cancel" type="button">Cancel</button>
|
|
117
|
+
<button id="hq-confirm" type="button">Hand to my agent</button>
|
|
118
|
+
</div>
|
|
119
|
+
<div id="hq-note">
|
|
120
|
+
<span id="hq-note-text"></span>
|
|
121
|
+
<code id="hq-command"></code>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
<script>
|
|
126
|
+
(function () {
|
|
127
|
+
'use strict';
|
|
128
|
+
var GAME_URL = ${JSON.stringify(gameUrl)};
|
|
129
|
+
var POLL_MS = ${POLL_INTERVAL_MS};
|
|
130
|
+
|
|
131
|
+
var frame = document.getElementById('frame');
|
|
132
|
+
var dot = document.getElementById('dot');
|
|
133
|
+
var statusText = document.getElementById('status-text');
|
|
134
|
+
var banner = document.getElementById('banner');
|
|
135
|
+
var terrainBanner = document.getElementById('terrain-banner');
|
|
136
|
+
|
|
137
|
+
var loaded = false;
|
|
138
|
+
var saving = false;
|
|
139
|
+
var markersDirty = false;
|
|
140
|
+
var lastKnownMtime = 0;
|
|
141
|
+
var pollTimer = null;
|
|
142
|
+
var waiters = [];
|
|
143
|
+
// Bumped by reload(). Every async step checks it, so a reload triggered mid-boot cannot leave
|
|
144
|
+
// two polling loops running against one iframe.
|
|
145
|
+
var generation = 0;
|
|
146
|
+
|
|
147
|
+
function setStatus(kind, text) {
|
|
148
|
+
dot.className = kind || '';
|
|
149
|
+
statusText.textContent = text;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function post(type, data) {
|
|
153
|
+
if (frame.contentWindow) frame.contentWindow.postMessage({ type: type, data: data }, '*');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The flat envelope — see note 3 in this file's header.
|
|
157
|
+
function postFlat(message) {
|
|
158
|
+
if (frame.contentWindow) frame.contentWindow.postMessage(message, '*');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Resolve on the next message of this type, or null after timeoutMs. Never rejects. */
|
|
162
|
+
function await_(type, timeoutMs) {
|
|
163
|
+
return new Promise(function (resolve) {
|
|
164
|
+
var waiter = { type: type, resolve: resolve, timer: null };
|
|
165
|
+
waiter.timer = setTimeout(function () {
|
|
166
|
+
var i = waiters.indexOf(waiter);
|
|
167
|
+
if (i !== -1) waiters.splice(i, 1);
|
|
168
|
+
resolve(null);
|
|
169
|
+
}, timeoutMs);
|
|
170
|
+
waiters.push(waiter);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
window.addEventListener('message', function (event) {
|
|
175
|
+
var message = event.data;
|
|
176
|
+
if (!message || typeof message.type !== 'string') return;
|
|
177
|
+
|
|
178
|
+
for (var i = waiters.length - 1; i >= 0; i--) {
|
|
179
|
+
if (waiters[i].type === message.type) {
|
|
180
|
+
clearTimeout(waiters[i].timer);
|
|
181
|
+
waiters[i].resolve(message);
|
|
182
|
+
waiters.splice(i, 1);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
switch (message.type) {
|
|
187
|
+
case 'REQUEST_ASSETS':
|
|
188
|
+
// Re-read rather than serving the copy captured at boot: an agent may have generated an
|
|
189
|
+
// asset since this page loaded, and a stale list is an empty asset palette with no
|
|
190
|
+
// explanation.
|
|
191
|
+
fetch('/api/game-data').then(function (r) { return r.json(); }).then(function (payload) {
|
|
192
|
+
var data = payload && payload.gameData;
|
|
193
|
+
postFlat({ type: 'ASSETS_RESPONSE', assets: (data && data.assets) || [] });
|
|
194
|
+
}).catch(function () {
|
|
195
|
+
postFlat({ type: 'ASSETS_RESPONSE', assets: [] });
|
|
196
|
+
});
|
|
197
|
+
break;
|
|
198
|
+
|
|
199
|
+
case 'ADD_OBJECT':
|
|
200
|
+
// The engine already placed the object; all the host owes it is a dirty mark so the next
|
|
201
|
+
// poll picks the new id up. (The Creator also sets environmentObjectsManuallyEdited here —
|
|
202
|
+
// that flag exists only to gate the web lane's world-edit CLI and has no meaning for a
|
|
203
|
+
// project whose agent edits world.json directly.)
|
|
204
|
+
if (message.objectId) postFlat({ type: 'MARK_OBJECT_MODIFIED', objectId: message.objectId });
|
|
205
|
+
break;
|
|
206
|
+
|
|
207
|
+
case 'ADD_MARKER':
|
|
208
|
+
case 'UPDATE_MARKER':
|
|
209
|
+
markersDirty = true;
|
|
210
|
+
break;
|
|
211
|
+
|
|
212
|
+
case 'GENERATE_HQ_ASSET':
|
|
213
|
+
openHqDialog(message.assetId, message.assetName);
|
|
214
|
+
break;
|
|
215
|
+
|
|
216
|
+
// No other tabs exist here, and asset re-baking is a "bitmagic generate" concern.
|
|
217
|
+
case 'SWITCH_TO_TAB':
|
|
218
|
+
case 'OPEN_ASSET_ACTION':
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The "Generate high-quality version" button in the engine's object inspector.
|
|
225
|
+
*
|
|
226
|
+
* The engine only announces the intent — the HOST owns the flow from here, in the web lane too.
|
|
227
|
+
* There, the Creator collects a description and POSTs a background job. Here the editor cannot
|
|
228
|
+
* generate anything (the Asset Forger credentials are server-side and never reach this machine),
|
|
229
|
+
* so it collects the same description, saves it onto the asset, and journals the request for the
|
|
230
|
+
* agent to run. A placeholder often carries no description at all, which is why the text is
|
|
231
|
+
* asked for rather than assumed.
|
|
232
|
+
*/
|
|
233
|
+
var hqOverlay = document.getElementById('hq-overlay');
|
|
234
|
+
var hqNote = document.getElementById('hq-note');
|
|
235
|
+
var hqPrompt = document.getElementById('hq-prompt');
|
|
236
|
+
var hqAsset = null;
|
|
237
|
+
|
|
238
|
+
function closeHqDialog() {
|
|
239
|
+
hqOverlay.classList.remove('show');
|
|
240
|
+
hqNote.classList.remove('show');
|
|
241
|
+
hqAsset = null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function openHqDialog(assetId, assetName) {
|
|
245
|
+
if (!assetId) return;
|
|
246
|
+
hqAsset = { id: assetId, name: assetName || assetId };
|
|
247
|
+
document.getElementById('hq-name').textContent = hqAsset.name;
|
|
248
|
+
hqNote.classList.remove('show');
|
|
249
|
+
// Prefill from the description ON DISK rather than anything cached here: the record the save
|
|
250
|
+
// patches is the stored one, and the agent may have written a description since boot.
|
|
251
|
+
var existing = '';
|
|
252
|
+
try {
|
|
253
|
+
var project = await (await fetch('/api/game-data')).json();
|
|
254
|
+
var asset = ((project.gameData && project.gameData.assets) || [])
|
|
255
|
+
.filter(function (a) { return a && a.id === assetId; })[0];
|
|
256
|
+
if (asset && typeof asset.description === 'string') existing = asset.description;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
// No description to prefill is a worse dialog, not a broken one.
|
|
259
|
+
}
|
|
260
|
+
hqPrompt.value = existing;
|
|
261
|
+
hqOverlay.classList.add('show');
|
|
262
|
+
hqPrompt.focus();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function confirmHq() {
|
|
266
|
+
if (!hqAsset) return;
|
|
267
|
+
var prompt = hqPrompt.value.trim();
|
|
268
|
+
if (prompt === '') { hqPrompt.focus(); return; }
|
|
269
|
+
var response = await fetch('/api/editor/hq-request', {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
headers: { 'Content-Type': 'application/json' },
|
|
272
|
+
body: JSON.stringify({ assetId: hqAsset.id, assetName: hqAsset.name, prompt: prompt })
|
|
273
|
+
});
|
|
274
|
+
var result = await response.json();
|
|
275
|
+
if (!response.ok || !result.ok) {
|
|
276
|
+
setStatus('error', result.error || 'Could not record the request');
|
|
277
|
+
closeHqDialog();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
lastKnownMtime = result.worldMtimeMs || lastKnownMtime;
|
|
281
|
+
// The description is saved either way; only the handoff depends on whether this asset can
|
|
282
|
+
// actually be regenerated. Saying so beats printing a command that would refuse to run.
|
|
283
|
+
if (result.canGenerate === false) {
|
|
284
|
+
document.getElementById('hq-note-text').textContent =
|
|
285
|
+
'Description saved. ' + (result.reason || 'This asset cannot be regenerated.');
|
|
286
|
+
document.getElementById('hq-command').textContent = '';
|
|
287
|
+
hqNote.classList.add('show');
|
|
288
|
+
setStatus('saved', 'Description saved');
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
document.getElementById('hq-note-text').textContent =
|
|
292
|
+
'Saved and journalled. Generating the mesh needs credentials this editor does not hold, '
|
|
293
|
+
+ 'so your agent runs it:';
|
|
294
|
+
document.getElementById('hq-command').textContent = result.command;
|
|
295
|
+
hqNote.classList.add('show');
|
|
296
|
+
setStatus('saved', 'Handed to your agent');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
document.getElementById('hq-cancel').addEventListener('click', closeHqDialog);
|
|
300
|
+
document.getElementById('hq-confirm').addEventListener('click', function () {
|
|
301
|
+
void confirmHq();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
async function boot(gen) {
|
|
305
|
+
loaded = false;
|
|
306
|
+
markersDirty = false;
|
|
307
|
+
banner.classList.remove('show');
|
|
308
|
+
terrainBanner.classList.remove('show');
|
|
309
|
+
setStatus('', 'Loading…');
|
|
310
|
+
|
|
311
|
+
var response = await fetch('/api/game-data');
|
|
312
|
+
if (gen !== generation) return;
|
|
313
|
+
if (!response.ok) {
|
|
314
|
+
setStatus('error', 'Could not read the project');
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
var project = await response.json();
|
|
318
|
+
lastKnownMtime = project.worldMtimeMs || 0;
|
|
319
|
+
|
|
320
|
+
// Both waits are armed BEFORE navigation: either signal can arrive while the page loads.
|
|
321
|
+
var templateReady = await_('GAME_TEMPLATE_READY', 60000);
|
|
322
|
+
var gameLoaded = await_('GAME_LOADED', 120000);
|
|
323
|
+
frame.src = GAME_URL;
|
|
324
|
+
|
|
325
|
+
var ready = await templateReady;
|
|
326
|
+
if (gen !== generation) return;
|
|
327
|
+
if (!ready) {
|
|
328
|
+
setStatus('error', 'The game never finished booting — try "bitmagic verify"');
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
post('LOAD_GAME', { gameId: project.gameId, gameData: project.gameData, skipMenu: true });
|
|
332
|
+
var started = await gameLoaded;
|
|
333
|
+
if (gen !== generation) return;
|
|
334
|
+
if (!started) {
|
|
335
|
+
setStatus('error', 'The world never loaded — try "bitmagic verify"');
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Mirrors the Creator's enterEditorMode(): pause, turn the editor on (this is what unpacks
|
|
340
|
+
// InstancedMeshes so clicks can hit an individual instance), then configure the free camera
|
|
341
|
+
// and hide the HUD.
|
|
342
|
+
post('SET_PAUSE', { paused: true });
|
|
343
|
+
post('SET_EDITOR_MODE', { enabled: true });
|
|
344
|
+
post('SET_EDITOR_TAB', { tab: 'scene' });
|
|
345
|
+
|
|
346
|
+
loaded = true;
|
|
347
|
+
setStatus('saved', 'Ready');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function flush() {
|
|
351
|
+
if (!loaded || saving) return;
|
|
352
|
+
|
|
353
|
+
post('CHECK_SCENE_CHANGES');
|
|
354
|
+
var changes = await await_('SCENE_HAS_CHANGES', 2000);
|
|
355
|
+
if (!changes) return;
|
|
356
|
+
if (!changes.hasChanges && !markersDirty) return;
|
|
357
|
+
|
|
358
|
+
post('GET_SCENE_EDITING_STATUS');
|
|
359
|
+
var status = await await_('SCENE_EDITING_STATUS', 5000);
|
|
360
|
+
if (!status) return;
|
|
361
|
+
|
|
362
|
+
saving = true;
|
|
363
|
+
setStatus('saving', 'Saving…');
|
|
364
|
+
try {
|
|
365
|
+
var response = await fetch('/api/scene/save', {
|
|
366
|
+
method: 'POST',
|
|
367
|
+
headers: { 'Content-Type': 'application/json' },
|
|
368
|
+
body: JSON.stringify({ changes: changes, status: status, markersDirty: markersDirty })
|
|
369
|
+
});
|
|
370
|
+
var result = await response.json();
|
|
371
|
+
if (!response.ok || !result.ok) {
|
|
372
|
+
setStatus('error', result.error || 'Save failed');
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
lastKnownMtime = result.worldMtimeMs || lastKnownMtime;
|
|
376
|
+
markersDirty = false;
|
|
377
|
+
// Only once the write landed: clearing earlier would drop the edit on a failed save.
|
|
378
|
+
post('CLEAR_SCENE_CHANGES');
|
|
379
|
+
setStatus('saved', result.applied > 0 ? 'Saved to world.json' : 'Ready');
|
|
380
|
+
} catch (error) {
|
|
381
|
+
setStatus('error', 'Save failed: ' + error.message);
|
|
382
|
+
} finally {
|
|
383
|
+
saving = false;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Terrain sculpting is out of scope for this command, but it is not out of REACH: clicking the
|
|
389
|
+
* ground drops the engine straight into a whole-terrain voxel session (EditorManager.selectObject
|
|
390
|
+
* routes every terrain-chunk hit to startTerrainVoxelSession, with no lock to stop it). Its
|
|
391
|
+
* "Save & Exit" uploads the terrain VXL to a signed URL that only the web lane and the forge can
|
|
392
|
+
* mint — here saveToS3 fails, logs a console warning, and marks the session committed. The
|
|
393
|
+
* creator would lose the work with no visible sign.
|
|
394
|
+
*
|
|
395
|
+
* So say so, within one poll of the first voxel edit, and offer the revert the engine already
|
|
396
|
+
* implements. hasUnsavedTerrainChanges() is false for a session that was merely opened, so
|
|
397
|
+
* clicking the ground by accident stays silent.
|
|
398
|
+
*/
|
|
399
|
+
async function checkTerrainEdits() {
|
|
400
|
+
if (!loaded) return;
|
|
401
|
+
post('CHECK_TERRAIN_CHANGES');
|
|
402
|
+
var terrain = await await_('TERRAIN_HAS_CHANGES', 2000);
|
|
403
|
+
if (!terrain) return;
|
|
404
|
+
terrainBanner.classList.toggle('show', terrain.hasChanges === true);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function checkExternalEdits() {
|
|
408
|
+
if (!loaded || saving) return;
|
|
409
|
+
try {
|
|
410
|
+
var state = await (await fetch('/api/state')).json();
|
|
411
|
+
// Anything other than the mtime our own last write produced is someone else's edit — most
|
|
412
|
+
// likely the creator's agent. Offer a reload rather than taking one: an unprompted reload
|
|
413
|
+
// would discard a drag in progress.
|
|
414
|
+
if (state.worldMtimeMs && state.worldMtimeMs !== lastKnownMtime
|
|
415
|
+
&& state.worldMtimeMs !== state.lastWrittenMtimeMs) {
|
|
416
|
+
banner.classList.add('show');
|
|
417
|
+
}
|
|
418
|
+
} catch (error) {
|
|
419
|
+
// The sidecar is this page's own server; if it is gone the reload button is the only
|
|
420
|
+
// meaningful action left, and the poll below will keep trying.
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// One serial loop rather than two timers: a flush and an mtime check must never overlap, or the
|
|
425
|
+
// check reads the mtime of a write that has not finished being accounted for.
|
|
426
|
+
async function loop(gen) {
|
|
427
|
+
if (gen !== generation) return;
|
|
428
|
+
await flush();
|
|
429
|
+
await checkTerrainEdits();
|
|
430
|
+
await checkExternalEdits();
|
|
431
|
+
if (gen !== generation) return;
|
|
432
|
+
pollTimer = setTimeout(function () { loop(gen); }, POLL_MS);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function start() {
|
|
436
|
+
if (pollTimer) clearTimeout(pollTimer);
|
|
437
|
+
var gen = ++generation;
|
|
438
|
+
boot(gen).then(function () { loop(gen); });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
document.getElementById('reload').addEventListener('click', start);
|
|
442
|
+
document.getElementById('banner-reload').addEventListener('click', start);
|
|
443
|
+
document.getElementById('terrain-discard').addEventListener('click', function () {
|
|
444
|
+
post('REVERT_TERRAIN_CHANGES');
|
|
445
|
+
terrainBanner.classList.remove('show');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
start();
|
|
449
|
+
})();
|
|
450
|
+
</script>
|
|
451
|
+
</body>
|
|
452
|
+
</html>
|
|
453
|
+
`;
|
|
454
|
+
}
|
|
455
|
+
//# sourceMappingURL=shell-page.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shell-page.js","sourceRoot":"","sources":["../../src/editor/shell-page.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,oGAAoG;AACpG,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAE7B,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;;;;yBAIgB,UAAU,CAAC,OAAO,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;uBA6C5B,UAAU,CAAC,OAAO,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mBAmC9B,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;kBACxB,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoUjC,CAAC;AACF,CAAC"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Environment } from '../config/environments.js';
|
|
2
|
+
import type { ProjectContext } from '../project/context.js';
|
|
3
|
+
interface FitBox {
|
|
4
|
+
x: number;
|
|
5
|
+
z: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
export interface PropVoxelizeOptions {
|
|
9
|
+
context: ProjectContext;
|
|
10
|
+
environment: Environment;
|
|
11
|
+
token: string;
|
|
12
|
+
assetId: string;
|
|
13
|
+
prompt: string;
|
|
14
|
+
glbUrl: string;
|
|
15
|
+
log: (message: string) => void;
|
|
16
|
+
}
|
|
17
|
+
export interface PropVoxelizeResult {
|
|
18
|
+
assetId: string;
|
|
19
|
+
assetName: string;
|
|
20
|
+
assetUrl?: string;
|
|
21
|
+
/** How many placed instances now point at the upgraded asset. */
|
|
22
|
+
instanceCount: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The asset this command upgrades, with the `fitBox` the bake has to be constrained to.
|
|
26
|
+
*
|
|
27
|
+
* A missing `fitBox` is refused rather than defaulted. It records the size the object occupied
|
|
28
|
+
* when the level was baked, and every instance was placed against it — voxelizing to a different
|
|
29
|
+
* envelope produces an asset that is the wrong size everywhere it already sits, which nothing
|
|
30
|
+
* downstream would flag.
|
|
31
|
+
*/
|
|
32
|
+
export declare function findPropAsset(world: Record<string, unknown>, assetId: string): {
|
|
33
|
+
asset: Record<string, unknown>;
|
|
34
|
+
fitBox: FitBox;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Building-scale boxes voxelize hollow (a closed shell); prop-scale fills solid. Same thresholds
|
|
38
|
+
* as the HQ job — a solid-filled building is millions of wasted voxels, and a hollow prop reads as
|
|
39
|
+
* a shell the moment anything clips into it.
|
|
40
|
+
*/
|
|
41
|
+
export declare function shouldFillInterior(fitBox: FitBox): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Steps 2 and 3. Split from the command so the browser work is callable directly on a retry that
|
|
44
|
+
* already has a GLB, and so the command file stays about flags and output.
|
|
45
|
+
*/
|
|
46
|
+
export declare function voxelizeAndInstall(options: PropVoxelizeOptions): Promise<PropVoxelizeResult>;
|
|
47
|
+
export {};
|