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
@@ -0,0 +1,94 @@
1
+ // EXPORT ROAD REFS — Unity renders every road prefab to a PNG (the GROUND TRUTH in
2
+ // pixels). Drop next to ExportKiwiRoadMaterials.cs in Assets/Editor/, run
3
+ // Tools ▸ Sindicate ▸ Export Road Reference Images
4
+ // Writes Assets/SindicateExport/refs/<prefab>.png (512px, iso view). Sindicate's compare
5
+ // sheet puts these beside its own renders so every mismatch is visible at a glance.
6
+ using UnityEngine;
7
+ using UnityEditor;
8
+ using System.IO;
9
+
10
+ public static class ExportKiwiRoadRefs
11
+ {
12
+ static readonly string[] ROOTS = {
13
+ "Assets/AIGenerateMap/Prefabs/Roads",
14
+ "Assets/AIGenerateMap/JunctionPrefabs",
15
+ "Assets/AIGenerateMap/RoadPrefabs",
16
+ };
17
+
18
+ [MenuItem("Tools/Sindicate/Export Road Reference Images")]
19
+ public static void Export()
20
+ {
21
+ var outDir = "Assets/SindicateExport/refs";
22
+ Directory.CreateDirectory(outDir);
23
+
24
+ var camGO = new GameObject("__sindicateRefCam");
25
+ var cam = camGO.AddComponent<Camera>();
26
+ cam.clearFlags = CameraClearFlags.SolidColor;
27
+ cam.backgroundColor = new Color(0.09f, 0.09f, 0.14f);
28
+ cam.fieldOfView = 40f;
29
+ cam.farClipPlane = 5000f;
30
+ var lightGO = new GameObject("__sindicateRefLight");
31
+ var light = lightGO.AddComponent<Light>();
32
+ light.type = LightType.Directional;
33
+ light.intensity = 1.4f;
34
+ lightGO.transform.rotation = Quaternion.Euler(50, -30, 0);
35
+
36
+ var rt = new RenderTexture(512, 512, 24);
37
+ var tex = new Texture2D(512, 512, TextureFormat.RGB24, false);
38
+ int done = 0;
39
+
40
+ try
41
+ {
42
+ foreach (var root in ROOTS)
43
+ {
44
+ foreach (var guid in AssetDatabase.FindAssets("t:Prefab", new[] { root }))
45
+ {
46
+ var path = AssetDatabase.GUIDToAssetPath(guid);
47
+ var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
48
+ if (prefab == null) continue;
49
+ // name refs the same way the materials export names runs: variant dirs
50
+ // (Motorway/DualCarriageway) prefixed to disambiguate duplicates
51
+ var name = Path.GetFileNameWithoutExtension(path);
52
+ if (path.Contains("/Motorway/")) name = "Motorway_" + name;
53
+ else if (path.Contains("/DualCarriageway/")) name = "DualCarriageway_" + name;
54
+
55
+ var inst = (GameObject)Object.Instantiate(prefab, new Vector3(0, -5000, 0), Quaternion.identity);
56
+ try
57
+ {
58
+ var bounds = new Bounds(inst.transform.position, Vector3.one);
59
+ bool first = true;
60
+ foreach (var r in inst.GetComponentsInChildren<Renderer>())
61
+ {
62
+ if (first) { bounds = r.bounds; first = false; }
63
+ else bounds.Encapsulate(r.bounds);
64
+ }
65
+ var size = Mathf.Max(bounds.size.x, bounds.size.z, bounds.size.y, 2f);
66
+ var dist = size * 1.35f;
67
+ cam.transform.position = bounds.center + new Vector3(0.7f, 0.8f, 1f).normalized * dist;
68
+ cam.transform.LookAt(bounds.center);
69
+
70
+ cam.targetTexture = rt;
71
+ cam.Render();
72
+ RenderTexture.active = rt;
73
+ tex.ReadPixels(new Rect(0, 0, 512, 512), 0, 0);
74
+ tex.Apply();
75
+ File.WriteAllBytes(Path.Combine(outDir, name + ".png"), tex.EncodeToPNG());
76
+ done++;
77
+ }
78
+ finally { Object.DestroyImmediate(inst); }
79
+ }
80
+ }
81
+ }
82
+ finally
83
+ {
84
+ cam.targetTexture = null;
85
+ RenderTexture.active = null;
86
+ Object.DestroyImmediate(rt);
87
+ Object.DestroyImmediate(tex);
88
+ Object.DestroyImmediate(camGO);
89
+ Object.DestroyImmediate(lightGO);
90
+ AssetDatabase.Refresh();
91
+ }
92
+ EditorUtility.DisplayDialog("Sindicate refs", $"Rendered {done} reference images.\n\nTell Claude.", "OK");
93
+ }
94
+ }
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ // BUILD THE KIWIROADS PACK — one command, correct order. The stages are order-dependent
3
+ // (assemblies must exist before patches merge; FBX must be referenced before they're baked),
4
+ // so running them by hand invites half-built packs.
5
+ //
6
+ // node tools/buildKiwiRoadsPack.mjs [--export <SindicateExport>] [--kiwi <KiwiAssets>] [--pack <packDir>]
7
+ //
8
+ // 1. truthToAssemblies Unity truth export → 538 assemblies (+ merges patches/, fbxScale.json)
9
+ // 2. rebuildSimpleRoundabouts regenerate the meshes Kiwi lost upstream → patches/
10
+ // 3. truthToAssemblies again so the fresh patches land in the assemblies
11
+ // 4. fbxToGlb bake Synty FBX props (with their Unity import scale) → GLB
12
+ import { spawnSync } from 'node:child_process';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const here = path.dirname(fileURLToPath(import.meta.url));
17
+ const arg = (name, fallback) => {
18
+ const i = process.argv.indexOf(`--${name}`);
19
+ return i > 0 ? process.argv[i + 1] : fallback;
20
+ };
21
+ const exportDir = arg('export', '/Users/nick/Desktop/Kiwi/Assets/SindicateExport');
22
+ const kiwiAssets = arg('kiwi', '/Users/nick/Desktop/Kiwi/Assets');
23
+ const packDir = arg('pack', '/Users/nick/Sites/TakeTheCity/public/assets/packs/KiwiRoads');
24
+
25
+ const run = (script, args, label) => {
26
+ console.log(`\n── ${label}`);
27
+ const r = spawnSync(process.execPath, [path.join(here, script), ...args], { stdio: 'inherit' });
28
+ if (r.status !== 0) { console.error(`FAILED at ${label}`); process.exit(r.status ?? 1); }
29
+ };
30
+
31
+ run('truthToAssemblies.mjs', [exportDir, kiwiAssets, packDir], 'assemblies from Unity truth');
32
+ run('rebuildSimpleRoundabouts.mjs', [packDir], 'reconstruct SimpleRoundabout meshes');
33
+ run('truthToAssemblies.mjs', [exportDir, kiwiAssets, packDir], 'assemblies again (merge fresh patches)');
34
+ run('fbxToGlb.mjs', [packDir], 'bake FBX props → GLB');
35
+ run('roadKitAnalyze.mjs', [packDir], 'measure sockets → roadkit.json');
36
+ console.log('\npack built.');
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ // THE DESIGNER's MCP SERVER — lets a Claude session drive the World Designer while the
3
+ // human watches the viewport. Zero-dep stdio JSON-RPC (MCP 2024-11-05): every tool call
4
+ // proxies to the game dev server's designer bus (/__designer-bus) or endpoints, and the
5
+ // open /designer.html page executes it live.
6
+ //
7
+ // Register once per game:
8
+ // claude mcp add dustwater-designer -- node /Users/nick/Sites/Sindicate/tools/designer-mcp.mjs --server http://localhost:5317
9
+ //
10
+ // The designer page must be open in a browser tab (it polls the bus).
11
+ const SERVER = (() => {
12
+ const i = process.argv.indexOf('--server');
13
+ return (i >= 0 && process.argv[i + 1]) || process.env.SINDICATE_SERVER || 'http://localhost:5317';
14
+ })();
15
+
16
+ async function bus(op, args = {}, timeoutMs = 20000) {
17
+ const { id } = await fetch(`${SERVER}/__designer-bus/cmd`, {
18
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ op, args }),
19
+ }).then((r) => r.json());
20
+ const t0 = Date.now();
21
+ while (Date.now() - t0 < timeoutMs) {
22
+ const r = await fetch(`${SERVER}/__designer-bus/take?id=${id}`);
23
+ if (r.status === 200) {
24
+ const j = await r.json();
25
+ if (!j.ok) throw new Error(j.err ?? 'designer op failed');
26
+ return j.value;
27
+ }
28
+ await new Promise((res) => setTimeout(res, 300));
29
+ }
30
+ throw new Error(`designer page did not answer in ${timeoutMs / 1000}s — is /designer.html open in a browser tab on ${SERVER}?`);
31
+ }
32
+
33
+ const num = (d) => ({ type: 'number', description: d });
34
+ const str = (d) => ({ type: 'string', description: d });
35
+ const TOOLS = [
36
+ { name: 'designer_status', description: 'Current designer state: mode, prefab/road counts, selection, layout name.', inputSchema: { type: 'object', properties: {} },
37
+ run: () => bus('status') },
38
+ { name: 'designer_packs', description: 'List imported asset packs available to the designer.', inputSchema: { type: 'object', properties: {} },
39
+ run: async () => (await fetch(`${SERVER}/__pack/list`).then((r) => r.json())).imported },
40
+ { name: 'designer_models', description: 'List a pack’s placeable models plus its default texture atlas URL.', inputSchema: { type: 'object', properties: { pack: str('imported pack name') }, required: ['pack'] },
41
+ run: ({ pack }) => fetch(`${SERVER}/__designer/models?pack=${encodeURIComponent(pack)}`).then((r) => r.json()) },
42
+ { name: 'designer_palette', description: 'Search the loaded palette (pack/model labels) by substring.', inputSchema: { type: 'object', properties: { filter: str('substring filter, empty for first 200') } },
43
+ run: ({ filter = '' }) => bus('palette', filter) },
44
+ { name: 'designer_place', description: 'Place a prefab at world x,z (y defaults to terrain height). Returns its index.', inputSchema: { type: 'object', properties: { file: str('model name without extension'), dir: str('URL dir, e.g. /assets/packs/<pack>/models'), x: num('world x'), z: num('world z'), y: num('optional y override'), ry: num('yaw radians'), tex: str('optional texture URL') }, required: ['file', 'dir', 'x', 'z'] },
45
+ run: (a) => bus('place', a) },
46
+ { name: 'designer_move', description: 'Move/rotate placed prefab #i (omitted fields keep their value; y re-snaps to terrain unless given).', inputSchema: { type: 'object', properties: { i: num('prefab index'), x: num(''), z: num(''), y: num(''), ry: num('yaw radians') }, required: ['i'] },
47
+ run: (a) => bus('move', a) },
48
+ { name: 'designer_remove', description: 'Delete placed prefab #i.', inputSchema: { type: 'object', properties: { i: num('prefab index') }, required: ['i'] },
49
+ run: (a) => bus('remove', a) },
50
+ { name: 'designer_road', description: 'Add a road polyline: pts = [{x,z}, ...] in world metres.', inputSchema: { type: 'object', properties: { pts: { type: 'array', items: { type: 'object', properties: { x: num(''), z: num('') }, required: ['x', 'z'] }, description: 'polyline points' } }, required: ['pts'] },
51
+ run: (a) => bus('road', a) },
52
+ { name: 'designer_camera', description: 'Move the viewport camera to x,y,z looking at tx,ty,tz.', inputSchema: { type: 'object', properties: { x: num(''), y: num(''), z: num(''), tx: num('look-at x'), ty: num('look-at y'), tz: num('look-at z') }, required: ['x', 'y', 'z'] },
53
+ run: (a) => bus('camera', a) },
54
+ { name: 'designer_save', description: 'Save the layout to designs/<name>.json in the game repo.', inputSchema: { type: 'object', properties: { name: str('design name') }, required: ['name'] },
55
+ run: (a) => bus('save', a) },
56
+ { name: 'designer_load', description: 'Load designs/<name>.json into the viewport (replaces current layout).', inputSchema: { type: 'object', properties: { name: str('design name') }, required: ['name'] },
57
+ run: (a) => bus('load', a, 120000) },
58
+ { name: 'designer_clear', description: 'Remove every placed prefab and road (does not touch saved files).', inputSchema: { type: 'object', properties: {} },
59
+ run: () => bus('clear') },
60
+ { name: 'designer_designs', description: 'List saved design names.', inputSchema: { type: 'object', properties: {} },
61
+ run: async () => (await fetch(`${SERVER}/__designer/list`).then((r) => r.json())).designs },
62
+ ];
63
+
64
+ // ---- stdio JSON-RPC plumbing (newline-delimited, per the MCP stdio transport) ----
65
+ const reply = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
66
+ const fail = (id, message) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32603, message } }) + '\n');
67
+
68
+ let buf = '';
69
+ process.stdin.on('data', (chunk) => {
70
+ buf += chunk;
71
+ let nl;
72
+ while ((nl = buf.indexOf('\n')) >= 0) {
73
+ const line = buf.slice(0, nl).trim();
74
+ buf = buf.slice(nl + 1);
75
+ if (line) handle(line);
76
+ }
77
+ });
78
+
79
+ async function handle(line) {
80
+ let msg;
81
+ try { msg = JSON.parse(line); } catch { return; }
82
+ const { id, method, params } = msg;
83
+ if (method === 'initialize') {
84
+ return reply(id, {
85
+ protocolVersion: params?.protocolVersion ?? '2024-11-05',
86
+ capabilities: { tools: {} },
87
+ serverInfo: { name: 'sindicate-designer', version: '1.0.0' },
88
+ });
89
+ }
90
+ if (method === 'tools/list') return reply(id, { tools: TOOLS.map(({ run, ...t }) => t) });
91
+ if (method === 'tools/call') {
92
+ const tool = TOOLS.find((t) => t.name === params?.name);
93
+ if (!tool) return fail(id, `unknown tool ${params?.name}`);
94
+ try {
95
+ const value = await tool.run(params?.arguments ?? {});
96
+ return reply(id, { content: [{ type: 'text', text: JSON.stringify(value ?? { ok: true }) }] });
97
+ } catch (e) {
98
+ return reply(id, { content: [{ type: 'text', text: String(e?.message ?? e) }], isError: true });
99
+ }
100
+ }
101
+ if (method === 'ping') return reply(id, {});
102
+ if (id !== undefined) return fail(id, `unknown method ${method}`); // notifications are ignored
103
+ }
@@ -0,0 +1,316 @@
1
+ // THE WORLD DESIGNER (v1) — the engine's world editor. A game exposes it with a one-line
2
+ // designer.html entry:
3
+ //
4
+ // <script type="module">
5
+ // import { startDesigner } from 'sindicate/tools/designer.js';
6
+ // startDesigner({ heightAt: (x, z) => 0 }); // pass the game's heightAt for real terrain
7
+ // </script>
8
+ //
9
+ // v1: orbit viewport · prefab palette (imported packs via /__pack, plus the game's own
10
+ // asset dirs) · click-to-place on the ground · drag-move · Q/E rotate · Delete removes ·
11
+ // road polyline mode · save/load layout JSON through the dev middleware
12
+ // (designs/<name>.json in the GAME repo — machine-written layouts are JSON by law).
13
+ //
14
+ // The DESIGNER BUS (served by designerPlugin alongside /__pack) lets an MCP server drive
15
+ // every one of these actions programmatically while the human watches the viewport.
16
+ import * as THREE from 'three/webgpu';
17
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
18
+ import { loadModel } from '../src/core/assets.js';
19
+
20
+ const state = {
21
+ layout: { name: 'untitled', prefabs: [], roads: [] },
22
+ placed: [], // { entry, object } parallel to layout.prefabs
23
+ sel: -1,
24
+ mode: 'place', // 'place' | 'road' | 'select'
25
+ activePrefab: null, // { file, dir, tex }
26
+ roadPts: [],
27
+ heightAt: () => 0,
28
+ };
29
+
30
+ export async function startDesigner({ heightAt, worldSize = 2000 } = {}) {
31
+ if (heightAt) state.heightAt = heightAt;
32
+ document.body.style.cssText = 'margin:0;overflow:hidden;background:#101018;font:13px -apple-system,sans-serif;color:#e8e2d0';
33
+
34
+ // ── viewport ──
35
+ const renderer = new THREE.WebGPURenderer({ antialias: true });
36
+ await renderer.init();
37
+ renderer.setSize(innerWidth, innerHeight);
38
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
39
+ document.body.appendChild(renderer.domElement);
40
+ const scene = new THREE.Scene();
41
+ scene.background = new THREE.Color(0x181826);
42
+ const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.1, worldSize * 4);
43
+ camera.position.set(0, worldSize * 0.28, worldSize * 0.4); // open on the whole map, not a patch of dirt
44
+ const controls = new OrbitControls(camera, renderer.domElement);
45
+ controls.target.set(0, 0, 0);
46
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x334455, 1.2));
47
+ const sun = new THREE.DirectionalLight(0xfff2dd, 2.2);
48
+ sun.position.set(120, 180, 80);
49
+ scene.add(sun);
50
+ // the game's REAL ground, low-res — placement is judged against what the world will be,
51
+ // not a flat grid. 256² segments of heightAt (~66k calls, both games' heightAt is pure
52
+ // math) shaded by relief: valley umber up to pale bone.
53
+ const TSEG = 256;
54
+ const tGeo = new THREE.PlaneGeometry(worldSize, worldSize, TSEG, TSEG).rotateX(-Math.PI / 2);
55
+ {
56
+ const pos = tGeo.attributes.position;
57
+ const cols = new Float32Array(pos.count * 3);
58
+ let lo = Infinity, hi = -Infinity;
59
+ for (let i = 0; i < pos.count; i++) {
60
+ const h = state.heightAt(pos.getX(i), pos.getZ(i));
61
+ pos.setY(i, h);
62
+ if (h < lo) lo = h;
63
+ if (h > hi) hi = h;
64
+ }
65
+ for (let i = 0; i < pos.count; i++) {
66
+ const t = (pos.getY(i) - lo) / Math.max(1e-6, hi - lo);
67
+ cols[i * 3] = 0.24 + 0.5 * t; cols[i * 3 + 1] = 0.22 + 0.42 * t; cols[i * 3 + 2] = 0.18 + 0.3 * t;
68
+ }
69
+ tGeo.setAttribute('color', new THREE.BufferAttribute(cols, 3));
70
+ tGeo.computeVertexNormals();
71
+ }
72
+ const terrain = new THREE.Mesh(tGeo, new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 1 }));
73
+ terrain.name = 'designer:terrain';
74
+ scene.add(terrain);
75
+ if (!heightAt) scene.add(new THREE.GridHelper(worldSize, 200, 0x3a3a52, 0x26263a)); // flat fallback still reads as a floor
76
+ const groundPlane = new THREE.Mesh(new THREE.PlaneGeometry(worldSize * 4, worldSize * 4).rotateX(-Math.PI / 2),
77
+ new THREE.MeshBasicMaterial({ visible: false }));
78
+ scene.add(groundPlane);
79
+
80
+ const roadLines = new THREE.Group(); scene.add(roadLines);
81
+ addEventListener('resize', () => {
82
+ camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
83
+ renderer.setSize(innerWidth, innerHeight);
84
+ });
85
+
86
+ // ── UI shell ──
87
+ const ui = document.createElement('div');
88
+ ui.style.cssText = 'position:fixed;left:0;top:0;bottom:0;width:280px;overflow-y:auto;background:rgba(16,16,26,0.94);border-right:1px solid #2c2c40;padding:14px;z-index:10';
89
+ ui.innerHTML = `
90
+ <div style="color:#e8c66a;font-weight:700;letter-spacing:2px;margin-bottom:2px">SINDICATE DESIGNER</div>
91
+ <div id="d-status" style="color:#8d8875;font-size:11px;margin-bottom:10px">v1</div>
92
+ <div style="display:flex;gap:6px;margin-bottom:10px">
93
+ <input id="d-name" value="untitled" style="flex:1;background:#1c1c2b;border:1px solid #2c2c40;border-radius:5px;color:#e8e2d0;padding:4px 8px;font-size:12px">
94
+ <button id="d-save" style="${BTN}">Save</button>
95
+ <button id="d-load" style="${BTN}">Load</button>
96
+ </div>
97
+ <div style="display:flex;gap:6px;margin-bottom:12px">
98
+ <button id="d-mode-place" style="${BTN}">Place</button>
99
+ <button id="d-mode-road" style="${BTN}">Road</button>
100
+ <button id="d-mode-select" style="${BTN}">Select</button>
101
+ </div>
102
+ <div style="color:#8d8875;font-size:11px;margin-bottom:8px">click = place/pick · drag = move · Q/E rotate · Backspace delete · road mode: click points, Enter ends road</div>
103
+ <input id="d-filter" placeholder="filter prefabs…" style="width:100%;background:#1c1c2b;border:1px solid #2c2c40;border-radius:5px;color:#e8e2d0;padding:5px 8px;font-size:12px;margin-bottom:8px">
104
+ <div id="d-palette" style="font-size:12px"></div>`;
105
+ document.body.appendChild(ui);
106
+ const status = (m) => { ui.querySelector('#d-status').textContent = m; };
107
+
108
+ // ── palette: imported packs + staged inventories via the dev middleware ──
109
+ let palette = []; // { label, file, dir, tex } dir = URL prefix the model loads from
110
+ async function loadPalette() {
111
+ try {
112
+ const reg = await fetch('/__pack/list').then((r) => r.json());
113
+ for (const p of reg.imported ?? []) {
114
+ const files = await fetch(`/__designer/models?pack=${encodeURIComponent(p)}`).then((r) => r.json()).catch(() => ({ models: [] }));
115
+ for (const f of files.models ?? []) {
116
+ const file = f.replace(/\.(fbx|glb)$/i, '');
117
+ const ext = /\.glb$/i.test(f) ? 'glb' : 'fbx';
118
+ palette.push({ label: `${p}/${file}`, file, ext, dir: `/assets/packs/${p}/models`, ...(files.tex ? { tex: files.tex } : {}) });
119
+ }
120
+ }
121
+ } catch (e) { console.warn('[designer] pack palette failed', e); }
122
+ renderPalette();
123
+ status(`${palette.length} prefabs`);
124
+ }
125
+ function renderPalette(filter = '') {
126
+ const el = ui.querySelector('#d-palette');
127
+ el.innerHTML = '';
128
+ const f = filter.toLowerCase();
129
+ for (const p of palette.filter((p) => !f || p.label.toLowerCase().includes(f)).slice(0, 400)) {
130
+ const row = document.createElement('div');
131
+ row.textContent = p.label;
132
+ row.style.cssText = 'padding:3px 6px;border-radius:4px;cursor:pointer;color:#b8b09a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis' + (state.activePrefab?.label === p.label ? ';background:#2c2c48;color:#e8c66a' : '');
133
+ row.onclick = () => { state.activePrefab = p; state.mode = 'place'; renderPalette(ui.querySelector('#d-filter').value); status('placing: ' + p.label); };
134
+ el.appendChild(row);
135
+ }
136
+ }
137
+ ui.querySelector('#d-filter').oninput = (e) => renderPalette(e.target.value);
138
+ ui.querySelector('#d-mode-place').onclick = () => { state.mode = 'place'; status('mode: place'); };
139
+ ui.querySelector('#d-mode-road').onclick = () => { state.mode = 'road'; state.roadPts = []; status('mode: road — click points, Enter to finish'); };
140
+ ui.querySelector('#d-mode-select').onclick = () => { state.mode = 'select'; status('mode: select'); };
141
+
142
+ // ── placement ──
143
+ const raycaster = new THREE.Raycaster();
144
+ const mouse = new THREE.Vector2();
145
+ const groundHit = (ev) => {
146
+ mouse.set((ev.clientX / innerWidth) * 2 - 1, -(ev.clientY / innerHeight) * 2 + 1);
147
+ raycaster.setFromCamera(mouse, camera);
148
+ const hit = raycaster.intersectObjects([terrain, groundPlane])[0]; // real ground first, flat far-field after
149
+ return hit ? { x: hit.point.x, z: hit.point.z } : null;
150
+ };
151
+
152
+ // every mutation lands in sessionStorage: vite's dep-optimize reload (first FBX load
153
+ // discovers new deps) or a stray F5 must never eat a layout in progress
154
+ const persist = () => { try { sessionStorage.setItem('sindicate-designer', JSON.stringify(state.layout)); } catch { /* full */ } };
155
+
156
+ async function placePrefab(entry) {
157
+ const obj = new THREE.Group();
158
+ obj.name = `dz:${entry.file}`;
159
+ try {
160
+ const bare = entry.file.replace(/\.(fbx|glb)$/i, '');
161
+ const ext = entry.ext ?? (/\.glb$/i.test(entry.file) ? 'glb' : 'fbx');
162
+ const model = await loadModel(`${entry.dir}/${bare}.${ext}`, entry.tex ? { texture: entry.tex } : {});
163
+ obj.add(model.clone ? model.clone() : model);
164
+ } catch (e) {
165
+ // placeholder box if the model can't load — the layout entry still lands
166
+ obj.add(new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2), new THREE.MeshStandardMaterial({ color: 0xcc5533 })));
167
+ }
168
+ obj.position.set(entry.x, entry.y ?? state.heightAt(entry.x, entry.z), entry.z);
169
+ obj.rotation.y = entry.ry ?? 0;
170
+ scene.add(obj);
171
+ state.layout.prefabs.push(entry);
172
+ state.placed.push({ entry, object: obj });
173
+ persist();
174
+ return state.placed.length - 1;
175
+ }
176
+
177
+ function select(i) {
178
+ if (state.sel >= 0) state.placed[state.sel]?.object.traverse((o) => { if (o.material?.emissive) o.material.emissive?.setHex?.(0x000000); });
179
+ state.sel = i;
180
+ if (i >= 0) status(`selected #${i} ${state.placed[i].entry.file}`);
181
+ }
182
+
183
+ function removeSelected() {
184
+ if (state.sel < 0) return;
185
+ const { object } = state.placed[state.sel];
186
+ scene.remove(object);
187
+ state.placed.splice(state.sel, 1);
188
+ state.layout.prefabs.splice(state.sel, 1);
189
+ select(-1);
190
+ persist();
191
+ }
192
+
193
+ function redrawRoads() {
194
+ roadLines.clear();
195
+ for (const road of state.layout.roads) {
196
+ const pts = road.map((p) => new THREE.Vector3(p.x, state.heightAt(p.x, p.z) + 0.3, p.z));
197
+ roadLines.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: 0xe8c66a })));
198
+ }
199
+ if (state.roadPts.length > 1) {
200
+ const pts = state.roadPts.map((p) => new THREE.Vector3(p.x, state.heightAt(p.x, p.z) + 0.3, p.z));
201
+ roadLines.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: 0x7dc24a })));
202
+ }
203
+ }
204
+
205
+ renderer.domElement.addEventListener('pointerdown', async (ev) => {
206
+ if (ev.button !== 0 || ev.target !== renderer.domElement) return;
207
+ const g = groundHit(ev);
208
+ if (!g) return;
209
+ if (state.mode === 'place' && state.activePrefab) {
210
+ const y = state.heightAt(g.x, g.z);
211
+ await placePrefab({ file: state.activePrefab.file, dir: state.activePrefab.dir, ...(state.activePrefab.ext ? { ext: state.activePrefab.ext } : {}), x: +g.x.toFixed(2), y: +y.toFixed(2), z: +g.z.toFixed(2), ry: 0, ...(state.activePrefab.tex ? { tex: state.activePrefab.tex } : {}) });
212
+ status(`placed ${state.activePrefab.file} (${state.layout.prefabs.length} total)`);
213
+ } else if (state.mode === 'road') {
214
+ state.roadPts.push({ x: +g.x.toFixed(1), z: +g.z.toFixed(1) });
215
+ redrawRoads();
216
+ } else if (state.mode === 'select') {
217
+ raycaster.setFromCamera(mouse, camera);
218
+ const hits = raycaster.intersectObjects(state.placed.map((p) => p.object), true);
219
+ if (hits.length) {
220
+ const root = state.placed.findIndex((p) => { let o = hits[0].object; while (o) { if (o === p.object) return true; o = o.parent; } return false; });
221
+ select(root);
222
+ } else select(-1);
223
+ }
224
+ });
225
+
226
+ addEventListener('keydown', (ev) => {
227
+ if (ev.target.tagName === 'INPUT') return;
228
+ if (ev.key === 'Enter' && state.mode === 'road' && state.roadPts.length > 1) {
229
+ state.layout.roads.push(state.roadPts);
230
+ state.roadPts = [];
231
+ redrawRoads();
232
+ persist();
233
+ status(`road saved (${state.layout.roads.length} roads)`);
234
+ }
235
+ if (state.sel >= 0) {
236
+ const p = state.placed[state.sel];
237
+ if (ev.key === 'q' || ev.key === 'Q') { p.entry.ry = +((p.entry.ry + Math.PI / 8) % (Math.PI * 2)).toFixed(3); p.object.rotation.y = p.entry.ry; }
238
+ if (ev.key === 'e' || ev.key === 'E') { p.entry.ry = +((p.entry.ry - Math.PI / 8) % (Math.PI * 2)).toFixed(3); p.object.rotation.y = p.entry.ry; }
239
+ if (ev.key === 'Backspace' || ev.key === 'Delete') removeSelected();
240
+ }
241
+ });
242
+
243
+ // ── save / load ──
244
+ async function save(name) {
245
+ state.layout.name = name;
246
+ const r = await fetch(`/__designer/save?name=${encodeURIComponent(name)}`, {
247
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(state.layout, null, 2),
248
+ });
249
+ status(r.ok ? `saved designs/${name}.json (${state.layout.prefabs.length} prefabs, ${state.layout.roads.length} roads)` : 'save failed');
250
+ return r.ok;
251
+ }
252
+ async function load(name) {
253
+ const j = await fetch(`/__designer/load?name=${encodeURIComponent(name)}`).then((r) => r.json());
254
+ if (j.err) { status('load failed: ' + j.err); return false; }
255
+ for (const p of state.placed) scene.remove(p.object);
256
+ state.placed = []; state.layout = j; state.sel = -1;
257
+ ui.querySelector('#d-name').value = j.name ?? name;
258
+ const entries = [...state.layout.prefabs];
259
+ state.layout.prefabs = [];
260
+ for (const e of entries) await placePrefab(e);
261
+ redrawRoads();
262
+ persist();
263
+ status(`loaded ${name} (${state.layout.prefabs.length} prefabs, ${state.layout.roads.length} roads)`);
264
+ return true;
265
+ }
266
+ ui.querySelector('#d-save').onclick = () => save(ui.querySelector('#d-name').value.trim() || 'untitled');
267
+ ui.querySelector('#d-load').onclick = () => load(ui.querySelector('#d-name').value.trim() || 'untitled');
268
+
269
+ // ── THE DESIGNER BUS — the MCP server drives everything above through this ──
270
+ const api = {
271
+ status: () => ({ mode: state.mode, prefabs: state.layout.prefabs.length, roads: state.layout.roads.length, sel: state.sel, active: state.activePrefab?.label ?? null, name: state.layout.name }),
272
+ palette: (f = '') => palette.filter((p) => !f || p.label.toLowerCase().includes(f.toLowerCase())).slice(0, 200).map((p) => p.label),
273
+ place: async ({ file, dir, x, z, y, ry = 0, tex, ext }) => { const i = await placePrefab({ file, dir, ...(ext ? { ext } : {}), x, y: y ?? state.heightAt(x, z), z, ry, ...(tex ? { tex } : {}) }); return { placed: i, total: state.layout.prefabs.length }; },
274
+ move: ({ i, x, z, y, ry }) => { const p = state.placed[i]; if (!p) return { err: 'no such index' };
275
+ if (x != null) p.entry.x = x; if (z != null) p.entry.z = z;
276
+ p.entry.y = y ?? state.heightAt(p.entry.x, p.entry.z);
277
+ if (ry != null) p.entry.ry = ry;
278
+ p.object.position.set(p.entry.x, p.entry.y, p.entry.z); p.object.rotation.y = p.entry.ry; persist(); return { ok: true }; },
279
+ remove: ({ i }) => { select(i); removeSelected(); return { total: state.layout.prefabs.length }; },
280
+ road: ({ pts }) => { state.layout.roads.push(pts); redrawRoads(); persist(); return { roads: state.layout.roads.length }; },
281
+ camera: ({ x, y, z, tx = 0, ty = 0, tz = 0 }) => { camera.position.set(x, y, z); controls.target.set(tx, ty, tz); controls.update(); return { ok: true }; },
282
+ save: ({ name }) => save(name ?? state.layout.name),
283
+ load: ({ name }) => load(name),
284
+ clear: () => { for (const p of state.placed) scene.remove(p.object); state.placed = []; state.layout = { name: state.layout.name, prefabs: [], roads: [] }; redrawRoads(); persist(); return { ok: true }; },
285
+ };
286
+ (async function pollBus() {
287
+ try {
288
+ const { cmds } = await fetch('/__designer-bus/poll').then((r) => r.json());
289
+ for (const c of cmds) {
290
+ let value, err = null;
291
+ try { value = await api[c.op]?.(c.args ?? {}); if (api[c.op] === undefined) err = `unknown op '${c.op}'`; }
292
+ catch (e) { err = String(e); }
293
+ await fetch('/__designer-bus/result', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: c.id, ok: !err, value, err }) });
294
+ }
295
+ } catch { /* dev server hiccup */ }
296
+ setTimeout(pollBus, 400);
297
+ })();
298
+
299
+ // restore a layout in progress (reload survivor — see persist())
300
+ try {
301
+ const kept = JSON.parse(sessionStorage.getItem('sindicate-designer') ?? 'null');
302
+ if (kept && (kept.prefabs?.length || kept.roads?.length)) {
303
+ state.layout = { name: kept.name ?? 'untitled', prefabs: [], roads: kept.roads ?? [] };
304
+ ui.querySelector('#d-name').value = state.layout.name;
305
+ for (const e of kept.prefabs ?? []) await placePrefab(e);
306
+ redrawRoads();
307
+ status(`restored working layout (${state.layout.prefabs.length} prefabs, ${state.layout.roads.length} roads)`);
308
+ }
309
+ } catch { /* nothing kept */ }
310
+
311
+ await loadPalette();
312
+ renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera); });
313
+ status(`ready — ${palette.length} prefabs`);
314
+ }
315
+
316
+ const BTN = 'padding:4px 10px;border:1px solid #3a3a52;border-radius:5px;background:#22223a;color:#e8c66a;cursor:pointer;font-size:12px';