sindicate 0.12.0 → 0.15.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/docs/plans/kiwi-road-recon.md +97 -0
  3. package/docs/plans/road-system.md +142 -0
  4. package/docs/plans/three-cities.md +43 -0
  5. package/package.json +11 -3
  6. package/src/core/assets.js +15 -0
  7. package/src/systems/missions.js +2 -2
  8. package/src/systems/shatter.js +2 -1
  9. package/src/ui/dialogue.js +2 -1
  10. package/src/ui/pauseMenu.js +18 -11
  11. package/src/ui/saveLoadScreen.js +13 -9
  12. package/src/ui/theme.js +34 -0
  13. package/src/world/bridgeSplice.js +176 -0
  14. package/src/world/design.js +72 -0
  15. package/src/world/geo.js +53 -0
  16. package/src/world/heightfield.js +146 -0
  17. package/src/world/kit.js +924 -0
  18. package/src/world/permanentWay.js +161 -0
  19. package/src/world/placement.js +309 -0
  20. package/src/world/railFormation.js +185 -0
  21. package/src/world/roadKit.js +110 -0
  22. package/src/world/roadLink.js +270 -0
  23. package/src/world/roadNetwork.js +118 -0
  24. package/src/world/roadTerrain.js +263 -0
  25. package/src/world/waterLevels.js +95 -0
  26. package/src/world/waterMeshes.js +168 -0
  27. package/src/world/worldBase.js +493 -0
  28. package/tools/ExportKiwiRoadMaterials.cs +145 -0
  29. package/tools/ExportKiwiRoadRefs.cs +94 -0
  30. package/tools/buildKiwiRoadsPack.mjs +36 -0
  31. package/tools/designer-mcp.mjs +103 -0
  32. package/tools/designer.js +316 -0
  33. package/tools/devPlugin.js +324 -0
  34. package/tools/fbxToGlb.mjs +115 -0
  35. package/tools/glbWrite.mjs +62 -0
  36. package/tools/rebuildSimpleRoundabouts.mjs +211 -0
  37. package/tools/registry.json +45 -0
  38. package/tools/roadKitAnalyze.mjs +366 -0
  39. package/tools/sindicate-cli.mjs +71 -0
  40. package/tools/truthToAssemblies.mjs +160 -0
  41. package/tools/unityMeshToGlb.mjs +95 -0
  42. package/tools/unityPrefabToAssembly.mjs +276 -0
@@ -23,6 +23,10 @@
23
23
  // perfSink: process.env.MY_PERF_SINK || '/tmp/mygame-perf.jsonl',
24
24
  // })]
25
25
  import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import { execFileSync } from 'node:child_process';
28
+ import { fileURLToPath } from 'node:url';
29
+ const _toolsDir = path.dirname(fileURLToPath(import.meta.url));
26
30
 
27
31
  // Guard for the source-writing endpoints: only same-machine pages may write, and the
28
32
  // payload must look like the file it claims to be — an empty/garbage body used to be
@@ -205,6 +209,188 @@ function editorShotsPlugin({ shotsDir, mapsDir }) {
205
209
  };
206
210
  }
207
211
 
