castle-web-cli 0.4.128 → 0.4.130

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 (157) hide show
  1. package/dist/agent-prompts.js +4 -4
  2. package/dist/filesChanged.d.ts +2 -0
  3. package/dist/filesChanged.js +6 -2
  4. package/dist/ide.js +15 -4
  5. package/dist/importBrowse.js +1 -0
  6. package/dist/index.js +10 -4
  7. package/dist/init.js +63 -15
  8. package/dist/native/loop.js +1 -1
  9. package/dist/native/tools.js +1 -1
  10. package/dist/serve.d.ts +1 -0
  11. package/dist/serve.js +20 -1
  12. package/dist/shell/assets/index-BpOLUyVO.js +441 -0
  13. package/dist/shell/assets/{index-CAq6f9B5.css → index-D6K-0YDB.css} +1 -1
  14. package/dist/shell/index.html +2 -2
  15. package/kits/physics-2d/CLAUDE.md +40 -23
  16. package/kits/physics-2d/behaviors/Joints.jsx +4 -3
  17. package/kits/physics-2d/behaviors/SoundPlayer.jsx +266 -0
  18. package/kits/physics-2d/behaviors/Sprite.jsx +3 -3
  19. package/kits/physics-2d/behaviors/Video.jsx +4 -3
  20. package/kits/physics-2d/castle.json +1 -1
  21. package/kits/physics-2d/editors/PlayOnly.jsx +15 -6
  22. package/kits/physics-2d/editors/PxArtEditor.jsx +95 -31
  23. package/kits/physics-2d/editors/SceneEditor.jsx +5 -5
  24. package/kits/physics-2d/editors/SingleEditor.jsx +37 -8
  25. package/kits/physics-2d/editors/StyleEditor.jsx +7 -4
  26. package/kits/physics-2d/editors/pixelEditorChrome.jsx +7 -7
  27. package/kits/physics-2d/editors/pixelInspector.jsx +13 -7
  28. package/kits/physics-2d/editors/pxArtTimeline.jsx +29 -11
  29. package/kits/physics-2d/editors/pxArtTimeline.module.css +11 -0
  30. package/kits/physics-2d/engine/audioContext.js +41 -0
  31. package/kits/physics-2d/engine/autoInspector.jsx +50 -33
  32. package/kits/physics-2d/engine/blueprint.js +15 -3
  33. package/kits/physics-2d/engine/liveReload.js +33 -21
  34. package/kits/physics-2d/engine/media.js +29 -39
  35. package/kits/physics-2d/engine/sound.js +316 -0
  36. package/kits/physics-2d/engine/systemRegistry.js +1 -1
  37. package/kits/physics-2d/engine/ui.jsx +274 -5
  38. package/kits/physics-2d/engine/ui.module.css +137 -16
  39. package/kits/physics-2d/scripts/testsounds.mjs +84 -0
  40. package/kits/physics-2d/systems/media.js +31 -15
  41. package/kits/physics-3d/CLAUDE.md +109 -0
  42. package/kits/physics-3d/behaviors/Body.jsx +51 -0
  43. package/kits/physics-3d/behaviors/Door.jsx +31 -0
  44. package/kits/physics-3d/behaviors/Lookable.jsx +11 -0
  45. package/kits/physics-3d/behaviors/Model.jsx +14 -0
  46. package/kits/physics-3d/behaviors/Pickup.jsx +39 -0
  47. package/kits/physics-3d/behaviors/Player.jsx +172 -0
  48. package/kits/physics-3d/behaviors/Portal.jsx +19 -0
  49. package/kits/physics-3d/behaviors/Shape.jsx +75 -0
  50. package/kits/physics-3d/behaviors/Solid.jsx +11 -0
  51. package/kits/physics-3d/behaviors/Transform.jsx +24 -0
  52. package/kits/physics-3d/blueprints/barrel.scene +15 -0
  53. package/kits/physics-3d/blueprints/crate.scene +29 -0
  54. package/kits/physics-3d/blueprints/door.scene +35 -0
  55. package/kits/physics-3d/blueprints/gem.scene +26 -0
  56. package/kits/physics-3d/blueprints/pillar.scene +25 -0
  57. package/kits/physics-3d/blueprints/platform.scene +23 -0
  58. package/kits/physics-3d/blueprints/player.scene +29 -0
  59. package/kits/physics-3d/blueprints/portal.scene +21 -0
  60. package/kits/physics-3d/blueprints/rock.scene +14 -0
  61. package/kits/physics-3d/blueprints/statue.scene +26 -0
  62. package/kits/physics-3d/blueprints/tree.scene +14 -0
  63. package/kits/physics-3d/blueprints/wall.scene +26 -0
  64. package/kits/{basic-2d → physics-3d}/castle.json +23 -5
  65. package/kits/physics-3d/docs/pxmodel-format.md +111 -0
  66. package/kits/physics-3d/drawings/crate.pxart +26 -0
  67. package/kits/physics-3d/drawings/door-edge.pxart +26 -0
  68. package/kits/physics-3d/drawings/door.pxart +27 -0
  69. package/kits/physics-3d/drawings/face.pxart +26 -0
  70. package/kits/physics-3d/drawings/floor.pxart +27 -0
  71. package/kits/physics-3d/drawings/platform.pxart +27 -0
  72. package/kits/physics-3d/drawings/statue.pxart +27 -0
  73. package/kits/physics-3d/drawings/wall.pxart +27 -0
  74. package/kits/physics-3d/engine3d/PlayOnly3D.jsx +32 -0
  75. package/kits/physics-3d/engine3d/PxModelEditor.jsx +619 -0
  76. package/kits/physics-3d/engine3d/Scene3DEditor.jsx +618 -0
  77. package/kits/physics-3d/engine3d/Scene3DPlayer.jsx +309 -0
  78. package/kits/physics-3d/engine3d/editor3dData.js +114 -0
  79. package/kits/physics-3d/engine3d/editor3dInspector.jsx +404 -0
  80. package/kits/physics-3d/engine3d/editorChrome.jsx +218 -0
  81. package/kits/physics-3d/engine3d/editorWorld.js +249 -0
  82. package/kits/physics-3d/engine3d/materials.js +174 -0
  83. package/kits/physics-3d/engine3d/meshops.js +123 -0
  84. package/kits/physics-3d/engine3d/modelEditorWorld.js +439 -0
  85. package/kits/physics-3d/engine3d/modelFiles.js +139 -0
  86. package/kits/physics-3d/engine3d/pxmodel.js +265 -0
  87. package/kits/physics-3d/engine3d/thumbnails.js +137 -0
  88. package/kits/physics-3d/engine3d/world3d.js +216 -0
  89. package/kits/{basic-2d → physics-3d}/eslint.config.js +10 -25
  90. package/kits/physics-3d/index.html +20 -0
  91. package/kits/physics-3d/main.jsx +30 -0
  92. package/kits/physics-3d/models/barrel.pxmodel +10 -0
  93. package/kits/physics-3d/models/player.pxmodel +222 -0
  94. package/kits/physics-3d/models/rock.pxmodel +30 -0
  95. package/kits/physics-3d/models/tree.pxmodel +10 -0
  96. package/kits/{basic-2d → physics-3d}/package-lock.json +140 -119
  97. package/kits/{basic-2d → physics-3d}/package.json +11 -9
  98. package/kits/{basic-2d → physics-3d}/pnpm-lock.yaml +162 -137
  99. package/kits/physics-3d/scenes/main.scene +414 -0
  100. package/kits/physics-3d/scenes/model-lab.scene +127 -0
  101. package/kits/physics-3d/systems/physics3d.js +356 -0
  102. package/package.json +5 -2
  103. package/dist/shell/assets/index-DKu9ejyh.js +0 -436
  104. package/kits/basic-2d/CLAUDE.md +0 -221
  105. package/kits/basic-2d/behaviors/Camera.jsx +0 -43
  106. package/kits/basic-2d/behaviors/Collider.jsx +0 -213
  107. package/kits/basic-2d/behaviors/Layout.jsx +0 -53
  108. package/kits/basic-2d/behaviors/Sprite.jsx +0 -357
  109. package/kits/basic-2d/behaviors/tint.js +0 -47
  110. package/kits/basic-2d/blueprints/cauldron.scene +0 -20
  111. package/kits/basic-2d/docs/pxart-format.md +0 -377
  112. package/kits/basic-2d/drawings/cauldron.pxart +0 -113
  113. package/kits/basic-2d/editors/BlueprintLibrary.jsx +0 -270
  114. package/kits/basic-2d/editors/ErrorBoundary.jsx +0 -59
  115. package/kits/basic-2d/editors/PlayOnly.jsx +0 -31
  116. package/kits/basic-2d/editors/PxArtEditor.jsx +0 -1092
  117. package/kits/basic-2d/editors/SceneEditor.jsx +0 -1780
  118. package/kits/basic-2d/editors/SelectionOverlay.jsx +0 -909
  119. package/kits/basic-2d/editors/SingleEditor.jsx +0 -122
  120. package/kits/basic-2d/editors/behaviorRegistry.js +0 -34
  121. package/kits/basic-2d/editors/editorHistory.js +0 -157
  122. package/kits/basic-2d/editors/inspectorSheet.js +0 -13
  123. package/kits/basic-2d/editors/pixelCanvas.js +0 -11
  124. package/kits/basic-2d/editors/pixelEditorChrome.jsx +0 -74
  125. package/kits/basic-2d/editors/pixelGeometry.js +0 -140
  126. package/kits/basic-2d/editors/pixelInspector.jsx +0 -633
  127. package/kits/basic-2d/editors/pxArtEditorModel.js +0 -732
  128. package/kits/basic-2d/editors/pxArtPlayback.js +0 -92
  129. package/kits/basic-2d/editors/pxArtTimeline.jsx +0 -752
  130. package/kits/basic-2d/editors/pxArtTimeline.module.css +0 -506
  131. package/kits/basic-2d/editors/pxArtTools.js +0 -232
  132. package/kits/basic-2d/editors/useArtboardFit.js +0 -105
  133. package/kits/basic-2d/engine/ScenePlayer.jsx +0 -209
  134. package/kits/basic-2d/engine/SceneUI.jsx +0 -59
  135. package/kits/basic-2d/engine/assets.js +0 -15
  136. package/kits/basic-2d/engine/autoInspector.jsx +0 -70
  137. package/kits/basic-2d/engine/behaviorExtensions.js +0 -32
  138. package/kits/basic-2d/engine/blueprint.js +0 -557
  139. package/kits/basic-2d/engine/collider.js +0 -200
  140. package/kits/basic-2d/engine/files.js +0 -141
  141. package/kits/basic-2d/engine/liveReload.js +0 -88
  142. package/kits/basic-2d/engine/pxart.js +0 -1032
  143. package/kits/basic-2d/engine/pxartSmooth.js +0 -222
  144. package/kits/basic-2d/engine/scene.js +0 -696
  145. package/kits/basic-2d/engine/spriteGeometry.js +0 -32
  146. package/kits/basic-2d/engine/systemRegistry.js +0 -16
  147. package/kits/basic-2d/engine/ui.jsx +0 -695
  148. package/kits/basic-2d/engine/ui.module.css +0 -2287
  149. package/kits/basic-2d/index.html +0 -24
  150. package/kits/basic-2d/main.jsx +0 -24
  151. package/kits/basic-2d/scenes/main.scene +0 -16
  152. package/kits/basic-2d/scripts/draw.mjs +0 -121
  153. package/kits/physics-2d/behaviors/Sound.jsx +0 -163
  154. package/kits/physics-2d/behaviors/Tone.jsx +0 -95
  155. package/kits/physics-2d/engine/tone.js +0 -112
  156. /package/kits/{basic-2d → physics-3d}/.prettierrc +0 -0
  157. /package/kits/{basic-2d → physics-3d}/vite.config.js +0 -0
