otherplane 0.1.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 (63) hide show
  1. package/CLAUDE.md +130 -0
  2. package/LICENSE +21 -0
  3. package/README.md +146 -0
  4. package/bin/otherplane.mjs +489 -0
  5. package/engine/eslint.config.mjs +25 -0
  6. package/engine/next.config.ts +43 -0
  7. package/engine/package-lock.json +6848 -0
  8. package/engine/package.json +36 -0
  9. package/engine/postcss.config.mjs +5 -0
  10. package/engine/src/app/LandingRedirect.tsx +15 -0
  11. package/engine/src/app/[room]/RoomViewer.tsx +413 -0
  12. package/engine/src/app/[room]/page.tsx +30 -0
  13. package/engine/src/app/favicon.ico +0 -0
  14. package/engine/src/app/layout.tsx +45 -0
  15. package/engine/src/app/page.tsx +11 -0
  16. package/engine/src/app/providers.tsx +22 -0
  17. package/engine/src/components/controls/MobileHud.tsx +25 -0
  18. package/engine/src/components/controls/PlayerController.tsx +170 -0
  19. package/engine/src/components/controls/TouchLookController.tsx +93 -0
  20. package/engine/src/components/controls/VirtualStick.tsx +153 -0
  21. package/engine/src/components/edit/EditCapture.tsx +182 -0
  22. package/engine/src/components/edit/EditorPanel.tsx +265 -0
  23. package/engine/src/components/edit/Markers.tsx +91 -0
  24. package/engine/src/components/hud/Button.tsx +228 -0
  25. package/engine/src/components/hud/ClickToPlay.tsx +13 -0
  26. package/engine/src/components/hud/ContentOverlay.tsx +44 -0
  27. package/engine/src/components/hud/NavHeader.module.css +24 -0
  28. package/engine/src/components/scene/Artifacts.tsx +85 -0
  29. package/engine/src/components/scene/Exits.tsx +92 -0
  30. package/engine/src/components/scene/PointerLockBridge.tsx +28 -0
  31. package/engine/src/components/scene/WorldScene.tsx +164 -0
  32. package/engine/src/components/spark/SparkLayer.tsx +112 -0
  33. package/engine/src/components/spark/SplatWorld.tsx +156 -0
  34. package/engine/src/config/audio.ts +11 -0
  35. package/engine/src/data/editApi.ts +73 -0
  36. package/engine/src/data/presets.ts +34 -0
  37. package/engine/src/data/room.ts +100 -0
  38. package/engine/src/data/site.ts +50 -0
  39. package/engine/src/data/universeconfig.ts +19 -0
  40. package/engine/src/icons/ArrowLeft.tsx +20 -0
  41. package/engine/src/icons/ChevronDown.tsx +23 -0
  42. package/engine/src/icons/ChevronLeft.tsx +22 -0
  43. package/engine/src/icons/Home.tsx +22 -0
  44. package/engine/src/icons/Spinner.module.css +13 -0
  45. package/engine/src/icons/Spinner.tsx +28 -0
  46. package/engine/src/icons/VolumeMax.tsx +21 -0
  47. package/engine/src/icons/VolumeX.tsx +22 -0
  48. package/engine/src/icons/icons.interface.ts +7 -0
  49. package/engine/src/icons/index.ts +27 -0
  50. package/engine/src/physics/RapierProvider.tsx +302 -0
  51. package/engine/src/physics/index.ts +2 -0
  52. package/engine/src/physics/types.ts +9 -0
  53. package/engine/src/providers/audio.tsx +215 -0
  54. package/engine/src/providers/edit.tsx +357 -0
  55. package/engine/src/providers/pointerLock.tsx +88 -0
  56. package/engine/src/styles/globals.css +88 -0
  57. package/engine/tailwind.config.js +184 -0
  58. package/engine/tsconfig.json +27 -0
  59. package/otherplane.config.example.json +6 -0
  60. package/package.json +56 -0
  61. package/schema/room.schema.json +77 -0
  62. package/scripts/gen_world.py +147 -0
  63. package/skill.md +94 -0
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env node
2
+ // The Otherplane CLI — one binary, a few verbs, like any other static site
3
+ // generator (Hugo/Astro/Eleventy). Your *project* is a folder: a
4
+ // otherplane.config.json plus rooms/, worlds/, music/. The *renderer* is the
5
+ // engine (content-free). These verbs operate on the project and drive the engine.
6
+ //
7
+ // otherplane init scaffold a new project here
8
+ // otherplane new "<prompt>" --slug x --name "X" generate a room (World Labs Marble)
9
+ // otherplane dev dev server (http://localhost:3000)
10
+ // otherplane edit dev server with edit mode on (+ a writer)
11
+ // otherplane check validate every rooms/*/room.json
12
+ // otherplane build [--base /museum] static export → <engine>/out
13
+ // otherplane serve serve the built export (:8000)
14
+ // otherplane preview build, then serve
15
+ // otherplane clean remove build output + synced mirror
16
+ // otherplane setup install the engine's deps
17
+ //
18
+ // Two roots, kept separate so this can become an installable tool:
19
+ // • SELF — where the CLI + scripts + (bundled) engine live.
20
+ // • PROJECT — where the content lives. Defaults to the current directory, or
21
+ // $OTHERPLANE_PROJECT. This is the source of truth.
22
+ //
23
+ // Why a sync step: Next can only statically serve files under the engine's
24
+ // public/, so before dev/build we mirror PROJECT's rooms/worlds/music into
25
+ // <engine>/public/ (gitignored). Keeps the engine content-free while letting the
26
+ // project's content live where it belongs.
27
+
28
+ import { spawn, spawnSync } from 'node:child_process';
29
+ import {
30
+ readdirSync, statSync, mkdirSync, copyFileSync, cpSync, rmSync, existsSync, readFileSync, writeFileSync,
31
+ } from 'node:fs';
32
+ import { createServer } from 'node:http';
33
+ import { join, dirname } from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
35
+ import { createRequire } from 'node:module';
36
+
37
+ // SELF: the otherplane install (this repo, or the installed package). Tooling
38
+ // (scripts/gen_world.py) lives here.
39
+ const SELF = join(dirname(fileURLToPath(import.meta.url)), '..');
40
+
41
+ // PROJECT: the content root. $OTHERPLANE_PROJECT wins; otherwise the current
42
+ // working directory (so `npx otherplane` works in any folder). When run via this
43
+ // repo's npm scripts, cwd IS the repo root, so behaviour is unchanged.
44
+ const PROJECT = process.env.OTHERPLANE_PROJECT || process.cwd();
45
+
46
+ // ENGINE: the renderer. Prefer a sibling engine/ (this repo's layout); else the
47
+ // installed @otherplane/engine package. This is the seam that lets the CLI ship
48
+ // as a tool with the engine as a dependency.
49
+ function resolveEngine() {
50
+ const sibling = join(SELF, 'engine');
51
+ if (existsSync(join(sibling, 'package.json'))) return sibling;
52
+ try {
53
+ const require = createRequire(import.meta.url);
54
+ return dirname(require.resolve('@otherplane/engine/package.json'));
55
+ } catch {
56
+ return sibling; // best-effort; commands that need it will report the miss
57
+ }
58
+ }
59
+ const ENGINE = resolveEngine();
60
+ const PUBLIC = join(ENGINE, 'public');
61
+ const CONTENT_DIRS = ['rooms', 'worlds', 'music'];
62
+ const CONFIG_PATH = join(PROJECT, 'otherplane.config.json');
63
+ // Next writes its export into the engine dir; the deployable belongs in the
64
+ // PROJECT (the engine may be an installed package elsewhere). We copy it there.
65
+ const ENGINE_OUT = join(ENGINE, 'out');
66
+ const OUT = join(PROJECT, 'out');
67
+
68
+ // Env passed to every engine (Next) invocation so it reads THIS project's config
69
+ // and content — not whatever happens to sit beside the engine dir.
70
+ const engineEnv = (extra = {}) => ({ ...process.env, OTHERPLANE_PROJECT: PROJECT, ...extra });
71
+
72
+ // ── helpers ────────────────────────────────────────────────────────────────
73
+ function run(cmd, args, opts = {}) {
74
+ const r = spawnSync(cmd, args, { stdio: 'inherit', cwd: PROJECT, ...opts });
75
+ if (r.status !== 0) process.exit(r.status ?? 1);
76
+ return r;
77
+ }
78
+
79
+ // rsync-lite: copy changed files, drop orphans. Avoids re-copying ~20MB of
80
+ // splats on every dev start.
81
+ function syncDir(src, dest) {
82
+ if (!existsSync(src)) return;
83
+ mkdirSync(dest, { recursive: true });
84
+ const srcEntries = readdirSync(src, { withFileTypes: true });
85
+ const keep = new Set(srcEntries.map((e) => e.name));
86
+ for (const e of readdirSync(dest, { withFileTypes: true })) {
87
+ if (!keep.has(e.name)) rmSync(join(dest, e.name), { recursive: true, force: true });
88
+ }
89
+ for (const e of srcEntries) {
90
+ const s = join(src, e.name);
91
+ const d = join(dest, e.name);
92
+ if (e.isDirectory()) { syncDir(s, d); continue; }
93
+ const ss = statSync(s);
94
+ if (existsSync(d)) {
95
+ const ds = statSync(d);
96
+ if (ds.size === ss.size && ds.mtimeMs >= ss.mtimeMs) continue;
97
+ }
98
+ copyFileSync(s, d);
99
+ }
100
+ }
101
+
102
+ function sync() {
103
+ for (const d of CONTENT_DIRS) syncDir(join(PROJECT, d), join(PUBLIC, d));
104
+ }
105
+
106
+ // Install the engine's deps on first use (so `npx otherplane` / a global install
107
+ // works with no separate setup step). No-op once node_modules exists.
108
+ function ensureEngineDeps() {
109
+ if (existsSync(join(ENGINE, 'node_modules'))) return;
110
+ console.log('installing the engine’s dependencies (one-time, ~a minute)…');
111
+ run('npm', ['--prefix', ENGINE, 'install']);
112
+ }
113
+
114
+ // How many rooms the project actually has (a folder with a room.json).
115
+ function roomCount() {
116
+ const dir = join(PROJECT, 'rooms');
117
+ if (!existsSync(dir)) return 0;
118
+ return readdirSync(dir, { withFileTypes: true })
119
+ .filter((d) => d.isDirectory() && existsSync(join(dir, d.name, 'room.json'))).length;
120
+ }
121
+
122
+ // Mirror a single room.json into the engine mirror so the dev server serves the
123
+ // fresh copy right after an edit-mode write. (Cheap; used by the edit writer.)
124
+ function syncRoom(slug) {
125
+ const src = join(PROJECT, 'rooms', slug, 'room.json');
126
+ if (!existsSync(src)) return;
127
+ const destDir = join(PUBLIC, 'rooms', slug);
128
+ mkdirSync(destDir, { recursive: true });
129
+ copyFileSync(src, join(destDir, 'room.json'));
130
+ }
131
+
132
+ // ── validation (mirrors schema/room.schema.json) ────────────────────────────
133
+ const isVec3 = (v) =>
134
+ Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number' && Number.isFinite(n));
135
+
136
+ function validateRoom(room) {
137
+ const errs = [];
138
+ const need = (cond, msg) => { if (!cond) errs.push(msg); };
139
+ need(typeof room.display_name === 'string' && room.display_name, 'display_name (non-empty string) required');
140
+ need(typeof room.splat_url === 'string' && room.splat_url, 'splat_url (non-empty string) required');
141
+ need(typeof room.collider_url === 'string' && room.collider_url, 'collider_url (non-empty string) required');
142
+ need(room.calibration && typeof room.calibration.scale === 'number' && room.calibration.scale > 0,
143
+ 'calibration.scale (number > 0) required');
144
+ for (const k of ['music_url', 'pano_url', 'thumbnail_url']) {
145
+ if (room[k] != null && typeof room[k] !== 'string') errs.push(`${k} must be a string or null`);
146
+ }
147
+ const list = (name) => {
148
+ const a = room[name];
149
+ if (a == null) return [];
150
+ if (!Array.isArray(a)) { errs.push(`${name} must be an array`); return []; }
151
+ return a;
152
+ };
153
+ const ids = new Set();
154
+ list('entryways').forEach((e, i) => {
155
+ need(typeof e.id === 'string' && e.id, `entryways[${i}].id (non-empty string) required`);
156
+ need(isVec3(e.pos), `entryways[${i}].pos must be [x,y,z] numbers`);
157
+ need(typeof e.yaw === 'number', `entryways[${i}].yaw (number) required`);
158
+ if (e.id) { if (ids.has(e.id)) errs.push(`duplicate entryway id "${e.id}"`); ids.add(e.id); }
159
+ });
160
+ list('exits').forEach((e, i) => {
161
+ need(isVec3(e.pos), `exits[${i}].pos must be [x,y,z] numbers`);
162
+ need(typeof e.to === 'string' && e.to, `exits[${i}].to (non-empty string) required`);
163
+ if (e.radius != null) need(typeof e.radius === 'number' && e.radius > 0, `exits[${i}].radius must be > 0`);
164
+ });
165
+ list('artifacts').forEach((a, i) => {
166
+ need(isVec3(a.pos), `artifacts[${i}].pos must be [x,y,z] numbers`);
167
+ need(typeof a.radius === 'number' && a.radius > 0, `artifacts[${i}].radius (number > 0) required`);
168
+ need(typeof a.url === 'string' && a.url, `artifacts[${i}].url (non-empty string) required`);
169
+ });
170
+ const warns = [];
171
+ const entryways = Array.isArray(room.entryways) ? room.entryways : [];
172
+ if (entryways.length && !entryways.some((e) => e.id === 'default')) {
173
+ warns.push('no "default" entryway — the no-fragment spawn falls back to the first one');
174
+ }
175
+ if (!entryways.length) warns.push('no entryways yet — mark one with `otherplane edit` (press C)');
176
+ return { errs, warns };
177
+ }
178
+
179
+ function check() {
180
+ const dir = join(PROJECT, 'rooms');
181
+ if (!existsSync(dir)) { console.log('no rooms/ yet — nothing to check'); return; }
182
+ const slugs = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
183
+ let bad = 0;
184
+ for (const slug of slugs) {
185
+ const path = join(dir, slug, 'room.json');
186
+ if (!existsSync(path)) { console.log(`✗ ${slug}: no room.json`); bad++; continue; }
187
+ let room;
188
+ try { room = JSON.parse(readFileSync(path, 'utf8')); }
189
+ catch (e) { console.log(`✗ ${slug}: invalid JSON — ${e.message}`); bad++; continue; }
190
+ const { errs, warns } = validateRoom(room);
191
+ if (errs.length) { bad++; console.log(`✗ ${slug}`); errs.forEach((m) => console.log(` ${m}`)); }
192
+ else { console.log(`✓ ${slug}`); }
193
+ warns.forEach((m) => console.log(` ⚠ ${m}`));
194
+ }
195
+ if (bad) { console.error(`\n${bad} room(s) failed validation`); process.exit(1); }
196
+ console.log(`\nall ${slugs.length} room(s) valid`);
197
+ }
198
+
199
+ // ── edit-mode writer (a tiny local HTTP sidecar) ─────────────────────────────
200
+ // The static export has no server, but coords are only ever marked in `edit`
201
+ // (a real dev server). Persistence belongs in the CLI (it owns the project
202
+ // filesystem), NOT in a Next route handler (a dynamic handler breaks
203
+ // output:'export'). This writes to the PROJECT source — never the mirror, which
204
+ // a re-sync would clobber — then mirrors that one file so the dev server serves
205
+ // it immediately.
206
+ const EDIT_PORT = Number(process.env.OTHERPLANE_EDIT_PORT) || 4400;
207
+
208
+ function readRoom(slug) {
209
+ return JSON.parse(readFileSync(join(PROJECT, 'rooms', slug, 'room.json'), 'utf8'));
210
+ }
211
+
212
+ // Pretty-print a room the way they're hand-authored: 2-space indent, but with
213
+ // [x, y, z] vectors AND each leaf object (an entryway/exit/artifact/calibration)
214
+ // kept on one line, so edit-mode saves read like the compact JSON humans write
215
+ // and produce clean one-line-per-marker diffs.
216
+ function stringifyRoom(room) {
217
+ let json = JSON.stringify(room, null, 2);
218
+ // Inline [x, y, z] vectors.
219
+ json = json.replace(
220
+ /\[\s*\n\s*(-?[\d.eE+]+),\s*\n\s*(-?[\d.eE+]+),\s*\n\s*(-?[\d.eE+]+)\s*\n\s*\]/g,
221
+ '[$1, $2, $3]',
222
+ );
223
+ // Collapse leaf objects (those with no nested braces — vectors are now inline).
224
+ json = json.replace(/\{[^{}]*\}/g, (m) => {
225
+ const inner = m.slice(1, -1).replace(/\s+/g, ' ').trim();
226
+ return inner ? `{ ${inner} }` : '{}';
227
+ });
228
+ return json + '\n';
229
+ }
230
+
231
+ function writeRoom(slug, room) {
232
+ const { errs } = validateRoom(room);
233
+ if (errs.length) throw new Error(errs.join('; '));
234
+ writeFileSync(join(PROJECT, 'rooms', slug, 'room.json'), stringifyRoom(room));
235
+ syncRoom(slug);
236
+ }
237
+
238
+ // Add an exit if an equivalent one (same target) isn't already present.
239
+ function addExitOnce(room, exit) {
240
+ room.exits = Array.isArray(room.exits) ? room.exits : [];
241
+ if (room.exits.some((e) => e.to === exit.to)) return false;
242
+ room.exits.push(exit);
243
+ return true;
244
+ }
245
+
246
+ function listRooms() {
247
+ const dir = join(PROJECT, 'rooms');
248
+ if (!existsSync(dir)) return [];
249
+ return readdirSync(dir, { withFileTypes: true })
250
+ .filter((d) => d.isDirectory())
251
+ .map((d) => {
252
+ try {
253
+ const r = readRoom(d.name);
254
+ return {
255
+ slug: d.name,
256
+ display_name: r.display_name ?? d.name,
257
+ entryways: (r.entryways ?? []).map((e) => ({ id: e.id, pos: e.pos, yaw: e.yaw })),
258
+ };
259
+ } catch { return { slug: d.name, display_name: d.name, entryways: [] }; }
260
+ });
261
+ }
262
+
263
+ function startEditServer() {
264
+ const send = (res, code, body) => {
265
+ res.writeHead(code, {
266
+ 'Content-Type': 'application/json',
267
+ 'Access-Control-Allow-Origin': '*',
268
+ 'Access-Control-Allow-Methods': 'GET,PUT,POST,OPTIONS',
269
+ 'Access-Control-Allow-Headers': 'Content-Type',
270
+ });
271
+ res.end(JSON.stringify(body));
272
+ };
273
+ const readBody = (req) => new Promise((resolve, reject) => {
274
+ let data = '';
275
+ req.on('data', (c) => { data += c; if (data.length > 5_000_000) req.destroy(); });
276
+ req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(e); } });
277
+ req.on('error', reject);
278
+ });
279
+
280
+ const server = createServer(async (req, res) => {
281
+ try {
282
+ if (req.method === 'OPTIONS') return send(res, 204, {});
283
+ const url = new URL(req.url, `http://localhost:${EDIT_PORT}`);
284
+ const parts = url.pathname.split('/').filter(Boolean);
285
+
286
+ // GET /rooms — enumerate rooms + their entryways (for the exit dropdown).
287
+ if (req.method === 'GET' && parts[0] === 'rooms' && parts.length === 1) {
288
+ return send(res, 200, { rooms: listRooms() });
289
+ }
290
+
291
+ // PUT /rooms/:slug — merge the marked arrays into the room, preserving all
292
+ // asset URLs (the client only ever sends coordinate data).
293
+ if (req.method === 'PUT' && parts[0] === 'rooms' && parts.length === 2) {
294
+ const slug = parts[1];
295
+ const body = await readBody(req);
296
+ let room;
297
+ try { room = readRoom(slug); } catch { return send(res, 404, { error: `no room "${slug}"` }); }
298
+ if (Array.isArray(body.entryways)) room.entryways = body.entryways;
299
+ if (Array.isArray(body.exits)) room.exits = body.exits;
300
+ if (Array.isArray(body.artifacts)) room.artifacts = body.artifacts;
301
+ if (body.calibration && typeof body.calibration.scale === 'number' && body.calibration.scale > 0) {
302
+ room.calibration = { ...room.calibration, scale: body.calibration.scale };
303
+ }
304
+ try { writeRoom(slug, room); } catch (e) { return send(res, 400, { error: e.message }); }
305
+ return send(res, 200, { ok: true, room });
306
+ }
307
+
308
+ // PUT /config — merge fields into otherplane.config.json (e.g. moveSpeed).
309
+ if (req.method === 'PUT' && parts[0] === 'config' && parts.length === 1) {
310
+ const patch = await readBody(req);
311
+ let cfg = {};
312
+ try { cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); } catch { /* new config */ }
313
+ const next = { ...cfg, ...patch };
314
+ writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2) + '\n');
315
+ return send(res, 200, { ok: true, config: next });
316
+ }
317
+
318
+ // POST /doors/link — wire a two-way door by reusing each side's existing
319
+ // entryway position. { a:{slug,entryId}, b:{slug,entryId} }.
320
+ if (req.method === 'POST' && parts[0] === 'doors' && parts[1] === 'link') {
321
+ const { a, b } = await readBody(req);
322
+ if (!a?.slug || !a?.entryId || !b?.slug || !b?.entryId) {
323
+ return send(res, 400, { error: 'need a:{slug,entryId} and b:{slug,entryId}' });
324
+ }
325
+ let roomA, roomB;
326
+ try { roomA = readRoom(a.slug); roomB = readRoom(b.slug); }
327
+ catch { return send(res, 404, { error: 'unknown room' }); }
328
+ const entryA = (roomA.entryways ?? []).find((e) => e.id === a.entryId);
329
+ const entryB = (roomB.entryways ?? []).find((e) => e.id === b.entryId);
330
+ if (!entryA || !entryB) return send(res, 400, { error: 'both entryways must already exist' });
331
+ addExitOnce(roomA, { pos: entryA.pos, radius: 1.3, to: `../${b.slug}/#${b.entryId}` });
332
+ addExitOnce(roomB, { pos: entryB.pos, radius: 1.3, to: `../${a.slug}/#${a.entryId}` });
333
+ try { writeRoom(a.slug, roomA); writeRoom(b.slug, roomB); }
334
+ catch (e) { return send(res, 400, { error: e.message }); }
335
+ return send(res, 200, { ok: true });
336
+ }
337
+
338
+ return send(res, 404, { error: 'not found' });
339
+ } catch (e) {
340
+ return send(res, 500, { error: e instanceof Error ? e.message : String(e) });
341
+ }
342
+ });
343
+
344
+ server.on('error', (e) => {
345
+ console.error(`edit writer failed to start on :${EDIT_PORT} — ${e.message}`);
346
+ console.error('(set OTHERPLANE_EDIT_PORT to use another port)');
347
+ });
348
+ server.listen(EDIT_PORT, () => console.log(`✎ edit writer on http://localhost:${EDIT_PORT}`));
349
+ return server;
350
+ }
351
+
352
+ // Run the engine dev server with an edit writer alongside it. Uses async spawn
353
+ // (not spawnSync) so the writer's event loop keeps serving while Next runs.
354
+ function runEdit() {
355
+ const server = startEditServer();
356
+ const child = spawn('npm', ['--prefix', ENGINE, 'run', 'edit'], {
357
+ stdio: 'inherit',
358
+ cwd: PROJECT,
359
+ env: engineEnv({ NEXT_PUBLIC_EDIT_API: `http://localhost:${EDIT_PORT}` }),
360
+ });
361
+ const shutdown = () => { try { server.close(); } catch {} try { child.kill(); } catch {} };
362
+ process.on('SIGINT', () => { shutdown(); process.exit(0); });
363
+ process.on('SIGTERM', () => { shutdown(); process.exit(0); });
364
+ child.on('close', (code) => { server.close(); process.exit(code ?? 0); });
365
+ }
366
+
367
+ // ── init: scaffold a fresh project in PROJECT ────────────────────────────────
368
+ function init() {
369
+ const wrote = [];
370
+ const put = (rel, contents) => {
371
+ const p = join(PROJECT, rel);
372
+ if (existsSync(p)) return;
373
+ mkdirSync(dirname(p), { recursive: true });
374
+ writeFileSync(p, contents);
375
+ wrote.push(rel);
376
+ };
377
+ put('otherplane.config.json', JSON.stringify({ landingRoom: '', basePath: '', siteTitle: 'My Museum' }, null, 2) + '\n');
378
+ put('.gitignore', ['# Otherplane build artifacts', 'node_modules/', 'out/', '.next/', ''].join('\n'));
379
+ put('.env.example', 'WORLD_LABS_KEY=\n');
380
+ mkdirSync(join(PROJECT, 'rooms'), { recursive: true });
381
+ mkdirSync(join(PROJECT, 'worlds'), { recursive: true });
382
+ mkdirSync(join(PROJECT, 'music'), { recursive: true });
383
+ if (wrote.length) {
384
+ console.log(`✓ initialised a Otherplane project in ${PROJECT}`);
385
+ wrote.forEach((f) => console.log(` + ${f}`));
386
+ console.log('\nnext: cp .env.example .env (paste WORLD_LABS_KEY), then `otherplane new "<prompt>" --slug <slug> --name "<Name>"`');
387
+ } else {
388
+ console.log('project already initialised — nothing to do');
389
+ }
390
+ }
391
+
392
+ // ── commands ─────────────────────────────────────────────────────────────────
393
+ const HELP = `otherplane — a static site generator for walkable Gaussian-splat museums
394
+
395
+ usage: otherplane <command> [args]
396
+
397
+ init scaffold a new project in this folder
398
+ new "<prompt>" --slug <slug> --name "<Name>" generate a room (World Labs Marble)
399
+ dev dev server (http://localhost:3000)
400
+ edit dev server with edit mode on (mark coords)
401
+ check validate every rooms/*/room.json
402
+ build [--base /museum] static export → ./out (deploy this)
403
+ serve serve the built export (http://localhost:8000)
404
+ preview build, then serve
405
+ clean remove build output + synced mirror
406
+ setup install the engine's dependencies
407
+ `;
408
+
409
+ const [, , cmd, ...rest] = process.argv;
410
+
411
+ // Pull "--base <path>" out of build args and turn it into a config override the
412
+ // engine reads via env (see next.config.ts / site.ts).
413
+ function takeBase(args) {
414
+ const i = args.indexOf('--base');
415
+ if (i === -1) return undefined;
416
+ return args[i + 1];
417
+ }
418
+
419
+ // Validate, mirror content, static-export, and land the deployable in PROJECT/out
420
+ // (the engine's own out/ may sit inside an installed package).
421
+ function buildEngine(base) {
422
+ if (roomCount() === 0) {
423
+ console.error('no rooms yet — add one with `otherplane new` or create rooms/<slug>/room.json, then build.');
424
+ process.exit(1);
425
+ }
426
+ ensureEngineDeps();
427
+ check();
428
+ sync();
429
+ run('npm', ['--prefix', ENGINE, 'run', 'build'], {
430
+ env: engineEnv(base != null ? { OTHERPLANE_BASE_PATH: base } : {}),
431
+ });
432
+ if (ENGINE_OUT !== OUT) {
433
+ rmSync(OUT, { recursive: true, force: true });
434
+ cpSync(ENGINE_OUT, OUT, { recursive: true });
435
+ console.log(`✓ exported to ${OUT}`);
436
+ }
437
+ }
438
+
439
+ switch (cmd) {
440
+ case 'init':
441
+ init();
442
+ break;
443
+ case 'new':
444
+ run('uv', ['run', '--with', 'requests', '--with', 'trimesh', join(SELF, 'scripts', 'gen_world.py'), ...rest],
445
+ { env: engineEnv() });
446
+ sync();
447
+ break;
448
+ case 'dev':
449
+ ensureEngineDeps();
450
+ sync();
451
+ run('npm', ['--prefix', ENGINE, 'run', 'dev'], { env: engineEnv() });
452
+ break;
453
+ case 'edit':
454
+ ensureEngineDeps();
455
+ sync();
456
+ runEdit();
457
+ break;
458
+ case 'check':
459
+ check();
460
+ break;
461
+ case 'build':
462
+ buildEngine(takeBase(rest));
463
+ break;
464
+ case 'serve':
465
+ run('python3', ['-m', 'http.server', '8000'], { cwd: OUT });
466
+ break;
467
+ case 'preview':
468
+ buildEngine(takeBase(rest));
469
+ run('python3', ['-m', 'http.server', '8000'], { cwd: OUT });
470
+ break;
471
+ case 'clean':
472
+ rmSync(join(ENGINE, '.next'), { recursive: true, force: true });
473
+ rmSync(ENGINE_OUT, { recursive: true, force: true });
474
+ rmSync(OUT, { recursive: true, force: true });
475
+ for (const d of CONTENT_DIRS) rmSync(join(PUBLIC, d), { recursive: true, force: true });
476
+ console.log('cleaned build output (.next, out) and the synced mirror');
477
+ break;
478
+ case 'setup':
479
+ run('npm', ['--prefix', ENGINE, 'install']);
480
+ break;
481
+ case 'help':
482
+ case undefined:
483
+ process.stdout.write(HELP);
484
+ break;
485
+ default:
486
+ console.error(`unknown command: ${cmd}\n`);
487
+ process.stdout.write(HELP);
488
+ process.exit(1);
489
+ }
@@ -0,0 +1,25 @@
1
+ import { dirname } from "path";
2
+ import { fileURLToPath } from "url";
3
+ import { FlatCompat } from "@eslint/eslintrc";
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = dirname(__filename);
7
+
8
+ const compat = new FlatCompat({
9
+ baseDirectory: __dirname,
10
+ });
11
+
12
+ const eslintConfig = [
13
+ ...compat.extends("next/core-web-vitals", "next/typescript"),
14
+ {
15
+ ignores: [
16
+ "node_modules/**",
17
+ ".next/**",
18
+ "out/**",
19
+ "build/**",
20
+ "next-env.d.ts",
21
+ ],
22
+ },
23
+ ];
24
+
25
+ export default eslintConfig;
@@ -0,0 +1,43 @@
1
+ import type { NextConfig } from 'next';
2
+ import { readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ // Deploy config lives at the repo root (otherplane.config.json), not in renderer
6
+ // source. Only `basePath` is needed at the Next layer (for project-page hosting
7
+ // under a sub-path); landingRoom/siteTitle are read in src/data/site.ts.
8
+ function basePath(): string {
9
+ // `otherplane build --base /museum` overrides the config for a one-off deploy.
10
+ if (typeof process.env.OTHERPLANE_BASE_PATH === 'string') return process.env.OTHERPLANE_BASE_PATH;
11
+ try {
12
+ const dir = process.env.OTHERPLANE_PROJECT || join(process.cwd(), '..');
13
+ const cfg = JSON.parse(readFileSync(join(dir, 'otherplane.config.json'), 'utf8'));
14
+ return typeof cfg.basePath === 'string' ? cfg.basePath : '';
15
+ } catch {
16
+ return '';
17
+ }
18
+ }
19
+
20
+ const nextConfig: NextConfig = {
21
+ // Static export: the viewer is a pure client-side player with no server needs,
22
+ // so it builds to plain files hostable on GitHub Pages / R2 / any static host.
23
+ output: 'export',
24
+ basePath: basePath() || undefined,
25
+ trailingSlash: true, // so /welcome-room/ serves /welcome-room/index.html
26
+ images: { unoptimized: true },
27
+ webpack: (config) => {
28
+ // If Next uses webpack (e.g., for some plugins), Spark’s WASM URL resolution is safer this way.
29
+ // See: spark-react-nextjs / spark-react-r3f notes
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
+ const parser = (config.module as any)?.parser ?? {};
32
+ config.module.parser = {
33
+ ...parser,
34
+ javascript: {
35
+ ...(parser.javascript ?? {}),
36
+ url: false
37
+ },
38
+ };
39
+ return config;
40
+ },
41
+ };
42
+
43
+ export default nextConfig;