212
+ // THE PACK IMPORTER (ENGINE_DESIGN §5c) — bring publisher packs (Synty et al.) into the
213
+ // RUNNING GAME's repo. A .unitypackage is a gzip tar of GUID dirs, each holding `pathname`
214
+ // (the original Assets/… path) and `asset` (the bytes) — the parse western's python
215
+ // extractor proved, done here with the OS tar. Assets land in the GAME (public/assets/
216
+ // packs/<name>/), never in Sindicate: the engine stays content-free and licenses stay
217
+ // per-project. Animation FBXs route through accentimation-converter when it's present.
218
+ export function packImporterPlugin({ stagingDir = '_staging', packsDir = 'public/assets/packs', converterPath = '../accentimation-converter' } = {}) {
219
+ // category rules from the 85-pack survey (135k paths): newer packs live under
220
+ // Assets/Synty/<Pack>/, older under Assets/<Pack>/Models/; Convex/Collision dirs hold
221
+ // collision meshes (not display models); /Animations/ FBXs are converter fodder;
222
+ // .prefab/.asset/.mat/.cs and friends are Unity-only noise.
223
+ const TEXTURE_RE = /\.(png|jpg|jpeg|tga)$/i;
224
+ const MODEL_RE = /\.(fbx|glb|gltf|obj)$/i;
225
+ const ANIM_HINT = /(^|\/)animations?\//i;
226
+ const COLLISION_RE = /(^|\/)(convex|collision|collisionconvex)\//i;
227
+ const isAnim = (p) => (ANIM_HINT.test(p) && /\.fbx$/i.test(p)) || /(^|\/)A_[^/]+\.fbx$/i.test(p);
228
+
229
+ function inventory(dir) {
230
+ const out = { models: [], textures: [], animations: [], collision: [], other: [] };
231
+ const walk = (d, rel = '') => {
232
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
233
+ if (e.name.startsWith('_')) continue; // working dirs (_anims) are not pack content
234
+ const r = rel ? `${rel}/${e.name}` : e.name;
235
+ if (e.isDirectory()) walk(path.join(d, e.name), r);
236
+ else if (isAnim(r)) out.animations.push(r);
237
+ else if (COLLISION_RE.test(r) && MODEL_RE.test(r)) out.collision.push(r);
238
+ else if (MODEL_RE.test(r)) out.models.push(r);
239
+ else if (TEXTURE_RE.test(r)) out.textures.push(r);
240
+ else out.other.push(r);
241
+ }
242
+ };
243
+ walk(dir);
244
+ return out;
245
+ }
246
+
247
+ return {
248
+ name: 'sindicate-pack-importer',
249
+ apply: 'serve',
250
+ configureServer(server) {
251
+ // upload: stream the .unitypackage to disk, tar-extract, resolve pathname map, stage
252
+ server.middlewares.use('/__pack/upload', (req, res) => {
253
+ if (req.method !== 'POST') { res.statusCode = 405; return res.end('POST only'); }
254
+ const name = (new URL(req.url, 'http://x').searchParams.get('name') || 'pack').replace(/\.unitypackage$/i, '').replace(/[^\w.-]+/g, '_');
255
+ const upDir = path.join(stagingDir, '_uploads');
256
+ fs.mkdirSync(upDir, { recursive: true });
257
+ const tmp = path.join(upDir, `${name}.unitypackage`);
258
+ const ws = fs.createWriteStream(tmp);
259
+ req.pipe(ws);
260
+ ws.on('finish', () => {
261
+ try {
262
+ const raw = path.join(stagingDir, '_raw', name);
263
+ fs.rmSync(raw, { recursive: true, force: true });
264
+ fs.mkdirSync(raw, { recursive: true });
265
+ execFileSync('tar', ['-xzf', tmp, '-C', raw], { stdio: 'pipe' });
266
+ const dest = path.join(stagingDir, name);
267
+ fs.rmSync(dest, { recursive: true, force: true });
268
+ let staged = 0;
269
+ for (const guid of fs.readdirSync(raw)) {
270
+ const pn = path.join(raw, guid, 'pathname');
271
+ const as = path.join(raw, guid, 'asset');
272
+ if (!fs.existsSync(pn) || !fs.existsSync(as)) continue;
273
+ const orig = fs.readFileSync(pn, 'utf8').split('\n')[0].trim();
274
+ if (!orig || orig.split('/').includes('..')) continue;
275
+ const to = path.join(dest, orig);
276
+ fs.mkdirSync(path.dirname(to), { recursive: true });
277
+ fs.renameSync(as, to);
278
+ staged++;
279
+ }
280
+ fs.rmSync(raw, { recursive: true, force: true });
281
+ fs.rmSync(tmp, { force: true });
282
+ sendJson(res, 200, { pack: name, staged, inventory: summarize(inventory(dest)) });
283
+ } catch (e) { sendJson(res, 500, { err: String(e).slice(0, 300) }); }
284
+ });
285
+ ws.on('error', (e) => sendJson(res, 500, { err: String(e) }));
286
+ });
287
+
288
+ const summarize = (inv) => ({ models: inv.models.length, textures: inv.textures.length, animations: inv.animations.length, collision: inv.collision.length, other: inv.other.length });
289
+ // THE CATALOG: registry.json (ships with the engine) + live status per entry —
290
+ // installed (node_modules/<name>/sindicate.json) and packaged (newest
291
+ // *.sindicatepackage in the repo dir, resolved against the Sites root = game's parent)
292
+ server.middlewares.use('/__pack/registry', (req, res) => {
293
+ try {
294
+ const reg = JSON.parse(fs.readFileSync(path.join(_toolsDir, 'registry.json'), 'utf8'));
295
+ const sites = path.resolve('..');
296
+ const out = reg.packages.map((p) => {
297
+ const manPath = path.join('node_modules', p.name, 'sindicate.json');
298
+ let installed = null;
299
+ if (fs.existsSync(manPath)) { try { installed = JSON.parse(fs.readFileSync(manPath, 'utf8')).version; } catch { installed = '?'; } }
300
+ const repoDir = path.join(sites, p.repo ?? '');
301
+ let packageFile = null;
302
+ if (p.repo && fs.existsSync(repoDir)) {
303
+ const c = fs.readdirSync(repoDir).filter((f) => f.endsWith('.sindicatepackage')).sort();
304
+ if (c.length) packageFile = path.join(repoDir, c[c.length - 1]);
305
+ }
306
+ return { ...p, installed, packageFile, repoExists: p.repo ? fs.existsSync(repoDir) : false };
307
+ });
308
+ sendJson(res, 200, { packages: out });
309
+ } catch (e) { sendJson(res, 500, { err: String(e) }); }
310
+ });
311
+
312
+ // install straight from the catalog: run the sindicate CLI server-side into THIS game
313
+ server.middlewares.use('/__pack/installAddon', async (req, res) => {
314
+ if (req.method !== 'POST') { res.statusCode = 405; return res.end('POST only'); }
315
+ try {
316
+ const { file } = JSON.parse((await readBody(req)).toString());
317
+ if (!file || !String(file).endsWith('.sindicatepackage') || !fs.existsSync(file)) return sendJson(res, 400, { err: 'no such package file' });
318
+ const out = execFileSync('node', [path.join(_toolsDir, 'sindicate-cli.mjs'), 'install', file, '--game', '.'], { stdio: 'pipe', timeout: 300000 });
319
+ sendJson(res, 200, { ok: true, log: out.toString().slice(-400) });
320
+ } catch (e) { sendJson(res, 500, { err: String(e.stderr ?? e).slice(0, 300) }); }
321
+ });
322
+
323
+ // installed add-ons: every node_modules package that ships a sindicate.json
324
+ server.middlewares.use('/__pack/addons', (req, res) => {
325
+ try {
326
+ const nm = 'node_modules';
327
+ const addons = [];
328
+ if (fs.existsSync(nm)) for (const d of fs.readdirSync(nm)) {
329
+ if (d.startsWith('.')) continue;
330
+ const man = path.join(nm, d, 'sindicate.json');
331
+ if (!fs.existsSync(man)) continue;
332
+ try { addons.push(JSON.parse(fs.readFileSync(man, 'utf8'))); } catch { /* unreadable manifest */ }
333
+ }
334
+ sendJson(res, 200, { addons });
335
+ } catch (e) { sendJson(res, 500, { err: String(e) }); }
336
+ });
337
+
338
+ // staged packs + inventories
339
+ server.middlewares.use('/__pack/list', (req, res) => {
340
+ try {
341
+ const packs = fs.existsSync(stagingDir)
342
+ ? fs.readdirSync(stagingDir).filter((d) => !d.startsWith('_') && fs.statSync(path.join(stagingDir, d)).isDirectory())
343
+ .map((d) => ({ pack: d, inventory: summarize(inventory(path.join(stagingDir, d))) }))
344
+ : [];
345
+ const imported = fs.existsSync(packsDir)
346
+ ? fs.readdirSync(packsDir).filter((d) => fs.existsSync(path.join(packsDir, d, 'pack.json')))
347
+ : [];
348
+ sendJson(res, 200, { packs, imported });
349
+ } catch (e) { sendJson(res, 500, { err: String(e) }); }
350
+ });
351
+
352
+ // import: copy models/textures into the game pack dir; cook animations via the converter
353
+ server.middlewares.use('/__pack/import', async (req, res) => {
354
+ if (req.method !== 'POST') { res.statusCode = 405; return res.end('POST only'); }
355
+ try {
356
+ const { pack, categories = ['models', 'textures', 'animations'] } = JSON.parse((await readBody(req)).toString());
357
+ const src = path.join(stagingDir, String(pack).replace(/[^\w.-]+/g, '_'));
358
+ if (!fs.existsSync(src)) return sendJson(res, 404, { err: 'no such staged pack' });
359
+ const inv = inventory(src);
360
+ const out = path.join(packsDir, pack);
361
+ const copied = { models: 0, textures: 0 };
362
+ const copy = (rel, sub) => {
363
+ const to = path.join(out, sub, path.basename(rel));
364
+ fs.mkdirSync(path.dirname(to), { recursive: true });
365
+ fs.copyFileSync(path.join(src, rel), to);
366
+ };
367
+ if (categories.includes('models')) for (const r of inv.models) { copy(r, 'models'); copied.models++; }
368
+ if (categories.includes('textures')) for (const r of inv.textures) { copy(r, 'textures'); copied.textures++; }
369
+ let clips = null;
370
+ if (categories.includes('animations') && inv.animations.length) {
371
+ const conv = path.resolve(converterPath, 'bin/accentimation.js');
372
+ if (fs.existsSync(conv)) {
373
+ const animDir = path.join(src, '_anims');
374
+ fs.rmSync(animDir, { recursive: true, force: true });
375
+ fs.mkdirSync(animDir, { recursive: true });
376
+ for (const r of inv.animations) fs.copyFileSync(path.join(src, r), path.join(animDir, path.basename(r)));
377
+ const clipsOut = path.join(out, 'clips');
378
+ try {
379
+ execFileSync('node', [conv, 'convert', animDir, '--out', clipsOut], { stdio: 'pipe', timeout: 300000 });
380
+ clips = fs.readdirSync(clipsOut).filter((f) => f.endsWith('.json')).length;
381
+ } catch (e) { clips = `converter failed: ${String(e.stderr ?? e).slice(0, 200)}`; }
382
+ } else clips = `converter not found at ${conv} — animations staged, not cooked`;
383
+ }
384
+ const manifest = { name: pack, importedAt: new Date().toISOString(), counts: { ...copied, clips }, source: 'unitypackage' };
385
+ fs.mkdirSync(out, { recursive: true });
386
+ fs.writeFileSync(path.join(out, 'pack.json'), JSON.stringify(manifest, null, 2));
387
+ sendJson(res, 200, { ok: true, pack, copied, clips, dest: out });
388
+ } catch (e) { sendJson(res, 500, { err: String(e).slice(0, 300) }); }
389
+ });
390
+ },
391
+ };
392
+ }
393
+
208
394
  // THE ENGINE DEV GUI (/__sindicate/) — served by the dev server, talks to the RUNNING