@@ -0,0 +1,619 @@
1
+ // The `.pxmodel` editor: the model on an orbit stage with part picking and
2
+ // move/rotate gizmos, a parts-list + part-fields inspector in the shared
3
+ // design system, add/duplicate/delete parts, a walk-preview toggle, and
4
+ // text-level undo/redo with debounced saves. The FILE is the source of truth;
5
+ // every edit round-trips through parse -> mutate -> sparse serialize.
6
+ import React, { useEffect, useRef, useState } from 'react';
7
+ import {
8
+ ColorField,
9
+ EditorBody,
10
+ MainEditor,
11
+ NumberField,
12
+ Panel,
13
+ SelectField,
14
+ SheetGrabHandle,
15
+ TextField,
16
+ cx,
17
+ styles,
18
+ useMobileSheet,
19
+ } from '@imports/castle.physics-2d/engine/ui';
20
+ import { useEditHistory } from '@imports/castle.physics-2d/editors/editorHistory';
21
+ import { useLiveDeckFiles3D, useStableSprites, fetchMissingFile } from './modelFiles';
22
+ import { parseModelCached, parseModel, serializeModel, STARTER_MODEL_TEXT } from './pxmodel';
23
+ import { boxMesh, extrudeFace, roundMeshInPlace } from './meshops';
24
+ import {
25
+ createModelEditorWorld,
26
+ setEditorModel,
27
+ disposeModelEditorWorld,
28
+ } from './modelEditorWorld';
29
+ import {
30
+ HotbarButton,
31
+ StageCanvas,
32
+ StageControls,
33
+ useEditorShortcuts,
34
+ useFileSaver3D,
35
+ } from './editorChrome';
36
+
37
+ const KIND_OPTIONS = [
38
+ { value: 'box', label: 'Box', icon: 'square' },
39
+ { value: 'sphere', label: 'Sphere', icon: 'circle' },
40
+ { value: 'cylinder', label: 'Cylinder', glyph: 'cylinder' },
41
+ { value: 'capsule', label: 'Capsule', glyph: 'capsule' },
42
+ ];
43
+
44
+ function dedupePartId(parts, base) {
45
+ const taken = new Set(parts.map((part) => part.id));
46
+ if (!taken.has(base)) return base;
47
+ for (let n = 2; ; n++) {
48
+ if (!taken.has(`${base}-${n}`)) return `${base}-${n}`;
49
+ }
50
+ }
51
+
52
+ function descendantIds(parts, rootId) {
53
+ const out = new Set([rootId]);
54
+ let grew = true;
55
+ while (grew) {
56
+ grew = false;
57
+ for (const part of parts) {
58
+ if (part.parent && out.has(part.parent) && !out.has(part.id)) {
59
+ out.add(part.id);
60
+ grew = true;
61
+ }
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+
67
+ export function PxModelEditor({ path }) {
68
+ const saver = useFileSaver3D();
69
+ const { files, setFiles, dataVersion } = useLiveDeckFiles3D({
70
+ shouldSkipPath: saver.hasPending,
71
+ });
72
+ const text = files[path];
73
+ useEffect(() => {
74
+ if (text === undefined) void fetchMissingFile(path, setFiles);
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, [text === undefined]);
77
+ const onChangeText = (next) => {
78
+ setFiles((current) => ({ ...current, [path]: next }));
79
+ saver.schedule(path, next);
80
+ };
81
+ const history = useEditHistory(text ?? '', onChangeText, path);
82
+ const sprites = useStableSprites(files);
83
+ // A brand-new (empty) file edits as the starter model; the first commit
84
+ // writes it out for real.
85
+ const model =
86
+ text && text.trim()
87
+ ? parseModelCached(text)
88
+ : text !== undefined
89
+ ? parseModel(STARTER_MODEL_TEXT)
90
+ : null;
91
+
92
+ const [mode, setMode] = useState('move');
93
+ const [selectedPartId, setSelectedPartId] = useState(null);
94
+ const [meshEdit, setMeshEdit] = useState(null); // { partId, mode: 'face'|'vertex', selection: [] }
95
+ const [walk, setWalk] = useState(false);
96
+ const canvasRef = useRef(null);
97
+ const medRef = useRef(null);
98
+
99
+ const commitModel = (mutate, options) => {
100
+ if (!model) return;
101
+ const next = structuredClone(model);
102
+ mutate(next);
103
+ history.commit(serializeModel(next), options);
104
+ };
105
+
106
+ const callbacks = {
107
+ onPickPart: (partId) => setSelectedPartId(partId),
108
+ onEditPick: (index, additive) =>
109
+ setMeshEdit((current) => {
110
+ if (!current) return current;
111
+ if (index == null) return { ...current, selection: additive ? current.selection : [] };
112
+ const has = current.selection.includes(index);
113
+ const selection = additive
114
+ ? has
115
+ ? current.selection.filter((candidate) => candidate !== index)
116
+ : [...current.selection, index]
117
+ : [index];
118
+ return { ...current, selection };
119
+ }),
120
+ onCommitMeshVertices: (partId, vertices) =>
121
+ commitModel((next) => {
122
+ const part = next.parts.find((candidate) => candidate.id === partId);
123
+ if (part) part.vertices = vertices;
124
+ }),
125
+ onCommitPart: (partId, patch) =>
126
+ commitModel((next) => {
127
+ const part = next.parts.find((candidate) => candidate.id === partId);
128
+ if (part) Object.assign(part, patch);
129
+ }),
130
+ };
131
+
132
+ useEffect(() => {
133
+ const canvas = canvasRef.current;
134
+ if (!canvas) return undefined;
135
+ const med = createModelEditorWorld(canvas, callbacks);
136
+ medRef.current = med;
137
+ return () => {
138
+ disposeModelEditorWorld(med);
139
+ medRef.current = null;
140
+ };
141
+ // eslint-disable-next-line react-hooks/exhaustive-deps
142
+ }, []);
143
+ useEffect(() => {
144
+ if (medRef.current) medRef.current.callbacks = callbacks;
145
+ });
146
+ useEffect(() => {
147
+ const med = medRef.current;
148
+ if (!med || !model || med.gizmoDragging) return;
149
+ setEditorModel(med, model, sprites);
150
+ // eslint-disable-next-line react-hooks/exhaustive-deps
151
+ }, [text, dataVersion]);
152
+ useEffect(() => {
153
+ const med = medRef.current;
154
+ if (!med) return;
155
+ med.mode = mode;
156
+ med.selectedPartId = selectedPartId;
157
+ med.edit = meshEdit;
158
+ med.walk = walk;
159
+ }, [mode, selectedPartId, walk, meshEdit]);
160
+
161
+ const addPart = (kind) => {
162
+ let id;
163
+ commitModel((next) => {
164
+ id = dedupePartId(next.parts, kind);
165
+ const part = { id, kind, position: [0, 0.5, 0] };
166
+ if (kind === 'sphere') part.radius = 0.4;
167
+ if (kind === 'cylinder' || kind === 'capsule') {
168
+ part.radius = 0.3;
169
+ part.height = 0.9;
170
+ }
171
+ if (kind === 'box') part.size = [0.6, 0.6, 0.6];
172
+ next.parts.push(part);
173
+ });
174
+ if (id) setSelectedPartId(id);
175
+ };
176
+ const duplicatePart = () => {
177
+ if (!selectedPartId) return;
178
+ let id;
179
+ commitModel((next) => {
180
+ const source = next.parts.find((part) => part.id === selectedPartId);
181
+ if (!source) return;
182
+ const copy = structuredClone(source);
183
+ id = dedupePartId(next.parts, source.id.replace(/-\d+$/, ''));
184
+ copy.id = id;
185
+ copy.position = [...copy.position];
186
+ copy.position[0] += 0.2;
187
+ next.parts.push(copy);
188
+ });
189
+ if (id) setSelectedPartId(id);
190
+ };
191
+ const deletePart = () => {
192
+ if (!selectedPartId) return;
193
+ commitModel((next) => {
194
+ const removed = next.parts.find((part) => part.id === selectedPartId);
195
+ if (!removed) return;
196
+ next.parts = next.parts.filter((part) => part.id !== selectedPartId);
197
+ for (const part of next.parts) {
198
+ if (part.parent === selectedPartId) {
199
+ // Keep the child where it was: fold the removed pivot into it.
200
+ part.parent = removed.parent ?? '';
201
+ part.position = part.position.map((v, i) => v + removed.position[i]);
202
+ }
203
+ }
204
+ });
205
+ setSelectedPartId(null);
206
+ };
207
+
208
+ const selectedPart = model?.parts.find((candidate) => candidate.id === selectedPartId);
209
+ const enterMeshEdit = () => {
210
+ if (selectedPart?.kind !== 'mesh') return;
211
+ setMeshEdit({ partId: selectedPart.id, mode: 'face', selection: [] });
212
+ setMode('move');
213
+ };
214
+ const exitMeshEdit = () => setMeshEdit(null);
215
+ const convertToMesh = () => {
216
+ if (selectedPart?.kind !== 'box') return;
217
+ commitModel((next) => {
218
+ const part = next.parts.find((candidate) => candidate.id === selectedPartId);
219
+ if (!part) return;
220
+ const { vertices, faces } = boxMesh(part.size);
221
+ part.kind = 'mesh';
222
+ part.vertices = vertices;
223
+ part.faces = faces;
224
+ });
225
+ };
226
+ const extrudeSelection = () => {
227
+ if (!meshEdit || meshEdit.mode !== 'face' || meshEdit.selection.length === 0) return;
228
+ commitModel((next) => {
229
+ const part = next.parts.find((candidate) => candidate.id === meshEdit.partId);
230
+ if (!part) return;
231
+ for (const faceIndex of meshEdit.selection) extrudeFace(part, faceIndex, 0.25);
232
+ roundMeshInPlace(part);
233
+ });
234
+ };
235
+
236
+ useEditorShortcuts({
237
+ history,
238
+ setMode,
239
+ onDuplicate: meshEdit ? undefined : duplicatePart,
240
+ onDelete: meshEdit ? undefined : deletePart,
241
+ onEscape: () => (meshEdit ? exitMeshEdit() : setSelectedPartId(null)),
242
+ });
243
+
244
+ const toggleWalk = () => {
245
+ const next = !walk;
246
+ setWalk(next);
247
+ const med = medRef.current;
248
+ if (!next && med && model) {
249
+ med.walkClock = 0;
250
+ setEditorModel(med, model, sprites); // restore the authored pose
251
+ }
252
+ };
253
+
254
+ if (!model) {
255
+ return (
256
+ <div className={styles.panelBare}>
257
+ <MainEditor>
258
+ <EditorBody>
259
+ <div className={styles.inspector}>
260
+ {text === undefined ? 'loading…' : `${path}: not a valid .pxmodel`}
261
+ </div>
262
+ </EditorBody>
263
+ </MainEditor>
264
+ </div>
265
+ );
266
+ }
267
+
268
+ return (
269
+ <div className={styles.panelBare}>
270
+ <MainEditor>
271
+ <EditorBody>
272
+ <div className={styles.sceneWorkspace}>
273
+ <div className={styles.sceneTools}>
274
+ <div className={styles.bpHotbar}>
275
+ {KIND_OPTIONS.map((option) => (
276
+ <HotbarButton
277
+ key={option.value}
278
+ icon={option.icon}
279
+ glyph={option.glyph}
280
+ label={option.label}
281
+ title={`Add a ${option.label.toLowerCase()} part`}
282
+ onClick={() => addPart(option.value)}
283
+ />
284
+ ))}
285
+ <span style={{ width: 10 }} />
286
+ {selectedPart?.kind === 'box' && !meshEdit ? (
287
+ <HotbarButton icon="object-group" label="To mesh" onClick={convertToMesh} />
288
+ ) : null}
289
+ {selectedPart?.kind === 'mesh' && !meshEdit ? (
290
+ <HotbarButton icon="pencil" label="Edit mesh" onClick={enterMeshEdit} />
291
+ ) : null}
292
+ {meshEdit ? (
293
+ <>
294
+ <HotbarButton
295
+ glyph="face"
296
+ label="Faces"
297
+ active={meshEdit.mode === 'face'}
298
+ onClick={() => setMeshEdit({ ...meshEdit, mode: 'face', selection: [] })}
299
+ />
300
+ <HotbarButton
301
+ glyph="vertices"
302
+ label="Vertices"
303
+ active={meshEdit.mode === 'vertex'}
304
+ onClick={() => setMeshEdit({ ...meshEdit, mode: 'vertex', selection: [] })}
305
+ />
306
+ <HotbarButton
307
+ glyph="extrude"
308
+ label="Extrude"
309
+ disabled={meshEdit.mode !== 'face' || meshEdit.selection.length === 0}
310
+ onClick={extrudeSelection}
311
+ />
312
+ <HotbarButton icon="check" label="Done" onClick={exitMeshEdit} />
313
+ </>
314
+ ) : null}
315
+ {model.parts.some((part) =>
316
+ ['legL', 'legR', 'armL', 'armR', 'torso'].includes(part.id)
317
+ ) ? (
318
+ <>
319
+ <span style={{ width: 10 }} />
320
+ <HotbarButton
321
+ icon="play"
322
+ label="Walk"
323
+ title="Preview the walk animation (parts named legL/legR/armL/armR/torso)"
324
+ active={walk}
325
+ onClick={toggleWalk}
326
+ />
327
+ </>
328
+ ) : null}
329
+ </div>
330
+ </div>
331
+ <div className={styles.stageWrap} style={{ padding: 0, position: 'relative' }}>
332
+ <StageCanvas ref={canvasRef} />
333
+ <StageControls
334
+ history={history}
335
+ mode={mode}
336
+ setMode={setMode}
337
+ onDuplicate={duplicatePart}
338
+ onDelete={deletePart}
339
+ actionsDisabled={!selectedPartId}
340
+ />
341
+ </div>
342
+ <ModelInspectorPane
343
+ model={model}
344
+ selectedPartId={selectedPartId}
345
+ onSelectPart={setSelectedPartId}
346
+ commitModel={commitModel}
347
+ setSelectedPartId={setSelectedPartId}
348
+ />
349
+ </div>
350
+ </EditorBody>
351
+ </MainEditor>
352
+ </div>
353
+ );
354
+ }
355
+
356
+ function partDepth(parts, part) {
357
+ let depth = 0;
358
+ let current = part;
359
+ while (current?.parent) {
360
+ depth += 1;
361
+ current = parts.find((candidate) => candidate.id === current.parent);
362
+ if (depth > 8) break;
363
+ }
364
+ return depth;
365
+ }
366
+
367
+ function PartList({ model, selectedPartId, onSelectPart }) {
368
+ return (
369
+ <Panel title="Parts">
370
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2, padding: '4px 0' }}>
371
+ {model.parts.map((part) => (
372
+ <button
373
+ key={part.id}
374
+ type="button"
375
+ onClick={() => onSelectPart(part.id)}
376
+ style={{
377
+ textAlign: 'left',
378
+ padding: '5px 10px',
379
+ paddingLeft: 10 + partDepth(model.parts, part) * 14,
380
+ border: 'none',
381
+ borderRadius: 5,
382
+ background:
383
+ part.id === selectedPartId ? 'var(--castle-inspector-divider)' : 'transparent',
384
+ color: 'inherit',
385
+ font: 'inherit',
386
+ fontSize: 13,
387
+ cursor: 'pointer',
388
+ }}>
389
+ {part.id}
390
+ <span style={{ opacity: 0.55 }}> · {part.kind}</span>
391
+ </button>
392
+ ))}
393
+ </div>
394
+ </Panel>
395
+ );
396
+ }
397
+
398
+ function PartPlacementFields({ part, patchPart }) {
399
+ const triplet = (key, label, step) => (
400
+ <>
401
+ {[0, 1, 2].map((axis) => (
402
+ <NumberField
403
+ key={`${key}${axis}`}
404
+ label={`${label} ${'XYZ'[axis]}`}
405
+ value={part[key][axis]}
406
+ step={step}
407
+ onChange={(value) => {
408
+ const next = [...part[key]];
409
+ next[axis] = value;
410
+ patchPart({ [key]: next }, `${key}${axis}`);
411
+ }}
412
+ />
413
+ ))}
414
+ </>
415
+ );
416
+ return (
417
+ <Panel title="Placement">
418
+ {triplet('position', 'Position', 0.05)}
419
+ {triplet('rotation', 'Rotation', 5)}
420
+ {triplet('offset', 'Offset', 0.05)}
421
+ </Panel>
422
+ );
423
+ }
424
+
425
+ function PartShapeFields({ part, patchPart }) {
426
+ return (
427
+ <Panel title="Geometry">
428
+ <SelectField
429
+ label="Kind"
430
+ value={part.kind}
431
+ options={KIND_OPTIONS}
432
+ onChange={(kind) => patchPart({ kind })}
433
+ />
434
+ {part.kind === 'box' ? (
435
+ [0, 1, 2].map((axis) => (
436
+ <NumberField
437
+ key={axis}
438
+ label={`Size ${'XYZ'[axis]}`}
439
+ value={part.size[axis]}
440
+ min={0.01}
441
+ step={0.05}
442
+ onChange={(value) => {
443
+ const next = [...part.size];
444
+ next[axis] = value;
445
+ patchPart({ size: next }, `size${axis}`);
446
+ }}
447
+ />
448
+ ))
449
+ ) : (
450
+ <>
451
+ <NumberField
452
+ label="Radius"
453
+ value={part.radius}
454
+ min={0.01}
455
+ step={0.05}
456
+ onChange={(radius) => patchPart({ radius }, 'radius')}
457
+ />
458
+ {part.kind !== 'sphere' ? (
459
+ <NumberField
460
+ label="Height"
461
+ value={part.height}
462
+ min={0.01}
463
+ step={0.05}
464
+ onChange={(height) => patchPart({ height }, 'height')}
465
+ />
466
+ ) : null}
467
+ </>
468
+ )}
469
+ </Panel>
470
+ );
471
+ }
472
+
473
+ function PartMaterialFields({ part, patchPart }) {
474
+ const number = (key, label, max) => (
475
+ <NumberField
476
+ label={label}
477
+ value={part[key]}
478
+ min={0}
479
+ max={max}
480
+ step={0.05}
481
+ onChange={(value) => patchPart({ [key]: value }, key)}
482
+ />
483
+ );
484
+ return (
485
+ <Panel title="Material">
486
+ <ColorField
487
+ label="Color"
488
+ value={part.color}
489
+ onChange={(color) => patchPart({ color }, 'color')}
490
+ />
491
+ {typeof part.art === 'object' && part.art !== null ? (
492
+ <TextField label="Art" value="(per-face art -- edit as JSON)" onChange={() => {}} />
493
+ ) : (
494
+ <TextField label="Art" value={part.art} onChange={(art) => patchPart({ art }, 'art')} />
495
+ )}
496
+ {number('roughness', 'Roughness', 1)}
497
+ {number('metalness', 'Metalness', 1)}
498
+ {number('clearcoat', 'Clearcoat', 1)}
499
+ <ColorField
500
+ label="Emissive"
501
+ value={part.emissive}
502
+ onChange={(emissive) => patchPart({ emissive }, 'emissive')}
503
+ />
504
+ {number('emissiveIntensity', 'Emissive Intensity', 4)}
505
+ </Panel>
506
+ );
507
+ }
508
+
509
+ function PartIdentityFields({ model, part, commitModel, setSelectedPartId }) {
510
+ const excluded = descendantIds(model.parts, part.id);
511
+ const parentOptions = [
512
+ { value: '', label: '(none)' },
513
+ ...model.parts
514
+ .filter((candidate) => !excluded.has(candidate.id))
515
+ .map((candidate) => ({ value: candidate.id, label: candidate.id })),
516
+ ];
517
+ return (
518
+ <Panel title="Part">
519
+ <TextField
520
+ label="Id"
521
+ value={part.id}
522
+ onChange={(nextId) => {
523
+ if (!nextId) return;
524
+ commitModel(
525
+ (next) => {
526
+ const target = next.parts.find((candidate) => candidate.id === part.id);
527
+ if (!target) return;
528
+ const unique = dedupePartId(
529
+ next.parts.filter((candidate) => candidate !== target),
530
+ nextId
531
+ );
532
+ for (const candidate of next.parts) {
533
+ if (candidate.parent === target.id) candidate.parent = unique;
534
+ }
535
+ target.id = unique;
536
+ setSelectedPartId(unique);
537
+ },
538
+ { coalesceKey: `rename:${part.id}` }
539
+ );
540
+ }}
541
+ />
542
+ <SelectField
543
+ label="Parent"
544
+ value={part.parent}
545
+ options={parentOptions}
546
+ onChange={(parent) =>
547
+ commitModel((next) => {
548
+ const target = next.parts.find((candidate) => candidate.id === part.id);
549
+ if (target) target.parent = parent;
550
+ })
551
+ }
552
+ />
553
+ </Panel>
554
+ );
555
+ }
556
+
557
+ function ModelInspectorPane({
558
+ model,
559
+ selectedPartId,
560
+ onSelectPart,
561
+ commitModel,
562
+ setSelectedPartId,
563
+ }) {
564
+ const sheet = useMobileSheet({
565
+ open: true,
566
+ baseClassName: styles.inspector,
567
+ storageKey: 'pxmodel-inspector',
568
+ });
569
+ const part = model.parts.find((candidate) => candidate.id === selectedPartId);
570
+ const patchPart = (patch, coalesceField) =>
571
+ commitModel(
572
+ (next) => {
573
+ const target = next.parts.find((candidate) => candidate.id === selectedPartId);
574
+ if (target) Object.assign(target, patch);
575
+ },
576
+ coalesceField ? { coalesceKey: `part:${selectedPartId}:${coalesceField}` } : undefined
577
+ );
578
+ return (
579
+ <aside {...sheet.rootProps}>
580
+ <div {...sheet.grabProps}>
581
+ <SheetGrabHandle />
582
+ </div>
583
+ <div className={cx(styles.sheetBody, styles.inspectorBody)}>
584
+ <Panel title="Model">
585
+ <TextField
586
+ label="Name"
587
+ value={model.name}
588
+ onChange={(name) =>
589
+ commitModel(
590
+ (next) => {
591
+ next.name = name;
592
+ },
593
+ { coalesceKey: 'model-name' }
594
+ )
595
+ }
596
+ />
597
+ </Panel>
598
+ <PartList model={model} selectedPartId={selectedPartId} onSelectPart={onSelectPart} />
599
+ {part ? (
600
+ <>
601
+ <PartIdentityFields
602
+ model={model}
603
+ part={part}
604
+ commitModel={commitModel}
605
+ setSelectedPartId={setSelectedPartId}
606
+ />
607
+ <PartPlacementFields part={part} patchPart={patchPart} />
608
+ <PartShapeFields part={part} patchPart={patchPart} />
609
+ <PartMaterialFields part={part} patchPart={patchPart} />
610
+ </>
611
+ ) : (
612
+ <div style={{ padding: '12px 16px', fontSize: 13, opacity: 0.7 }}>
613
+ click a part on the stage or in the list to edit it
614
+ </div>
615
+ )}
616
+ </div>
617
+ </aside>
618
+ );
619
+ }