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,184 @@
|
|
|
1
|
+
/** @type {import('tailwindcss').Config} */
|
|
2
|
+
|
|
3
|
+
import colors from "tailwindcss/colors";
|
|
4
|
+
import defaultTheme from "tailwindcss/defaultTheme";
|
|
5
|
+
import plugin from "tailwindcss/plugin";
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
content: [
|
|
9
|
+
"./primitives/**/*.{js,ts,jsx,tsx,mdx}",
|
|
10
|
+
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
|
11
|
+
"./layouts/**/*.{js,ts,jsx,tsx,mdx}",
|
|
12
|
+
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
|
13
|
+
"./icons/**/*.{js,ts,jsx,tsx,mdx}",
|
|
14
|
+
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
|
15
|
+
],
|
|
16
|
+
theme: {
|
|
17
|
+
extend: {
|
|
18
|
+
fontFamily: {
|
|
19
|
+
"sans-medium": ["Avenir-Medium", ...defaultTheme.fontFamily.sans],
|
|
20
|
+
"sans-heavy": ["Avenir-Heavy", ...defaultTheme.fontFamily.sans],
|
|
21
|
+
"sans-black": ["Avenir-Black", ...defaultTheme.fontFamily.sans],
|
|
22
|
+
},
|
|
23
|
+
fontSize: {
|
|
24
|
+
heading: ["24px", "36px"],
|
|
25
|
+
},
|
|
26
|
+
colors: {
|
|
27
|
+
gray: colors.neutral,
|
|
28
|
+
},
|
|
29
|
+
transitionProperty: {
|
|
30
|
+
"max-height": "max-height",
|
|
31
|
+
"padding": "padding"
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
plugins: [
|
|
36
|
+
plugin(function ({ addUtilities }) {
|
|
37
|
+
const actions = {
|
|
38
|
+
".action-normal": {
|
|
39
|
+
"background-color": "var(--action-normal)",
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const backgrounds = {
|
|
44
|
+
".bg-root": {
|
|
45
|
+
"background-color": "var(--background-root)",
|
|
46
|
+
},
|
|
47
|
+
".bg-root-inverted": {
|
|
48
|
+
"background-color": "var(--background-root-inverted)",
|
|
49
|
+
},
|
|
50
|
+
".bg-elevated": {
|
|
51
|
+
"background-color": "var(--background-elevated)",
|
|
52
|
+
},
|
|
53
|
+
".bg-action-primary": {
|
|
54
|
+
"background-color": "var(--background-action-primary)",
|
|
55
|
+
},
|
|
56
|
+
".bg-action-primary-hover": {
|
|
57
|
+
"background-color": "var(--background-action-primary-hover)",
|
|
58
|
+
},
|
|
59
|
+
".bg-action-primary-active": {
|
|
60
|
+
"background-color": "var(--background-action-primary-active)",
|
|
61
|
+
},
|
|
62
|
+
".bg-action-transparent": {
|
|
63
|
+
"background-color": "var(--background-action-transparent)",
|
|
64
|
+
},
|
|
65
|
+
".bg-action-primary-disabled": {
|
|
66
|
+
"background-color": "var(--background-action-primary-disabled)",
|
|
67
|
+
},
|
|
68
|
+
".bg-action-hover": {
|
|
69
|
+
"background-color": "var(--background-action-hover)",
|
|
70
|
+
},
|
|
71
|
+
".bg-action-active": {
|
|
72
|
+
"background-color": "var(--background-action-active)",
|
|
73
|
+
},
|
|
74
|
+
".bg-transparent": {
|
|
75
|
+
"background-color": "transparent",
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const text = {
|
|
80
|
+
".text-primary": {
|
|
81
|
+
color: "var(--text-primary)",
|
|
82
|
+
},
|
|
83
|
+
".text-secondary": {
|
|
84
|
+
color: "var(--text-secondary)",
|
|
85
|
+
},
|
|
86
|
+
".text-tertiary": {
|
|
87
|
+
color: "var(--text-tertiary)",
|
|
88
|
+
},
|
|
89
|
+
".text-inverted-primary": {
|
|
90
|
+
color: "var(--text-inverted-primary)",
|
|
91
|
+
},
|
|
92
|
+
".text-inverted-secondary": {
|
|
93
|
+
color: "var(--text-inverted-secondary)",
|
|
94
|
+
},
|
|
95
|
+
".text-sm": {
|
|
96
|
+
"font-size": "var(--size-sm)",
|
|
97
|
+
},
|
|
98
|
+
".text-md": {
|
|
99
|
+
"font-size": "var(--size-md)",
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const strokesAndFills = {
|
|
104
|
+
".stroke-primary": {
|
|
105
|
+
stroke: "var(--stroke-primary)",
|
|
106
|
+
},
|
|
107
|
+
".stroke-secondary": {
|
|
108
|
+
stroke: "var(--stroke-secondary)",
|
|
109
|
+
},
|
|
110
|
+
".stroke-tertiary": {
|
|
111
|
+
stroke: "var(--stroke-tertiary)",
|
|
112
|
+
},
|
|
113
|
+
".stroke-suggestion": {
|
|
114
|
+
stroke: "var(--stroke-suggestion)",
|
|
115
|
+
},
|
|
116
|
+
".stroke-inverted-primary": {
|
|
117
|
+
stroke: "var(--stroke-inverted-primary)",
|
|
118
|
+
},
|
|
119
|
+
".stroke-error-primary": {
|
|
120
|
+
stroke: "var(--stroke-error-primary)",
|
|
121
|
+
},
|
|
122
|
+
".stroke-success-primary": {
|
|
123
|
+
stroke: "var(--stroke-success-primary)",
|
|
124
|
+
},
|
|
125
|
+
".fill-root": {
|
|
126
|
+
fill: "var(--fill-root)",
|
|
127
|
+
},
|
|
128
|
+
".fill-primary": {
|
|
129
|
+
fill: "var(--fill-primary)",
|
|
130
|
+
},
|
|
131
|
+
".fill-secondary": {
|
|
132
|
+
fill: "var(--fill-secondary)",
|
|
133
|
+
},
|
|
134
|
+
".fill-tertiary": {
|
|
135
|
+
fill: "var(--fill-tertiary)",
|
|
136
|
+
},
|
|
137
|
+
".fill-switch-off": {
|
|
138
|
+
fill: "var(--fill-switch-off)",
|
|
139
|
+
},
|
|
140
|
+
".fill-switch-on": {
|
|
141
|
+
fill: "var(--fill-switch-on)",
|
|
142
|
+
},
|
|
143
|
+
".fill-inverted-primary": {
|
|
144
|
+
fill: "var(--fill-inverted-primary)",
|
|
145
|
+
},
|
|
146
|
+
".fill-inverted-secondary": {
|
|
147
|
+
fill: "var(--fill-inverted-secondary)",
|
|
148
|
+
},
|
|
149
|
+
".fill-error-primary": {
|
|
150
|
+
fill: "var(--fill-error-primary)",
|
|
151
|
+
},
|
|
152
|
+
".fill-success-primary": {
|
|
153
|
+
fill: "var(--fill-success-primary)",
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const bordersAndOutlines = {
|
|
158
|
+
".border-normal": {
|
|
159
|
+
"border-color": "var(--border-normal)",
|
|
160
|
+
},
|
|
161
|
+
".border-highlighted": {
|
|
162
|
+
"border-color": "var(--border-highlighted)",
|
|
163
|
+
},
|
|
164
|
+
".border-action": {
|
|
165
|
+
"border-color": "var(--text-secondary)",
|
|
166
|
+
},
|
|
167
|
+
".border-error": {
|
|
168
|
+
"border-color": "var(--border-error)",
|
|
169
|
+
},
|
|
170
|
+
".border-elevated": {
|
|
171
|
+
"border-color": "var(--border-elevated)",
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
addUtilities({
|
|
176
|
+
...actions,
|
|
177
|
+
...backgrounds,
|
|
178
|
+
...text,
|
|
179
|
+
...strokesAndFills,
|
|
180
|
+
...bordersAndOutlines,
|
|
181
|
+
});
|
|
182
|
+
}),
|
|
183
|
+
],
|
|
184
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2017",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "preserve",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"plugins": [
|
|
17
|
+
{
|
|
18
|
+
"name": "next"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"paths": {
|
|
22
|
+
"@/*": ["./src/*"]
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
26
|
+
"exclude": ["node_modules"]
|
|
27
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "otherplane",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A static site generator for walkable Gaussian-splat museums. Generate a 3D room per section, link rooms with doorways, publish a static walkable site.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"gaussian-splatting",
|
|
7
|
+
"static-site-generator",
|
|
8
|
+
"3d",
|
|
9
|
+
"webgl",
|
|
10
|
+
"three-js",
|
|
11
|
+
"spark",
|
|
12
|
+
"worldlabs",
|
|
13
|
+
"marble",
|
|
14
|
+
"museum"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/Julian-Moncarz/otherplane#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/Julian-Moncarz/otherplane.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/Julian-Moncarz/otherplane/issues"
|
|
23
|
+
},
|
|
24
|
+
"author": "Julian Moncarz",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"otherplane": "bin/otherplane.mjs"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"bin",
|
|
33
|
+
"scripts",
|
|
34
|
+
"schema",
|
|
35
|
+
"engine",
|
|
36
|
+
"skill.md",
|
|
37
|
+
"CLAUDE.md",
|
|
38
|
+
"otherplane.config.example.json"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"otherplane": "node bin/otherplane.mjs",
|
|
42
|
+
"prepublishOnly": "node bin/otherplane.mjs clean",
|
|
43
|
+
"setup": "node bin/otherplane.mjs setup",
|
|
44
|
+
"init": "node bin/otherplane.mjs init",
|
|
45
|
+
"new": "node bin/otherplane.mjs new",
|
|
46
|
+
"dev": "node bin/otherplane.mjs dev",
|
|
47
|
+
"edit": "node bin/otherplane.mjs edit",
|
|
48
|
+
"check": "node bin/otherplane.mjs check",
|
|
49
|
+
"build": "node bin/otherplane.mjs build",
|
|
50
|
+
"serve": "node bin/otherplane.mjs serve",
|
|
51
|
+
"preview": "node bin/otherplane.mjs preview",
|
|
52
|
+
"clean": "node bin/otherplane.mjs clean",
|
|
53
|
+
"lint": "npm --prefix engine run lint"
|
|
54
|
+
},
|
|
55
|
+
"license": "MIT"
|
|
56
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://otherplane.dev/schema/room.schema.json",
|
|
4
|
+
"title": "Room",
|
|
5
|
+
"description": "A Otherplane room: one splat world you can walk, plus the entryways you arrive at, the exits you leave through, and the artifacts you open. A museum is just rooms linked by exits — there is no manifest. Asset URLs resolve relative to this file's URL.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["display_name", "splat_url", "collider_url", "calibration"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"$schema": { "type": "string", "description": "Path/URL to this schema (editor autocomplete only; ignored by the viewer)." },
|
|
11
|
+
"display_name": { "type": "string", "description": "Human name shown in the HUD, e.g. \"Library\"." },
|
|
12
|
+
"splat_url": { "type": "string", "description": "Gaussian-splat world (.spz/.ply). Resolved relative to this file." },
|
|
13
|
+
"collider_url": { "type": "string", "description": "GLB collider mesh for wall/floor physics. Resolved relative to this file." },
|
|
14
|
+
"music_url": { "type": ["string", "null"], "description": "Optional loopable background track." },
|
|
15
|
+
"pano_url": { "type": ["string", "null"], "description": "Optional equirectangular panorama." },
|
|
16
|
+
"thumbnail_url": { "type": ["string", "null"], "description": "Optional preview image." },
|
|
17
|
+
"calibration": {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"description": "Marble reconstructs at an arbitrary scale; this brings the room to meters.",
|
|
20
|
+
"required": ["scale"],
|
|
21
|
+
"additionalProperties": false,
|
|
22
|
+
"properties": { "scale": { "type": "number", "exclusiveMinimum": 0 } }
|
|
23
|
+
},
|
|
24
|
+
"entryways": {
|
|
25
|
+
"type": "array",
|
|
26
|
+
"description": "Named spawn spots. One should be id \"default\" (the no-fragment spawn).",
|
|
27
|
+
"items": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"required": ["id", "pos", "yaw"],
|
|
30
|
+
"additionalProperties": false,
|
|
31
|
+
"properties": {
|
|
32
|
+
"id": { "type": "string", "description": "Stable id; URL fragments link to it (/library/#from-study). Renaming rots links." },
|
|
33
|
+
"pos": { "$ref": "#/$defs/vec3" },
|
|
34
|
+
"yaw": { "type": "number", "description": "Facing direction in degrees on arrival." }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"exits": {
|
|
39
|
+
"type": "array",
|
|
40
|
+
"description": "Walk up + press E to follow `to`. Dead links 404 by design.",
|
|
41
|
+
"items": {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"required": ["pos", "to"],
|
|
44
|
+
"additionalProperties": false,
|
|
45
|
+
"properties": {
|
|
46
|
+
"pos": { "$ref": "#/$defs/vec3" },
|
|
47
|
+
"radius": { "type": "number", "exclusiveMinimum": 0, "default": 1.3, "description": "Trigger radius in meters." },
|
|
48
|
+
"to": { "type": "string", "description": "Destination URL + #entryway. Relative within a site (../study/#from-library), absolute across sites." }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"artifacts": {
|
|
53
|
+
"type": "array",
|
|
54
|
+
"description": "Walk up + interact to open `url` in a fullscreen overlay (no travel).",
|
|
55
|
+
"items": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"required": ["pos", "radius", "url"],
|
|
58
|
+
"additionalProperties": false,
|
|
59
|
+
"properties": {
|
|
60
|
+
"id": { "type": "string" },
|
|
61
|
+
"pos": { "$ref": "#/$defs/vec3" },
|
|
62
|
+
"radius": { "type": "number", "exclusiveMinimum": 0 },
|
|
63
|
+
"url": { "type": "string", "description": "Web URL opened in the overlay." }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"$defs": {
|
|
69
|
+
"vec3": {
|
|
70
|
+
"type": "array",
|
|
71
|
+
"description": "[x, y, z] in meters, world space.",
|
|
72
|
+
"items": { "type": "number" },
|
|
73
|
+
"minItems": 3,
|
|
74
|
+
"maxItems": 3
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Minimal one-off Marble room generator.
|
|
3
|
+
|
|
4
|
+
Generates a single room from a TEXT prompt, polls the operation, downloads the
|
|
5
|
+
splat (.spz), collider (.glb), pano and thumbnail into the repo-root worlds/,
|
|
6
|
+
and writes a room.json skeleton to rooms/<slug>/room.json for the room primitive
|
|
7
|
+
(engine/src/data/room.ts). Mark entryways/exits in edit mode after.
|
|
8
|
+
|
|
9
|
+
The repo root holds the project's content (rooms/, worlds/, music/); the engine
|
|
10
|
+
mirrors it into engine/public/ at dev/build time. `otherplane new` runs that sync
|
|
11
|
+
for you; if you call this script directly, run `otherplane dev`/`edit` after.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
otherplane new "<text prompt>" --slug library --name "Library"
|
|
15
|
+
# or directly:
|
|
16
|
+
uv run --with requests scripts/gen_world.py "<text prompt>" --slug library --name "Library"
|
|
17
|
+
"""
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from urllib.parse import urlparse
|
|
25
|
+
|
|
26
|
+
import requests
|
|
27
|
+
|
|
28
|
+
BASE = "https://api.worldlabs.ai/marble/v1"
|
|
29
|
+
# The content root. The CLI passes OTHERPLANE_PROJECT; default to this repo's root
|
|
30
|
+
# so direct invocation (uv run scripts/gen_world.py) still works.
|
|
31
|
+
ROOT = Path(os.environ.get("OTHERPLANE_PROJECT") or Path(__file__).resolve().parent.parent)
|
|
32
|
+
OUT = ROOT / "worlds"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_key() -> str:
|
|
36
|
+
env = ROOT / ".env"
|
|
37
|
+
for line in env.read_text().splitlines():
|
|
38
|
+
if line.strip().startswith("WORLD_LABS_KEY"):
|
|
39
|
+
return line.split("=", 1)[1].strip()
|
|
40
|
+
sys.exit("WORLD_LABS_KEY not found in .env")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main() -> None:
|
|
44
|
+
ap = argparse.ArgumentParser()
|
|
45
|
+
ap.add_argument("prompt")
|
|
46
|
+
ap.add_argument("--model", default="marble-1.0-draft")
|
|
47
|
+
ap.add_argument("--name", default="step-one-test-room")
|
|
48
|
+
ap.add_argument("--slug", default="room", help="basename for output files, e.g. 'library'")
|
|
49
|
+
args = ap.parse_args()
|
|
50
|
+
|
|
51
|
+
headers = {"WLT-Api-Key": load_key(), "Content-Type": "application/json"}
|
|
52
|
+
|
|
53
|
+
print(f"→ generating ({args.model}): {args.prompt!r}")
|
|
54
|
+
r = requests.post(
|
|
55
|
+
f"{BASE}/worlds:generate",
|
|
56
|
+
headers=headers,
|
|
57
|
+
json={
|
|
58
|
+
"display_name": args.name,
|
|
59
|
+
"model": args.model,
|
|
60
|
+
"world_prompt": {"type": "text", "text_prompt": args.prompt},
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
r.raise_for_status()
|
|
64
|
+
op = r.json()
|
|
65
|
+
op_id = op.get("operation_id") or op.get("name", "").split("/")[-1] or op.get("id")
|
|
66
|
+
print(f" operation: {op_id}")
|
|
67
|
+
|
|
68
|
+
world_id = None
|
|
69
|
+
while True:
|
|
70
|
+
time.sleep(10)
|
|
71
|
+
s = requests.get(f"{BASE}/operations/{op_id}", headers=headers)
|
|
72
|
+
s.raise_for_status()
|
|
73
|
+
st = s.json()
|
|
74
|
+
if st.get("done"):
|
|
75
|
+
if st.get("error"):
|
|
76
|
+
print(json.dumps(st, indent=2))
|
|
77
|
+
sys.exit("generation failed")
|
|
78
|
+
resp = st.get("response", {})
|
|
79
|
+
world_id = resp.get("world_id") or resp.get("id")
|
|
80
|
+
print(f" done. world_id: {world_id}")
|
|
81
|
+
break
|
|
82
|
+
print(" …still generating")
|
|
83
|
+
|
|
84
|
+
# fetch authoritative world object
|
|
85
|
+
w = requests.get(f"{BASE}/worlds/{world_id}", headers=headers)
|
|
86
|
+
w.raise_for_status()
|
|
87
|
+
world = w.json()
|
|
88
|
+
assets = world.get("assets", {})
|
|
89
|
+
|
|
90
|
+
OUT.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
prov = ROOT / "generated" / args.slug
|
|
92
|
+
prov.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
(prov / "world.json").write_text(json.dumps(world, indent=2))
|
|
94
|
+
|
|
95
|
+
slug = args.slug
|
|
96
|
+
spz = assets.get("splats", {}).get("spz_urls", {})
|
|
97
|
+
targets = {
|
|
98
|
+
f"{slug}.spz": spz.get("500k") or spz.get("full_res") or spz.get("100k"),
|
|
99
|
+
f"{slug}_collider.glb": assets.get("mesh", {}).get("collider_mesh_url"),
|
|
100
|
+
f"{slug}_pano.jpg": assets.get("imagery", {}).get("pano_url"),
|
|
101
|
+
f"{slug}_thumb.jpg": assets.get("thumbnail_url") or assets.get("imagery", {}).get("thumbnail_url"),
|
|
102
|
+
}
|
|
103
|
+
saved: dict[str, str] = {} # role -> public path, e.g. "splat" -> "/worlds/library.spz"
|
|
104
|
+
for fname, url in targets.items():
|
|
105
|
+
if not url:
|
|
106
|
+
print(f" ! no url for {fname}")
|
|
107
|
+
continue
|
|
108
|
+
ext = Path(urlparse(url).path).suffix
|
|
109
|
+
out = OUT / fname
|
|
110
|
+
if ext and ext != out.suffix:
|
|
111
|
+
out = out.with_suffix(ext)
|
|
112
|
+
print(f" ↓ {fname} ← {url[:60]}…")
|
|
113
|
+
dl = requests.get(url)
|
|
114
|
+
dl.raise_for_status()
|
|
115
|
+
out.write_bytes(dl.content)
|
|
116
|
+
print(f" saved {out.relative_to(ROOT)} ({len(dl.content)//1024} KB)")
|
|
117
|
+
role = fname.split(".")[0].replace(slug, "").strip("_") or "splat"
|
|
118
|
+
saved[role] = f"/worlds/{out.name}"
|
|
119
|
+
|
|
120
|
+
print(f"\n✓ assets in {OUT.relative_to(ROOT)}")
|
|
121
|
+
|
|
122
|
+
# Emit a room.json skeleton for the room primitive (src/data/room.ts). One
|
|
123
|
+
# room = one folder = one pretty URL (/<slug>/). Assets are filled in;
|
|
124
|
+
# entryways/exits/artifacts are left empty for marking with `otherplane edit`
|
|
125
|
+
# (fly with Z, copy floor-snapped spots with C). With no entryways the
|
|
126
|
+
# renderer best-effort searches for floor until you mark a "default" one.
|
|
127
|
+
room_dir = ROOT / "rooms" / slug
|
|
128
|
+
room_dir.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
room = {
|
|
130
|
+
"$schema": "../../schema/room.schema.json",
|
|
131
|
+
"display_name": args.name,
|
|
132
|
+
"splat_url": saved.get("splat", f"/worlds/{slug}.spz"),
|
|
133
|
+
"collider_url": saved.get("collider", f"/worlds/{slug}_collider.glb"),
|
|
134
|
+
"thumbnail_url": saved.get("thumb"),
|
|
135
|
+
"music_url": None,
|
|
136
|
+
"calibration": {"scale": 1.0},
|
|
137
|
+
"entryways": [],
|
|
138
|
+
"exits": [],
|
|
139
|
+
"artifacts": [],
|
|
140
|
+
}
|
|
141
|
+
room_path = room_dir / "room.json"
|
|
142
|
+
room_path.write_text(json.dumps(room, indent=2) + "\n")
|
|
143
|
+
print(f"✓ room skeleton {room_path.relative_to(ROOT)} — run `otherplane edit`, open /{slug}/ to mark it")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
main()
|
package/skill.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Skill — turn a personal website into a walkable splat museum
|
|
2
|
+
|
|
3
|
+
Given someone's personal site, generate a 3D **room** per section, mark its
|
|
4
|
+
spawn/doors/artifacts, link rooms together, and publish a **static walkable
|
|
5
|
+
museum**. Generation is automated; curation and placement are human-in-the-loop.
|
|
6
|
+
|
|
7
|
+
> The runtime/renderer is documented in `engine/` (its own README + CLAUDE.md).
|
|
8
|
+
> This file is the *authoring* skill — how rooms get made, marked, linked, shipped.
|
|
9
|
+
|
|
10
|
+
## Providers / stack
|
|
11
|
+
|
|
12
|
+
| Need | Provider | Notes |
|
|
13
|
+
| :----------------------- | :---------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
14
|
+
| Room generation | **World Labs Marble** | `api.worldlabs.ai/marble/v1`; text/image → `.spz` splat + GLB collider. `marble-1.0-draft` for cheap iteration ($1.26). Key in `.env` as `WORLD_LABS_KEY`. |
|
|
15
|
+
| Renderer | **WorldSplats** (`engine/`) | Spark (splats) + Three.js + Rapier (physics); static export. Content-free viewer. |
|
|
16
|
+
| Concept art *(optional)* | **FLUX.2 Pro via fal.ai** | photographic concept image for a consistent house style; Marble reconstructs photos best. Not yet scripted. |
|
|
17
|
+
| Room music *(optional)* | **yt-dlp** | a quiet, loopable track; no key/cost. Manual pull. |
|
|
18
|
+
| Asset storage | **Cloudflare R2** (or any object storage / CDN) | permanent hosting, zero egress. Marble's own URLs have no retention SLA — **self-host anything you want to keep**. |
|
|
19
|
+
| Hosting | **GitHub Pages / Cloudflare Pages / any static host** | the built `out/` is plain static files. |
|
|
20
|
+
|
|
21
|
+
## The model (what a museum is)
|
|
22
|
+
|
|
23
|
+
A museum is just **rooms linked by exits** — like web pages linked by `href`s.
|
|
24
|
+
There is no manifest and no central "museum" object; the graph is emergent.
|
|
25
|
+
|
|
26
|
+
A room is `rooms/<slug>/room.json` (at the repo root; the CLI mirrors it into
|
|
27
|
+
`engine/public/` at build):
|
|
28
|
+
|
|
29
|
+
```jsonc
|
|
30
|
+
{
|
|
31
|
+
"display_name": "Library",
|
|
32
|
+
"splat_url": "library.spz", // resolved RELATIVE to this file
|
|
33
|
+
"collider_url": "library_collider.glb",
|
|
34
|
+
"music_url": "library.mp3",
|
|
35
|
+
"calibration": { "scale": 1.0 },
|
|
36
|
+
"entryways": [ { "id": "default", "pos": [x,y,z], "yaw": deg } ],
|
|
37
|
+
"exits": [ { "pos": [x,y,z], "radius": 1.3, "to": "../study/#from-library" } ],
|
|
38
|
+
"artifacts": [ { "pos": [x,y,z], "radius": 1.0, "url": "https://…" } ]
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
* **One room = one URL:** `/<slug>/`, entryway via `#fragment` (`/library/#from-study`).
|
|
43
|
+
|
|
44
|
+
* **entryway** — a named arrival spot (`id`/`pos`/`yaw`). `default` is used when there's no fragment.
|
|
45
|
+
|
|
46
|
+
* **exit** — walk up + press **E** to follow `to` (a room URL + `#entryway`; relative within a site, absolute across). Dead links 404 by design.
|
|
47
|
+
|
|
48
|
+
* **artifact** — walk up + interact to open a web URL in an overlay (no travel).
|
|
49
|
+
|
|
50
|
+
* **assets are URL-agnostic** — resolved relative to the room.json's URL, so they can be colocated, on R2/S3, or on the Marble CDN.
|
|
51
|
+
|
|
52
|
+
## Pipeline (per room) — ✋ = human checkpoint
|
|
53
|
+
|
|
54
|
+
1. **Read the section/piece** (essay, repo README, video). Pick a vibe + the artifact object (desk / CRT / telescope / …).
|
|
55
|
+
2. ✋ *(optional)* **Concept image** — FLUX, photographic, in the house style. Approve or regenerate.
|
|
56
|
+
3. **Generate the room:**
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm run new -- "<prompt>" --slug <slug> --name "<Name>"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Downloads splat/collider into `worlds/` and writes a `rooms/<slug>/room.json`
|
|
63
|
+
skeleton (empty entryways/exits/artifacts). Use `--model marble-1.0-draft`
|
|
64
|
+
while iterating.
|
|
65
|
+
4. ✋ **Mark & wire it** — `npm run edit`, open `/<slug>/`. An authoring panel
|
|
66
|
+
writes `room.json` for you (no clipboard, no hand-editing):
|
|
67
|
+
|
|
68
|
+
* **C** add / move an entryway (floor-snapped `pos`+`yaw`); **B** aim + add /
|
|
69
|
+
move an artifact; **F** select the orb under the crosshair; **Del** remove;
|
|
70
|
+
**Z** specter no-clip fly (↑/↓) to reach any spot.
|
|
71
|
+
|
|
72
|
+
* In the panel: name entryways, wire **exits** by menu (any room → entryway) or
|
|
73
|
+
paste an arbitrary URL, **promote** an entryway into a doorway, and click
|
|
74
|
+
**two-way** to auto-create the reciprocal door in the target room (it reuses
|
|
75
|
+
that room's existing entryway position — so mark the far side's arrival spot
|
|
76
|
+
first). Internal exits are relative `../<other>/#<entryway>`; a two-way link
|
|
77
|
+
is two exits + two entryways.
|
|
78
|
+
6. ✋ *(optional)* **Music** — `yt-dlp` a quiet loopable track into `music/`; set
|
|
79
|
+
`music_url`.
|
|
80
|
+
7. ✋ *(per artifact)* **Build the styled page** the artifact opens; set its `url`.
|
|
81
|
+
8. **Publish** — mirror the big assets to R2 (durable) and rewrite the `room.json`
|
|
82
|
+
URLs to point there; then `npm run build` and deploy `engine/out/`
|
|
83
|
+
to the static host. (Keep big binaries out of git — R2 holds blobs, git holds
|
|
84
|
+
the tiny `room.json` + the shared bundle.)
|
|
85
|
+
|
|
86
|
+
## Conventions / gotchas
|
|
87
|
+
|
|
88
|
+
* **Stable slugs + entryway ids** — other rooms (and other people's museums) link to them; renaming rots links.
|
|
89
|
+
|
|
90
|
+
* **Self-host keeper assets** — Marble CDN URLs are unsigned/stable today but undocumented, so don't depend on them for anything permanent.
|
|
91
|
+
|
|
92
|
+
* **`default`** **entryway** is the no-fragment spawn; mark one for every room.
|
|
93
|
+
|
|
94
|
+
* Marble splats use quaternion `[1,0,0,0]` + `calibration.scale` — handled by the viewer; you only mark coordinates.
|