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.
- package/CLAUDE.md +130 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/bin/otherplane.mjs +489 -0
- package/engine/eslint.config.mjs +25 -0
- package/engine/next.config.ts +43 -0
- package/engine/package-lock.json +6848 -0
- package/engine/package.json +36 -0
- package/engine/postcss.config.mjs +5 -0
- package/engine/src/app/LandingRedirect.tsx +15 -0
- package/engine/src/app/[room]/RoomViewer.tsx +413 -0
- package/engine/src/app/[room]/page.tsx +30 -0
- package/engine/src/app/favicon.ico +0 -0
- package/engine/src/app/layout.tsx +45 -0
- package/engine/src/app/page.tsx +11 -0
- package/engine/src/app/providers.tsx +22 -0
- package/engine/src/components/controls/MobileHud.tsx +25 -0
- package/engine/src/components/controls/PlayerController.tsx +170 -0
- package/engine/src/components/controls/TouchLookController.tsx +93 -0
- package/engine/src/components/controls/VirtualStick.tsx +153 -0
- package/engine/src/components/edit/EditCapture.tsx +182 -0
- package/engine/src/components/edit/EditorPanel.tsx +265 -0
- package/engine/src/components/edit/Markers.tsx +91 -0
- package/engine/src/components/hud/Button.tsx +228 -0
- package/engine/src/components/hud/ClickToPlay.tsx +13 -0
- package/engine/src/components/hud/ContentOverlay.tsx +44 -0
- package/engine/src/components/hud/NavHeader.module.css +24 -0
- package/engine/src/components/scene/Artifacts.tsx +85 -0
- package/engine/src/components/scene/Exits.tsx +92 -0
- package/engine/src/components/scene/PointerLockBridge.tsx +28 -0
- package/engine/src/components/scene/WorldScene.tsx +164 -0
- package/engine/src/components/spark/SparkLayer.tsx +112 -0
- package/engine/src/components/spark/SplatWorld.tsx +156 -0
- package/engine/src/config/audio.ts +11 -0
- package/engine/src/data/editApi.ts +73 -0
- package/engine/src/data/presets.ts +34 -0
- package/engine/src/data/room.ts +100 -0
- package/engine/src/data/site.ts +50 -0
- package/engine/src/data/universeconfig.ts +19 -0
- package/engine/src/icons/ArrowLeft.tsx +20 -0
- package/engine/src/icons/ChevronDown.tsx +23 -0
- package/engine/src/icons/ChevronLeft.tsx +22 -0
- package/engine/src/icons/Home.tsx +22 -0
- package/engine/src/icons/Spinner.module.css +13 -0
- package/engine/src/icons/Spinner.tsx +28 -0
- package/engine/src/icons/VolumeMax.tsx +21 -0
- package/engine/src/icons/VolumeX.tsx +22 -0
- package/engine/src/icons/icons.interface.ts +7 -0
- package/engine/src/icons/index.ts +27 -0
- package/engine/src/physics/RapierProvider.tsx +302 -0
- package/engine/src/physics/index.ts +2 -0
- package/engine/src/physics/types.ts +9 -0
- package/engine/src/providers/audio.tsx +215 -0
- package/engine/src/providers/edit.tsx +357 -0
- package/engine/src/providers/pointerLock.tsx +88 -0
- package/engine/src/styles/globals.css +88 -0
- package/engine/tailwind.config.js +184 -0
- package/engine/tsconfig.json +27 -0
- package/otherplane.config.example.json +6 -0
- package/package.json +56 -0
- package/schema/room.schema.json +77 -0
- package/scripts/gen_world.py +147 -0
- 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;
|