209
395
  // game through the game bus. v1: live tunables (core/tune.js groups render as sliders;
210
396
  // edits apply in the game the same frame). Panels for add-ons/packs mount here next.
@@ -221,6 +407,86 @@ export function guiPlugin() {
221
407
  </style></head><body>
222
408
  <h1>SINDICATE</h1><div class="sub">dev panel — tunables apply LIVE in the running game</div>
223
409
  <div id="groups"></div><div class="status" id="status">connecting to the game bus…</div>
410
+ <div class="grp" style="margin-top:26px"><h2>Packages</h2>
411
+ <div style="font-size:12px;color:#8d8875;margin-bottom:8px">The Sindicate catalog — install into THIS game with one click (or: sindicate install &lt;file&gt; --game .)</div>
412
+ <div id="ad-list" style="font-size:12px;color:#b8b09a"></div>
413
+ <div id="ad-status" style="font-size:12px;color:#8d8875;margin-top:6px"></div>
414
+ </div>
415
+ <script>
416
+ async function adRefresh() {
417
+ try {
418
+ const j = await fetch('/__pack/registry').then((r) => r.json());
419
+ const el = document.getElementById('ad-list'); el.innerHTML = '';
420
+ for (const p of j.packages ?? []) {
421
+ const row = document.createElement('div');
422
+ row.style.cssText = 'display:flex;align-items:flex-start;gap:10px;margin:8px 0';
423
+ const status = p.installed ? '<span style="color:#7dc24a">installed v' + p.installed + '</span>'
424
+ : p.packageFile ? '<span style="color:#e8c66a">available</span>'
425
+ : '<span style="color:#6d6858">' + (p.note ?? 'not packaged') + '</span>';
426
+ row.innerHTML = '<div style="flex:1"><span style="color:#f5ead0">' + p.name + '</span> <span style="color:#6d6858">· ' + p.type + '</span> ' + status
427
+ + '<br><span style="color:#8d8875">' + (p.description ?? '') + '</span></div>';
428
+ if (!p.installed && p.packageFile) {
429
+ const btn = document.createElement('button');
430
+ btn.textContent = 'Install';
431
+ btn.style.cssText = 'padding:3px 12px;border:1px solid #3a3a52;border-radius:5px;background:#22223a;color:#e8c66a;cursor:pointer;font-size:12px;flex:none';
432
+ btn.onclick = async () => {
433
+ btn.disabled = true;
434
+ document.getElementById('ad-status').textContent = 'installing ' + p.name + '…';
435
+ const r = await fetch('/__pack/installAddon', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ file: p.packageFile }) }).then((x) => x.json());
436
+ document.getElementById('ad-status').textContent = r.ok ? 'installed ' + p.name + ' — restart the dev server to use it' : 'failed: ' + (r.err ?? '?');
437
+ adRefresh();
438
+ };
439
+ row.appendChild(btn);
440
+ }
441
+ el.appendChild(row);
442
+ }
443
+ } catch (e) { document.getElementById('ad-list').textContent = 'unreachable: ' + e.message; }
444
+ }
445
+ adRefresh();
446
+ </script>
447
+ <div class="grp" style="margin-top:14px"><h2>Import Packs</h2>
448
+ <div style="font-size:12px;color:#8d8875;margin-bottom:10px">Drop or choose a .unitypackage — assets land in THIS game's repo (public/assets/packs/). Animations cook through accentimation-converter.</div>
449
+ <input type="file" id="pk-file" accept=".unitypackage" style="color:#b8b09a;font-size:12px">
450
+ <div id="pk-status" style="font-size:12px;color:#8d8875;margin-top:8px"></div>
451
+ <div id="pk-list" style="margin-top:10px"></div>
452
+ </div>
453
+ <script>
454
+ const pkStatus = (m) => { document.getElementById('pk-status').textContent = m; };
455
+ async function pkRefresh() {
456
+ try {
457
+ const j = await fetch('/__pack/list').then((r) => r.json());
458
+ const el = document.getElementById('pk-list'); el.innerHTML = '';
459
+ for (const p of j.packs ?? []) {
460
+ const row = document.createElement('div');
461
+ row.style.cssText = 'display:flex;align-items:center;gap:10px;margin:6px 0;font-size:12px;color:#b8b09a';
462
+ const inv = p.inventory;
463
+ const done = (j.imported ?? []).includes(p.pack);
464
+ row.innerHTML = '<span style="color:#f5ead0">' + p.pack + '</span><span>' + inv.models + ' models · ' + inv.textures + ' tex · ' + inv.animations + ' anims</span>' + (done ? '<span style="color:#7dc24a">imported</span>' : '');
465
+ const btn = document.createElement('button');
466
+ btn.textContent = done ? 'Re-import' : 'Import';
467
+ btn.style.cssText = 'padding:3px 12px;border:1px solid #3a3a52;border-radius:5px;background:#22223a;color:#e8c66a;cursor:pointer;font-size:12px';
468
+ btn.onclick = async () => {
469
+ btn.disabled = true; pkStatus('importing ' + p.pack + '…');
470
+ const r = await fetch('/__pack/import', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ pack: p.pack }) }).then((x) => x.json());
471
+ pkStatus(r.ok ? 'imported ' + p.pack + ': ' + r.copied.models + ' models, ' + r.copied.textures + ' textures' + (r.clips != null ? ', clips: ' + r.clips : '') : 'failed: ' + (r.err ?? '?'));
472
+ btn.disabled = false; pkRefresh();
473
+ };
474
+ row.appendChild(btn); el.appendChild(row);
475
+ }
476
+ if (!(j.packs ?? []).length) el.innerHTML = '<div style="font-size:12px;color:#6d6858">nothing staged yet</div>';
477
+ } catch (e) { pkStatus('importer unreachable: ' + e.message); }
478
+ }
479
+ document.getElementById('pk-file').onchange = async (ev) => {
480
+ const f = ev.target.files[0]; if (!f) return;
481
+ pkStatus('uploading ' + f.name + ' (' + Math.round(f.size / 1048576) + 'MB)…');
482
+ try {
483
+ const j = await fetch('/__pack/upload?name=' + encodeURIComponent(f.name), { method: 'POST', body: f }).then((r) => r.json());
484
+ pkStatus(j.err ? 'failed: ' + j.err : 'staged ' + j.pack + ' — ' + j.staged + ' files');
485
+ pkRefresh();
486
+ } catch (e) { pkStatus('upload failed: ' + e.message); }
487
+ };
488
+ pkRefresh();
489
+ </script>
224
490
  <script>
