castle-web-cli 0.4.129 → 0.4.131

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 (144) 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 +9 -10
  16. package/kits/physics-2d/castle.json +1 -1
  17. package/kits/physics-2d/editors/PlayOnly.jsx +16 -6
  18. package/kits/physics-2d/editors/PxArtEditor.jsx +95 -31
  19. package/kits/physics-2d/editors/SceneEditor.jsx +10 -10
  20. package/kits/physics-2d/editors/SingleEditor.jsx +37 -8
  21. package/kits/physics-2d/editors/StyleEditor.jsx +7 -4
  22. package/kits/physics-2d/editors/pixelEditorChrome.jsx +7 -7
  23. package/kits/physics-2d/editors/pixelInspector.jsx +13 -7
  24. package/kits/physics-2d/editors/pxArtTimeline.jsx +29 -11
  25. package/kits/physics-2d/editors/pxArtTimeline.module.css +11 -0
  26. package/kits/physics-2d/engine/ScenePlayer.jsx +2 -2
  27. package/kits/physics-2d/engine/blueprint.js +23 -5
  28. package/kits/physics-2d/engine/liveReload.js +33 -21
  29. package/kits/physics-2d/engine/scene.js +17 -5
  30. package/kits/physics-2d/engine/systemRegistry.js +1 -1
  31. package/kits/physics-2d/engine/ui.jsx +2 -2
  32. package/kits/physics-2d/engine/ui.module.css +42 -0
  33. package/kits/physics-3d/CLAUDE.md +108 -0
  34. package/kits/physics-3d/behaviors/Body.jsx +51 -0
  35. package/kits/physics-3d/behaviors/Door.jsx +31 -0
  36. package/kits/physics-3d/behaviors/Lookable.jsx +11 -0
  37. package/kits/physics-3d/behaviors/Model.jsx +14 -0
  38. package/kits/physics-3d/behaviors/Pickup.jsx +39 -0
  39. package/kits/physics-3d/behaviors/Player.jsx +172 -0
  40. package/kits/physics-3d/behaviors/Shape.jsx +75 -0
  41. package/kits/physics-3d/behaviors/Solid.jsx +11 -0
  42. package/kits/physics-3d/behaviors/Transform.jsx +24 -0
  43. package/kits/physics-3d/blueprints/barrel.scene +15 -0
  44. package/kits/physics-3d/blueprints/crate.scene +29 -0
  45. package/kits/physics-3d/blueprints/door.scene +35 -0
  46. package/kits/physics-3d/blueprints/gem.scene +26 -0
  47. package/kits/physics-3d/blueprints/pillar.scene +25 -0
  48. package/kits/physics-3d/blueprints/platform.scene +23 -0
  49. package/kits/physics-3d/blueprints/player.scene +29 -0
  50. package/kits/physics-3d/blueprints/rock.scene +14 -0
  51. package/kits/physics-3d/blueprints/statue.scene +26 -0
  52. package/kits/physics-3d/blueprints/tree.scene +14 -0
  53. package/kits/physics-3d/blueprints/wall.scene +26 -0
  54. package/kits/{basic-2d → physics-3d}/castle.json +23 -5
  55. package/kits/physics-3d/docs/pxmodel-format.md +111 -0
  56. package/kits/physics-3d/drawings/crate.pxart +26 -0
  57. package/kits/physics-3d/drawings/door-edge.pxart +26 -0
  58. package/kits/physics-3d/drawings/door.pxart +27 -0
  59. package/kits/physics-3d/drawings/face.pxart +26 -0
  60. package/kits/physics-3d/drawings/floor.pxart +27 -0
  61. package/kits/physics-3d/drawings/platform.pxart +27 -0
  62. package/kits/physics-3d/drawings/statue.pxart +27 -0
  63. package/kits/physics-3d/drawings/wall.pxart +27 -0
  64. package/kits/physics-3d/engine3d/PlayOnly3D.jsx +36 -0
  65. package/kits/physics-3d/engine3d/PxModelEditor.jsx +619 -0
  66. package/kits/physics-3d/engine3d/Scene3DEditor.jsx +618 -0
  67. package/kits/physics-3d/engine3d/Scene3DPlayer.jsx +309 -0
  68. package/kits/physics-3d/engine3d/editor3dData.js +114 -0
  69. package/kits/physics-3d/engine3d/editor3dInspector.jsx +404 -0
  70. package/kits/physics-3d/engine3d/editorChrome.jsx +218 -0
  71. package/kits/physics-3d/engine3d/editorWorld.js +249 -0
  72. package/kits/physics-3d/engine3d/materials.js +174 -0
  73. package/kits/physics-3d/engine3d/meshops.js +123 -0
  74. package/kits/physics-3d/engine3d/modelEditorWorld.js +439 -0
  75. package/kits/physics-3d/engine3d/modelFiles.js +136 -0
  76. package/kits/physics-3d/engine3d/pxmodel.js +265 -0
  77. package/kits/physics-3d/engine3d/thumbnails.js +137 -0
  78. package/kits/physics-3d/engine3d/world3d.js +216 -0
  79. package/kits/{basic-2d → physics-3d}/eslint.config.js +10 -25
  80. package/kits/physics-3d/index.html +20 -0
  81. package/kits/physics-3d/main.jsx +30 -0
  82. package/kits/physics-3d/models/barrel.pxmodel +10 -0
  83. package/kits/physics-3d/models/player.pxmodel +222 -0
  84. package/kits/physics-3d/models/rock.pxmodel +30 -0
  85. package/kits/physics-3d/models/tree.pxmodel +10 -0
  86. package/kits/{basic-2d → physics-3d}/package-lock.json +140 -119
  87. package/kits/{basic-2d → physics-3d}/package.json +11 -9
  88. package/kits/{basic-2d → physics-3d}/pnpm-lock.yaml +162 -137
  89. package/kits/physics-3d/scenes/main.scene +398 -0
  90. package/kits/physics-3d/scenes/model-lab.scene +111 -0
  91. package/kits/physics-3d/systems/physics3d.js +356 -0
  92. package/package.json +5 -2
  93. package/dist/shell/assets/index-DKu9ejyh.js +0 -436
  94. package/kits/basic-2d/CLAUDE.md +0 -221
  95. package/kits/basic-2d/behaviors/Camera.jsx +0 -43
  96. package/kits/basic-2d/behaviors/Collider.jsx +0 -213
  97. package/kits/basic-2d/behaviors/Layout.jsx +0 -53
  98. package/kits/basic-2d/behaviors/Sprite.jsx +0 -357
  99. package/kits/basic-2d/behaviors/tint.js +0 -47
  100. package/kits/basic-2d/blueprints/cauldron.scene +0 -20
  101. package/kits/basic-2d/docs/pxart-format.md +0 -377
  102. package/kits/basic-2d/drawings/cauldron.pxart +0 -113
  103. package/kits/basic-2d/editors/BlueprintLibrary.jsx +0 -270
  104. package/kits/basic-2d/editors/ErrorBoundary.jsx +0 -59
  105. package/kits/basic-2d/editors/PlayOnly.jsx +0 -31
  106. package/kits/basic-2d/editors/PxArtEditor.jsx +0 -1092
  107. package/kits/basic-2d/editors/SceneEditor.jsx +0 -1780
  108. package/kits/basic-2d/editors/SelectionOverlay.jsx +0 -909
  109. package/kits/basic-2d/editors/SingleEditor.jsx +0 -122
  110. package/kits/basic-2d/editors/behaviorRegistry.js +0 -34
  111. package/kits/basic-2d/editors/editorHistory.js +0 -157
  112. package/kits/basic-2d/editors/inspectorSheet.js +0 -13
  113. package/kits/basic-2d/editors/pixelCanvas.js +0 -11
  114. package/kits/basic-2d/editors/pixelEditorChrome.jsx +0 -74
  115. package/kits/basic-2d/editors/pixelGeometry.js +0 -140
  116. package/kits/basic-2d/editors/pixelInspector.jsx +0 -633
  117. package/kits/basic-2d/editors/pxArtEditorModel.js +0 -732
  118. package/kits/basic-2d/editors/pxArtPlayback.js +0 -92
  119. package/kits/basic-2d/editors/pxArtTimeline.jsx +0 -752
  120. package/kits/basic-2d/editors/pxArtTimeline.module.css +0 -506
  121. package/kits/basic-2d/editors/pxArtTools.js +0 -232
  122. package/kits/basic-2d/editors/useArtboardFit.js +0 -105
  123. package/kits/basic-2d/engine/ScenePlayer.jsx +0 -209
  124. package/kits/basic-2d/engine/SceneUI.jsx +0 -59
  125. package/kits/basic-2d/engine/assets.js +0 -15
  126. package/kits/basic-2d/engine/autoInspector.jsx +0 -70
  127. package/kits/basic-2d/engine/behaviorExtensions.js +0 -32
  128. package/kits/basic-2d/engine/blueprint.js +0 -557
  129. package/kits/basic-2d/engine/collider.js +0 -200
  130. package/kits/basic-2d/engine/files.js +0 -141
  131. package/kits/basic-2d/engine/liveReload.js +0 -88
  132. package/kits/basic-2d/engine/pxart.js +0 -1032
  133. package/kits/basic-2d/engine/pxartSmooth.js +0 -222
  134. package/kits/basic-2d/engine/scene.js +0 -696
  135. package/kits/basic-2d/engine/spriteGeometry.js +0 -32
  136. package/kits/basic-2d/engine/systemRegistry.js +0 -16
  137. package/kits/basic-2d/engine/ui.jsx +0 -695
  138. package/kits/basic-2d/engine/ui.module.css +0 -2287
  139. package/kits/basic-2d/index.html +0 -24
  140. package/kits/basic-2d/main.jsx +0 -24
  141. package/kits/basic-2d/scenes/main.scene +0 -16
  142. package/kits/basic-2d/scripts/draw.mjs +0 -121
  143. /package/kits/{basic-2d → physics-3d}/.prettierrc +0 -0
  144. /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
+ }