225
491
  const cmd = (js) => fetch('/__game/cmd', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ js }) }).then((r) => r.json());
226
492
  const take = async (id) => { for (let i = 0; i < 40; i++) { const r = await fetch('/__game/take?id=' + id).then((x) => x.json()); if (!r.pending) return r; await new Promise((res) => setTimeout(res, 250)); } return { err: 'timeout' }; };
@@ -273,6 +539,61 @@ export function guiPlugin() {
273
539
  };
274
540
  }
275
541
 
542
+ // THE WORLD DESIGNER's server half — layout JSON persistence (designs/<name>.json in the
543
+ // GAME repo: machine-written layouts are JSON by law) + the imported-pack model listing
544
+ // the palette feeds on. The designer BUS (an MCP server's way in) is a busPlugin instance
545
+ // registered alongside this in sindicateDevTools.
546
+ export function designerPlugin({ packsDir = 'public/assets/packs', designsDir = 'designs' } = {}) {
547
+ const NAME_RE = /^[\w.-]+$/;
548
+ return {
549
+ name: 'sindicate-designer',
550
+ apply: 'serve',
551
+ configureServer(server) {
552
+ // model + texture inventory of ONE imported pack (palette fodder)
553
+ server.middlewares.use('/__designer/models', (req, res) => {
554
+ try {
555
+ const pack = new URL(req.url, 'http://x').searchParams.get('pack') || '';
556
+ if (!NAME_RE.test(pack)) return sendJson(res, 400, { err: 'bad pack name' });
557
+ const mDir = path.join(packsDir, pack, 'models');
558
+ const tDir = path.join(packsDir, pack, 'textures');
559
+ const models = fs.existsSync(mDir) ? fs.readdirSync(mDir).filter((f) => /\.(fbx|glb)$/i.test(f)) : [];
560
+ const textures = fs.existsSync(tDir) ? fs.readdirSync(tDir).filter((f) => /\.(png|jpg|jpeg|tga)$/i.test(f)) : [];
561
+ // the pack's likeliest shared atlas — Synty convention first, else the first texture
562
+ const atlas = textures.find((t) => /_Texture_01/i.test(t)) ?? textures.find((t) => /texture/i.test(t)) ?? textures[0] ?? null;
563
+ sendJson(res, 200, { models, textures, tex: atlas ? `/assets/packs/${pack}/textures/${atlas}` : null });
564
+ } catch (e) { sendJson(res, 500, { err: String(e) }); }
565
+ });
566
+ server.middlewares.use('/__designer/save', async (req, res) => {
567
+ if (req.method !== 'POST') return sendJson(res, 405, { err: 'POST only' });
568
+ const name = new URL(req.url, 'http://x').searchParams.get('name') || '';
569
+ if (!NAME_RE.test(name)) return sendJson(res, 400, { err: 'bad design name' });
570
+ try {
571
+ const body = JSON.parse((await readBody(req)).toString()); // parse = validate: only real JSON lands on disk
572
+ fs.mkdirSync(designsDir, { recursive: true });
573
+ fs.writeFileSync(path.join(designsDir, `${name}.json`), JSON.stringify(body, null, 2));
574
+ sendJson(res, 200, { ok: true, file: `${designsDir}/${name}.json` });
575
+ } catch (e) { sendJson(res, 500, { err: String(e) }); }
576
+ });
577
+ server.middlewares.use('/__designer/load', (req, res) => {
578
+ const name = new URL(req.url, 'http://x').searchParams.get('name') || '';
579
+ if (!NAME_RE.test(name)) return sendJson(res, 400, { err: 'bad design name' });
580
+ const file = path.join(designsDir, `${name}.json`);
581
+ if (!fs.existsSync(file)) return sendJson(res, 404, { err: 'no such design' });
582
+ try { sendJson(res, 200, JSON.parse(fs.readFileSync(file, 'utf8'))); }
583
+ catch (e) { sendJson(res, 500, { err: String(e) }); }
584
+ });
585
+ server.middlewares.use('/__designer/list', (req, res) => {
586
+ try {
587
+ const designs = fs.existsSync(designsDir)
588
+ ? fs.readdirSync(designsDir).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5))
589
+ : [];
590
+ sendJson(res, 200, { designs });
591
+ } catch (e) { sendJson(res, 500, { err: String(e) }); }
592
+ });
593
+ },
594
+ };
595
+ }
596
+
276
597
  export function sindicateDevTools({
277
598
  sourceWrites = [],
278
599
  assetLists = [],
@@ -294,8 +615,11 @@ export function sindicateDevTools({
294
615
  ...stores.map(binStorePlugin),
295
616
  perfSinkPlugin(perfSink),
296
617
  guiPlugin(),
618
+ packImporterPlugin(),
297
619
  busPlugin({ name: 'game', prefix: '/__game', originGuard: true }),
298
620
  busPlugin({ name: 'editor', prefix: '/__editor' }),
621
+ busPlugin({ name: 'designer', prefix: '/__designer-bus' }),
622
+ designerPlugin(),
299
623
  editorShotsPlugin({ shotsDir: editorShotsDir, mapsDir }),
300
624
  ];
301
625
  }
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ // FBX → GLB (pack bake) — Synty props arrive as raw .fbx with their unit scale living in
3
+ // Unity's per-file import settings (PolygonCity is metre-authored at globalScale 100,
4
+ // PolygonTown is centimetres at 1). Shipping raw FBX makes every consumer carry both an
5
+ // FBXLoader and the scale table; baking to GLB with the scale applied kills both.
6
+ //
7
+ // node tools/fbxToGlb.mjs <packDir>
8
+ //
9
+ // Reads <packDir>/fbxScale.json (written by truthToAssemblies from the Unity .meta files),
10
+ // converts every listed .fbx, rewrites the assemblies + patches + index to point at the GLBs,
11
+ // and leaves the .fbx sources in place (nothing is deleted — re-runnable).
12
+ //
13
+ // Geometry convention: whatever three's FBXLoader produces, world-baked. That is exactly what
14
+ // the viewers rendered before, so the flipped instance transforms from truthToAssemblies keep
15
+ // matching. No handedness change happens here.
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { writeGlb } from './glbWrite.mjs';
19
+
20
+ // three's FBXLoader touches a few DOM APIs (texture loading paths we never hit)
21
+ globalThis.self = globalThis;
22
+ globalThis.window = globalThis;
23
+ const stubEl = () => ({ style: {}, addEventListener() {}, removeEventListener() {}, setAttribute() {}, getContext: () => null });
24
+ globalThis.document = { createElementNS: stubEl, createElement: stubEl };
25
+
26
+ const THREE = await import('three');
27
+ const { FBXLoader } = await import('three/examples/jsm/loaders/FBXLoader.js');
28
+
29
+ const [packDir] = process.argv.slice(2);
30
+ if (!packDir) { console.error('usage: fbxToGlb.mjs <packDir>'); process.exit(1); }
31
+
32
+ const modelsDir = path.join(packDir, 'models');
33
+ const scalePath = path.join(packDir, 'fbxScale.json');
34
+ const scales = fs.existsSync(scalePath) ? JSON.parse(fs.readFileSync(scalePath, 'utf8')) : {};
35
+ const loader = new FBXLoader();
36
+
37
+ function convert(fbxName, scale) {
38
+ const buf = fs.readFileSync(path.join(modelsDir, fbxName));
39
+ const root = loader.parse(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength), '');
40
+ root.scale.setScalar(scale);
41
+ root.updateMatrixWorld(true);
42
+
43
+ // world-bake every mesh into one buffer (parts get a single material from the truth data,
44
+ // so submesh splits buy nothing and cost draw calls)
45
+ const pos = [], nrm = [], uv = [], idx = [];
46
+ const v = new THREE.Vector3();
47
+ const nm = new THREE.Matrix3();
48
+ root.traverse((o) => {
49
+ if (!o.isMesh) return;
50
+ const g = o.geometry;
51
+ const p = g.attributes.position;
52
+ if (!p) return;
53
+ const base = pos.length / 3;
54
+ nm.getNormalMatrix(o.matrixWorld);
55
+ for (let i = 0; i < p.count; i++) {
56
+ v.fromBufferAttribute(p, i).applyMatrix4(o.matrixWorld);
57
+ pos.push(v.x, v.y, v.z);
58
+ if (g.attributes.normal) {
59
+ v.fromBufferAttribute(g.attributes.normal, i).applyMatrix3(nm).normalize();
60
+ nrm.push(v.x, v.y, v.z);
61
+ } else nrm.push(0, 1, 0);
62
+ if (g.attributes.uv) uv.push(g.attributes.uv.getX(i), g.attributes.uv.getY(i));
63
+ else uv.push(0, 0);
64
+ }
65
+ if (g.index) for (let i = 0; i < g.index.count; i++) idx.push(g.index.getX(i) + base);
66
+ else for (let i = 0; i < p.count; i++) idx.push(base + i);
67
+ });
68
+ if (!pos.length) throw new Error('no geometry');
69
+ const glbName = fbxName.replace(/\.fbx$/i, '.glb');
70
+ const r = writeGlb({
71
+ name: path.basename(glbName, '.glb'),
72
+ vertexCount: pos.length / 3,
73
+ indices: idx,
74
+ attrs: {
75
+ POSITION: { array: new Float32Array(pos), dim: 3 },
76
+ NORMAL: { array: new Float32Array(nrm), dim: 3 },
77
+ TEXCOORD_0: { array: new Float32Array(uv), dim: 2 },
78
+ },
79
+ subs: [{ firstIndex: 0, indexCount: idx.length, baseVertex: 0 }],
80
+ }, path.join(modelsDir, glbName));
81
+ return { glbName, ...r };
82
+ }
83
+
84
+ const renamed = {};
85
+ for (const [fbxName, scale] of Object.entries(scales)) {
86
+ if (!fs.existsSync(path.join(modelsDir, fbxName))) { console.warn(`missing ${fbxName}`); continue; }
87
+ try {
88
+ const r = convert(fbxName, scale);
89
+ renamed[fbxName] = r.glbName;
90
+ console.log(`${fbxName} ×${scale} → ${r.glbName} ${r.verts} verts, ${r.tris} tris`);
91
+ } catch (e) { console.warn(`FAILED ${fbxName}: ${e.message}`); }
92
+ }
93
+
94
+ // repoint assemblies, patches and the model index at the GLBs
95
+ let touched = 0;
96
+ for (const dir of ['assemblies', 'patches']) {
97
+ const d = path.join(packDir, dir);
98
+ if (!fs.existsSync(d)) continue;
99
+ for (const f of fs.readdirSync(d)) {
100
+ if (!f.endsWith('.json') || f === 'index.json') continue;
101
+ const p = path.join(d, f);
102
+ const man = JSON.parse(fs.readFileSync(p, 'utf8'));
103
+ let hit = false;
104
+ for (const part of man.parts) if (renamed[part.file]) { part.file = renamed[part.file]; hit = true; }
105
+ if (hit) { fs.writeFileSync(p, JSON.stringify(man)); touched++; }
106
+ }
107
+ }
108
+ const idxPath = path.join(packDir, 'index.json');
109
+ if (fs.existsSync(idxPath)) {
110
+ const index = JSON.parse(fs.readFileSync(idxPath, 'utf8'))
111
+ .map((f) => renamed[f] ?? f)
112
+ .filter((f) => !/\.fbx$/i.test(f));
113
+ fs.writeFileSync(idxPath, JSON.stringify([...new Set(index)].sort()));
114
+ }
115
+ console.log(`${Object.keys(renamed).length} converted · ${touched} manifests repointed`);
@@ -0,0 +1,62 @@
1
+ // GLB WRITER — shared by unityMeshToGlb (Unity YAML mesh recovery) and
2
+ // rebuildSimpleRoundabouts (synthesized replacements for Kiwi's deleted meshes).
3
+ // mesh = { name, vertexCount, indices, attrs: { POSITION: {array, dim}, ... }, subs }
4
+ import fs from 'node:fs';
5
+
6
+ export function writeGlb(mesh, outFile) {
7
+ const buffers = [];
8
+ let byteLen = 0;
9
+ const views = [];
10
+ const push = (buf, target) => {
11
+ while (byteLen % 4) { buffers.push(Buffer.alloc(1)); byteLen++; }
12
+ views.push({ buffer: 0, byteOffset: byteLen, byteLength: buf.length, ...(target ? { target } : {}) });
13
+ buffers.push(buf); byteLen += buf.length;
14
+ return views.length - 1;
15
+ };
16
+ const accessors = [];
17
+ const attrAcc = {};
18
+ for (const [sem, a] of Object.entries(mesh.attrs)) {
19
+ const buf = Buffer.from(a.array.buffer, a.array.byteOffset, a.array.byteLength);
20
+ const view = push(buf, 34962);
21
+ let min, max;
22
+ if (sem === 'POSITION') {
23
+ min = [Infinity, Infinity, Infinity]; max = [-Infinity, -Infinity, -Infinity];
24
+ for (let v = 0; v < mesh.vertexCount; v++) for (let d = 0; d < 3; d++) {
25
+ const x = a.array[v * 3 + d];
26
+ if (x < min[d]) min[d] = x;
27
+ if (x > max[d]) max[d] = x;
28
+ }
29
+ }
30
+ accessors.push({ bufferView: view, componentType: 5126, count: mesh.vertexCount, type: ['SCALAR', 'VEC2', 'VEC3', 'VEC4'][a.dim - 1], ...(min ? { min, max } : {}) });
31
+ attrAcc[sem] = accessors.length - 1;
32
+ }
33
+ const primitives = mesh.subs.map((s) => {
34
+ const idx = new Uint32Array(mesh.indices.slice(s.firstIndex, s.firstIndex + s.indexCount).map((i) => i + s.baseVertex));
35
+ const view = push(Buffer.from(idx.buffer), 34963);
36
+ accessors.push({ bufferView: view, componentType: 5125, count: idx.length, type: 'SCALAR' });
37
+ return { attributes: attrAcc, indices: accessors.length - 1, mode: 4 };
38
+ });
39
+ const gltf = {
40
+ asset: { version: '2.0', generator: 'sindicate unityMeshToGlb' },
41
+ scene: 0,
42
+ scenes: [{ nodes: [0] }],
43
+ nodes: [{ mesh: 0, name: mesh.name }],
44
+ meshes: [{ name: mesh.name, primitives }],
45
+ accessors,
46
+ bufferViews: views,
47
+ buffers: [{ byteLength: byteLen }],
48
+ };
49
+ const bin = Buffer.concat(buffers, byteLen);
50
+ let json = Buffer.from(JSON.stringify(gltf));
51
+ while (json.length % 4) json = Buffer.concat([json, Buffer.from(' ')]);
52
+ const binPad = (4 - (bin.length % 4)) % 4;
53
+ const paddedBin = binPad ? Buffer.concat([bin, Buffer.alloc(binPad)]) : bin;
54
+ const total = 12 + 8 + json.length + 8 + paddedBin.length;
55
+ const out = Buffer.alloc(total);
56
+ out.writeUInt32LE(0x46546c67, 0); out.writeUInt32LE(2, 4); out.writeUInt32LE(total, 8);
57
+ out.writeUInt32LE(json.length, 12); out.writeUInt32LE(0x4e4f534a, 16); json.copy(out, 20);
58
+ let o = 20 + json.length;
59
+ out.writeUInt32LE(paddedBin.length, o); out.writeUInt32LE(0x004e4942, o + 4); paddedBin.copy(out, o + 8);
60
+ fs.writeFileSync(outFile, out);
61
+ return { verts: mesh.vertexCount, tris: mesh.indices.length / 3, prims: primitives.length, bytes: total };
62
+ }