castle-web-cli 0.4.129 → 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.
- package/dist/agent-prompts.js +4 -4
- package/dist/filesChanged.d.ts +2 -0
- package/dist/filesChanged.js +6 -2
- package/dist/ide.js +15 -4
- package/dist/importBrowse.js +1 -0
- package/dist/index.js +10 -4
- package/dist/init.js +63 -15
- package/dist/native/loop.js +1 -1
- package/dist/native/tools.js +1 -1
- package/dist/serve.d.ts +1 -0
- package/dist/serve.js +20 -1
- package/dist/shell/assets/index-BpOLUyVO.js +441 -0
- package/dist/shell/assets/{index-CAq6f9B5.css → index-D6K-0YDB.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/CLAUDE.md +9 -10
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/editors/PlayOnly.jsx +15 -6
- package/kits/physics-2d/editors/PxArtEditor.jsx +95 -31
- package/kits/physics-2d/editors/SceneEditor.jsx +5 -5
- package/kits/physics-2d/editors/SingleEditor.jsx +37 -8
- package/kits/physics-2d/editors/StyleEditor.jsx +7 -4
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +7 -7
- package/kits/physics-2d/editors/pixelInspector.jsx +13 -7
- package/kits/physics-2d/editors/pxArtTimeline.jsx +29 -11
- package/kits/physics-2d/editors/pxArtTimeline.module.css +11 -0
- package/kits/physics-2d/engine/blueprint.js +15 -3
- package/kits/physics-2d/engine/liveReload.js +33 -21
- package/kits/physics-2d/engine/systemRegistry.js +1 -1
- package/kits/physics-2d/engine/ui.jsx +2 -2
- package/kits/physics-2d/engine/ui.module.css +42 -0
- package/kits/physics-3d/CLAUDE.md +109 -0
- package/kits/physics-3d/behaviors/Body.jsx +51 -0
- package/kits/physics-3d/behaviors/Door.jsx +31 -0
- package/kits/physics-3d/behaviors/Lookable.jsx +11 -0
- package/kits/physics-3d/behaviors/Model.jsx +14 -0
- package/kits/physics-3d/behaviors/Pickup.jsx +39 -0
- package/kits/physics-3d/behaviors/Player.jsx +172 -0
- package/kits/physics-3d/behaviors/Portal.jsx +19 -0
- package/kits/physics-3d/behaviors/Shape.jsx +75 -0
- package/kits/physics-3d/behaviors/Solid.jsx +11 -0
- package/kits/physics-3d/behaviors/Transform.jsx +24 -0
- package/kits/physics-3d/blueprints/barrel.scene +15 -0
- package/kits/physics-3d/blueprints/crate.scene +29 -0
- package/kits/physics-3d/blueprints/door.scene +35 -0
- package/kits/physics-3d/blueprints/gem.scene +26 -0
- package/kits/physics-3d/blueprints/pillar.scene +25 -0
- package/kits/physics-3d/blueprints/platform.scene +23 -0
- package/kits/physics-3d/blueprints/player.scene +29 -0
- package/kits/physics-3d/blueprints/portal.scene +21 -0
- package/kits/physics-3d/blueprints/rock.scene +14 -0
- package/kits/physics-3d/blueprints/statue.scene +26 -0
- package/kits/physics-3d/blueprints/tree.scene +14 -0
- package/kits/physics-3d/blueprints/wall.scene +26 -0
- package/kits/{basic-2d → physics-3d}/castle.json +23 -5
- package/kits/physics-3d/docs/pxmodel-format.md +111 -0
- package/kits/physics-3d/drawings/crate.pxart +26 -0
- package/kits/physics-3d/drawings/door-edge.pxart +26 -0
- package/kits/physics-3d/drawings/door.pxart +27 -0
- package/kits/physics-3d/drawings/face.pxart +26 -0
- package/kits/physics-3d/drawings/floor.pxart +27 -0
- package/kits/physics-3d/drawings/platform.pxart +27 -0
- package/kits/physics-3d/drawings/statue.pxart +27 -0
- package/kits/physics-3d/drawings/wall.pxart +27 -0
- package/kits/physics-3d/engine3d/PlayOnly3D.jsx +32 -0
- package/kits/physics-3d/engine3d/PxModelEditor.jsx +619 -0
- package/kits/physics-3d/engine3d/Scene3DEditor.jsx +618 -0
- package/kits/physics-3d/engine3d/Scene3DPlayer.jsx +309 -0
- package/kits/physics-3d/engine3d/editor3dData.js +114 -0
- package/kits/physics-3d/engine3d/editor3dInspector.jsx +404 -0
- package/kits/physics-3d/engine3d/editorChrome.jsx +218 -0
- package/kits/physics-3d/engine3d/editorWorld.js +249 -0
- package/kits/physics-3d/engine3d/materials.js +174 -0
- package/kits/physics-3d/engine3d/meshops.js +123 -0
- package/kits/physics-3d/engine3d/modelEditorWorld.js +439 -0
- package/kits/physics-3d/engine3d/modelFiles.js +139 -0
- package/kits/physics-3d/engine3d/pxmodel.js +265 -0
- package/kits/physics-3d/engine3d/thumbnails.js +137 -0
- package/kits/physics-3d/engine3d/world3d.js +216 -0
- package/kits/{basic-2d → physics-3d}/eslint.config.js +10 -25
- package/kits/physics-3d/index.html +20 -0
- package/kits/physics-3d/main.jsx +30 -0
- package/kits/physics-3d/models/barrel.pxmodel +10 -0
- package/kits/physics-3d/models/player.pxmodel +222 -0
- package/kits/physics-3d/models/rock.pxmodel +30 -0
- package/kits/physics-3d/models/tree.pxmodel +10 -0
- package/kits/{basic-2d → physics-3d}/package-lock.json +140 -119
- package/kits/{basic-2d → physics-3d}/package.json +11 -9
- package/kits/{basic-2d → physics-3d}/pnpm-lock.yaml +162 -137
- package/kits/physics-3d/scenes/main.scene +414 -0
- package/kits/physics-3d/scenes/model-lab.scene +127 -0
- package/kits/physics-3d/systems/physics3d.js +356 -0
- package/package.json +5 -2
- package/dist/shell/assets/index-DKu9ejyh.js +0 -436
- package/kits/basic-2d/CLAUDE.md +0 -221
- package/kits/basic-2d/behaviors/Camera.jsx +0 -43
- package/kits/basic-2d/behaviors/Collider.jsx +0 -213
- package/kits/basic-2d/behaviors/Layout.jsx +0 -53
- package/kits/basic-2d/behaviors/Sprite.jsx +0 -357
- package/kits/basic-2d/behaviors/tint.js +0 -47
- package/kits/basic-2d/blueprints/cauldron.scene +0 -20
- package/kits/basic-2d/docs/pxart-format.md +0 -377
- package/kits/basic-2d/drawings/cauldron.pxart +0 -113
- package/kits/basic-2d/editors/BlueprintLibrary.jsx +0 -270
- package/kits/basic-2d/editors/ErrorBoundary.jsx +0 -59
- package/kits/basic-2d/editors/PlayOnly.jsx +0 -31
- package/kits/basic-2d/editors/PxArtEditor.jsx +0 -1092
- package/kits/basic-2d/editors/SceneEditor.jsx +0 -1780
- package/kits/basic-2d/editors/SelectionOverlay.jsx +0 -909
- package/kits/basic-2d/editors/SingleEditor.jsx +0 -122
- package/kits/basic-2d/editors/behaviorRegistry.js +0 -34
- package/kits/basic-2d/editors/editorHistory.js +0 -157
- package/kits/basic-2d/editors/inspectorSheet.js +0 -13
- package/kits/basic-2d/editors/pixelCanvas.js +0 -11
- package/kits/basic-2d/editors/pixelEditorChrome.jsx +0 -74
- package/kits/basic-2d/editors/pixelGeometry.js +0 -140
- package/kits/basic-2d/editors/pixelInspector.jsx +0 -633
- package/kits/basic-2d/editors/pxArtEditorModel.js +0 -732
- package/kits/basic-2d/editors/pxArtPlayback.js +0 -92
- package/kits/basic-2d/editors/pxArtTimeline.jsx +0 -752
- package/kits/basic-2d/editors/pxArtTimeline.module.css +0 -506
- package/kits/basic-2d/editors/pxArtTools.js +0 -232
- package/kits/basic-2d/editors/useArtboardFit.js +0 -105
- package/kits/basic-2d/engine/ScenePlayer.jsx +0 -209
- package/kits/basic-2d/engine/SceneUI.jsx +0 -59
- package/kits/basic-2d/engine/assets.js +0 -15
- package/kits/basic-2d/engine/autoInspector.jsx +0 -70
- package/kits/basic-2d/engine/behaviorExtensions.js +0 -32
- package/kits/basic-2d/engine/blueprint.js +0 -557
- package/kits/basic-2d/engine/collider.js +0 -200
- package/kits/basic-2d/engine/files.js +0 -141
- package/kits/basic-2d/engine/liveReload.js +0 -88
- package/kits/basic-2d/engine/pxart.js +0 -1032
- package/kits/basic-2d/engine/pxartSmooth.js +0 -222
- package/kits/basic-2d/engine/scene.js +0 -696
- package/kits/basic-2d/engine/spriteGeometry.js +0 -32
- package/kits/basic-2d/engine/systemRegistry.js +0 -16
- package/kits/basic-2d/engine/ui.jsx +0 -695
- package/kits/basic-2d/engine/ui.module.css +0 -2287
- package/kits/basic-2d/index.html +0 -24
- package/kits/basic-2d/main.jsx +0 -24
- package/kits/basic-2d/scenes/main.scene +0 -16
- package/kits/basic-2d/scripts/draw.mjs +0 -121
- /package/kits/{basic-2d → physics-3d}/.prettierrc +0 -0
- /package/kits/{basic-2d → physics-3d}/vite.config.js +0 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
// The three.js side of the .pxmodel editor: one model on the world3d stage,
|
|
2
|
+
// orbit camera, part picking, a selection box on the picked part, and
|
|
3
|
+
// move/rotate gizmos that write back part-local placement. The react chrome
|
|
4
|
+
// (PxModelEditor.jsx) owns the file text, part list, and saving.
|
|
5
|
+
import * as THREE from 'three';
|
|
6
|
+
import { OrbitControls, TransformControls } from 'three/examples/jsm/Addons.js';
|
|
7
|
+
import { makeWorld3D, resizeWorld, renderWorld, disposeWorld } from './world3d';
|
|
8
|
+
import { buildModelNode } from './pxmodel';
|
|
9
|
+
import { buildMeshGeometry, faceVertexIndices } from './meshops';
|
|
10
|
+
import { disposeNode } from './materials';
|
|
11
|
+
|
|
12
|
+
const CLICK_SLOP = 5;
|
|
13
|
+
|
|
14
|
+
export function createModelEditorWorld(canvas, callbacks) {
|
|
15
|
+
const world = makeWorld3D(canvas);
|
|
16
|
+
world.camera.position.set(3, 2.4, 3);
|
|
17
|
+
const orbit = new OrbitControls(world.camera, canvas);
|
|
18
|
+
orbit.enableDamping = true;
|
|
19
|
+
orbit.dampingFactor = 0.12;
|
|
20
|
+
orbit.target.set(0, 0.7, 0);
|
|
21
|
+
const gizmo = new TransformControls(world.camera, canvas);
|
|
22
|
+
gizmo.setTranslationSnap(0.01);
|
|
23
|
+
gizmo.setRotationSnap(THREE.MathUtils.degToRad(5));
|
|
24
|
+
world.scene.add(gizmo.getHelper());
|
|
25
|
+
|
|
26
|
+
const med = {
|
|
27
|
+
world,
|
|
28
|
+
orbit,
|
|
29
|
+
gizmo,
|
|
30
|
+
canvas,
|
|
31
|
+
callbacks,
|
|
32
|
+
modelNode: null,
|
|
33
|
+
partGroups: null,
|
|
34
|
+
mode: 'select',
|
|
35
|
+
selectedPartId: null,
|
|
36
|
+
gizmoDragging: false,
|
|
37
|
+
walk: false,
|
|
38
|
+
walkClock: 0,
|
|
39
|
+
box: null,
|
|
40
|
+
raf: 0,
|
|
41
|
+
model: null,
|
|
42
|
+
edit: null, // { partId, mode: 'face'|'vertex', selection: [indices] }
|
|
43
|
+
editVis: null,
|
|
44
|
+
editProxy: null,
|
|
45
|
+
editDrag: null,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
gizmo.addEventListener('dragging-changed', (event) => {
|
|
49
|
+
if (!event.value) med.gizmoDraggedUntil = performance.now() + 150;
|
|
50
|
+
med.gizmoDragging = event.value;
|
|
51
|
+
med.orbit.enabled = !event.value;
|
|
52
|
+
if (med.edit) {
|
|
53
|
+
if (event.value) beginEditDrag(med);
|
|
54
|
+
else endEditDrag(med);
|
|
55
|
+
} else if (!event.value) {
|
|
56
|
+
commitGizmo(med);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
gizmo.addEventListener('objectChange', () => {
|
|
60
|
+
if (med.edit && med.gizmoDragging) applyEditDrag(med);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
let downAt = null;
|
|
64
|
+
canvas.addEventListener('pointerdown', (event) => {
|
|
65
|
+
downAt = { x: event.clientX, y: event.clientY };
|
|
66
|
+
});
|
|
67
|
+
canvas.addEventListener('pointerup', (event) => {
|
|
68
|
+
if (!downAt) return;
|
|
69
|
+
const travel = Math.hypot(event.clientX - downAt.x, event.clientY - downAt.y);
|
|
70
|
+
downAt = null;
|
|
71
|
+
if (travel > CLICK_SLOP || med.gizmoDragging) return;
|
|
72
|
+
if (performance.now() < (med.gizmoDraggedUntil ?? 0)) return;
|
|
73
|
+
if (med.edit) med.callbacks.onEditPick(pickMeshElement(med, event), event.shiftKey);
|
|
74
|
+
else med.callbacks.onPickPart(pickPart(med, event));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
startModelLoop(med);
|
|
78
|
+
return med;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function setEditorModel(med, model, sprites) {
|
|
82
|
+
clearEditVisuals(med);
|
|
83
|
+
if (med.modelNode) {
|
|
84
|
+
med.gizmo.detach();
|
|
85
|
+
med.world.scene.remove(med.modelNode);
|
|
86
|
+
disposeNode(med.modelNode);
|
|
87
|
+
}
|
|
88
|
+
// A private clone: edit-mode drags mutate this copy live (the parse cache
|
|
89
|
+
// shares model objects with the play runtime, so never mutate those).
|
|
90
|
+
med.model = model ? structuredClone(model) : null;
|
|
91
|
+
med.modelNode = med.model ? buildModelNode(med.model, sprites) : null;
|
|
92
|
+
med.partGroups = med.modelNode?.userData.modelParts ?? null;
|
|
93
|
+
if (med.modelNode) med.world.scene.add(med.modelNode);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function disposeModelEditorWorld(med) {
|
|
97
|
+
cancelAnimationFrame(med.raf);
|
|
98
|
+
med.resizeObserver?.disconnect();
|
|
99
|
+
clearSelectionBox(med);
|
|
100
|
+
med.gizmo.detach();
|
|
101
|
+
med.gizmo.dispose();
|
|
102
|
+
med.orbit.dispose();
|
|
103
|
+
if (med.modelNode) disposeNode(med.modelNode);
|
|
104
|
+
disposeWorld(med.world);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function screenRaycaster(med, event) {
|
|
108
|
+
const rect = med.canvas.getBoundingClientRect();
|
|
109
|
+
const ndc = new THREE.Vector2(
|
|
110
|
+
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
|
111
|
+
-((event.clientY - rect.top) / rect.height) * 2 + 1
|
|
112
|
+
);
|
|
113
|
+
const raycaster = new THREE.Raycaster();
|
|
114
|
+
raycaster.setFromCamera(ndc, med.world.camera);
|
|
115
|
+
return raycaster;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function pickPart(med, event) {
|
|
119
|
+
if (!med.modelNode) return null;
|
|
120
|
+
const hits = screenRaycaster(med, event).intersectObject(med.modelNode, true);
|
|
121
|
+
for (const hit of hits) {
|
|
122
|
+
let object = hit.object;
|
|
123
|
+
while (object && object.userData.partId === undefined) object = object.parent;
|
|
124
|
+
if (object?.userData.partId) return object.userData.partId;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function commitGizmo(med) {
|
|
130
|
+
const group = med.gizmo.object;
|
|
131
|
+
if (!group || !group.userData.partId) return;
|
|
132
|
+
const r = (v) => Math.round(v * 100) / 100;
|
|
133
|
+
const deg = (v) => Math.round(THREE.MathUtils.radToDeg(v) * 10) / 10;
|
|
134
|
+
med.callbacks.onCommitPart(group.userData.partId, {
|
|
135
|
+
position: [r(group.position.x), r(group.position.y), r(group.position.z)],
|
|
136
|
+
rotation: [deg(group.rotation.x), deg(group.rotation.y), deg(group.rotation.z)],
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function clearSelectionBox(med) {
|
|
141
|
+
if (!med.box) return;
|
|
142
|
+
med.world.scene.remove(med.box.helper);
|
|
143
|
+
med.box.helper.dispose();
|
|
144
|
+
med.box = null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function syncSelection(med) {
|
|
148
|
+
if (med.edit) {
|
|
149
|
+
clearSelectionBox(med);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const group = med.selectedPartId ? (med.partGroups?.get(med.selectedPartId) ?? null) : null;
|
|
153
|
+
if (!group || med.box?.group !== group) clearSelectionBox(med);
|
|
154
|
+
if (group && !med.box) {
|
|
155
|
+
med.box = { helper: new THREE.BoxHelper(group, 0xffffff), group };
|
|
156
|
+
med.world.scene.add(med.box.helper);
|
|
157
|
+
}
|
|
158
|
+
med.box?.helper.update();
|
|
159
|
+
const wantGizmo = group && (med.mode === 'move' || med.mode === 'rotate');
|
|
160
|
+
if (wantGizmo) {
|
|
161
|
+
med.gizmo.mode = med.mode === 'move' ? 'translate' : 'rotate';
|
|
162
|
+
if (med.gizmo.object !== group) med.gizmo.attach(group);
|
|
163
|
+
} else if (med.gizmo.object) {
|
|
164
|
+
med.gizmo.detach();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// A canned stride so the walk toggle can preview the animation conventions
|
|
169
|
+
// without anything actually moving.
|
|
170
|
+
function previewWalk(med, dt) {
|
|
171
|
+
if (!med.walk || !med.partGroups) return;
|
|
172
|
+
med.walkClock += dt;
|
|
173
|
+
const swing = Math.sin(med.walkClock * 7) * 0.6;
|
|
174
|
+
const set = (id, rx) => {
|
|
175
|
+
const group = med.partGroups.get(id);
|
|
176
|
+
if (group) group.rotation.x = rx;
|
|
177
|
+
};
|
|
178
|
+
set('legL', swing);
|
|
179
|
+
set('legR', -swing);
|
|
180
|
+
set('armL', -swing * 0.75);
|
|
181
|
+
set('armR', swing * 0.75);
|
|
182
|
+
const torso = med.partGroups.get('torso');
|
|
183
|
+
if (torso) {
|
|
184
|
+
const base = (torso.userData.editBaseY ??= torso.position.y);
|
|
185
|
+
torso.position.y = base + Math.abs(Math.sin(med.walkClock * 7)) * 0.05;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function startModelLoop(med) {
|
|
190
|
+
const fit = () => resizeWorld(med.world, med.canvas.clientWidth, med.canvas.clientHeight);
|
|
191
|
+
med.resizeObserver = new ResizeObserver(fit);
|
|
192
|
+
med.resizeObserver.observe(med.canvas);
|
|
193
|
+
fit();
|
|
194
|
+
let previous = performance.now();
|
|
195
|
+
const tick = (now) => {
|
|
196
|
+
const dt = Math.min(0.033, (now - previous) / 1000);
|
|
197
|
+
previous = now;
|
|
198
|
+
med.orbit.update();
|
|
199
|
+
previewWalk(med, dt);
|
|
200
|
+
syncSelection(med);
|
|
201
|
+
syncMeshEdit(med);
|
|
202
|
+
renderWorld(med.world);
|
|
203
|
+
med.raf = requestAnimationFrame(tick);
|
|
204
|
+
};
|
|
205
|
+
med.raf = requestAnimationFrame(tick);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
//
|
|
209
|
+
// Mesh edit mode
|
|
210
|
+
//
|
|
211
|
+
|
|
212
|
+
function editContext(med) {
|
|
213
|
+
const edit = med.edit;
|
|
214
|
+
const part = edit ? med.model?.parts.find((candidate) => candidate.id === edit.partId) : null;
|
|
215
|
+
const group = edit ? (med.partGroups?.get(edit.partId) ?? null) : null;
|
|
216
|
+
const meshObj = group?.children.find((child) => child.isMesh) ?? null;
|
|
217
|
+
if (!edit || !part || part.kind !== 'mesh' || !group || !meshObj) return null;
|
|
218
|
+
return { edit, part, group, meshObj };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function pickMeshElement(med, event) {
|
|
222
|
+
const ctx = editContext(med);
|
|
223
|
+
if (!ctx) return null;
|
|
224
|
+
const rect = med.canvas.getBoundingClientRect();
|
|
225
|
+
if (ctx.edit.mode === 'face') {
|
|
226
|
+
const hit = screenRaycaster(med, event).intersectObject(ctx.meshObj, false)[0];
|
|
227
|
+
if (!hit || hit.faceIndex === undefined) return null;
|
|
228
|
+
return ctx.meshObj.userData.triFaces?.[hit.faceIndex] ?? null;
|
|
229
|
+
}
|
|
230
|
+
// Vertex mode: nearest projected vertex within a small screen radius.
|
|
231
|
+
const world = new THREE.Vector3();
|
|
232
|
+
let best = null;
|
|
233
|
+
let bestDist = 14;
|
|
234
|
+
for (let index = 0; index < ctx.part.vertices.length; index++) {
|
|
235
|
+
const v = ctx.part.vertices[index];
|
|
236
|
+
world
|
|
237
|
+
.set(v[0] + ctx.part.offset[0], v[1] + ctx.part.offset[1], v[2] + ctx.part.offset[2])
|
|
238
|
+
.applyMatrix4(ctx.group.matrixWorld)
|
|
239
|
+
.project(med.world.camera);
|
|
240
|
+
const sx = ((world.x + 1) / 2) * rect.width;
|
|
241
|
+
const sy = ((1 - world.y) / 2) * rect.height;
|
|
242
|
+
const dist = Math.hypot(sx - (event.clientX - rect.left), sy - (event.clientY - rect.top));
|
|
243
|
+
if (dist < bestDist) {
|
|
244
|
+
bestDist = dist;
|
|
245
|
+
best = index;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return best;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function selectedVertexIndices(ctx) {
|
|
252
|
+
return ctx.edit.mode === 'face'
|
|
253
|
+
? faceVertexIndices(ctx.part, ctx.edit.selection)
|
|
254
|
+
: ctx.edit.selection;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function beginEditDrag(med) {
|
|
258
|
+
const ctx = editContext(med);
|
|
259
|
+
if (!ctx || !med.editProxy) return;
|
|
260
|
+
med.editDrag = {
|
|
261
|
+
start: med.editProxy.position.clone(),
|
|
262
|
+
vertices: structuredClone(ctx.part.vertices),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function applyEditDrag(med) {
|
|
267
|
+
const ctx = editContext(med);
|
|
268
|
+
if (!ctx || !med.editDrag || !med.editProxy) return;
|
|
269
|
+
const delta = med.editProxy.position.clone().sub(med.editDrag.start);
|
|
270
|
+
for (const index of selectedVertexIndices(ctx)) {
|
|
271
|
+
const v = med.editDrag.vertices[index];
|
|
272
|
+
if (v) ctx.part.vertices[index] = [v[0] + delta.x, v[1] + delta.y, v[2] + delta.z];
|
|
273
|
+
}
|
|
274
|
+
rebuildEditGeometry(med, ctx);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function endEditDrag(med) {
|
|
278
|
+
const ctx = editContext(med);
|
|
279
|
+
const drag = med.editDrag;
|
|
280
|
+
med.editDrag = null;
|
|
281
|
+
if (!ctx || !drag) return;
|
|
282
|
+
const round3 = (v) => Math.round(v * 1000) / 1000;
|
|
283
|
+
med.callbacks.onCommitMeshVertices(
|
|
284
|
+
ctx.edit.partId,
|
|
285
|
+
ctx.part.vertices.map((v) => v.map(round3))
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function rebuildEditGeometry(med, ctx) {
|
|
290
|
+
const { geometry, triFaces } = buildMeshGeometry(ctx.part);
|
|
291
|
+
ctx.meshObj.geometry.dispose();
|
|
292
|
+
ctx.meshObj.geometry = geometry;
|
|
293
|
+
ctx.meshObj.userData.triFaces = triFaces;
|
|
294
|
+
refreshEditVisuals(med, ctx, true);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function disposeEditVisObjects(med) {
|
|
298
|
+
if (!med.editVis) return;
|
|
299
|
+
for (const object of Object.values(med.editVis.objects)) {
|
|
300
|
+
object.parent?.remove(object);
|
|
301
|
+
object.geometry?.dispose();
|
|
302
|
+
object.material?.dispose();
|
|
303
|
+
}
|
|
304
|
+
med.editVis = null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function clearEditVisuals(med) {
|
|
308
|
+
disposeEditVisObjects(med);
|
|
309
|
+
if (med.editProxy) {
|
|
310
|
+
if (med.gizmo.object === med.editProxy) med.gizmo.detach();
|
|
311
|
+
med.editProxy.parent?.remove(med.editProxy);
|
|
312
|
+
med.editProxy = null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function editVisSignature(ctx) {
|
|
317
|
+
return JSON.stringify([ctx.edit.mode, ctx.edit.selection, ctx.part.vertices.length]);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Per-frame edit overlays: vertex dots, selected-face tint, and the translate
|
|
321
|
+
// proxy the gizmo drives. Rebuilt only when the selection/mesh changes.
|
|
322
|
+
function syncMeshEdit(med) {
|
|
323
|
+
const ctx = editContext(med);
|
|
324
|
+
if (!ctx) {
|
|
325
|
+
clearEditVisuals(med);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const sig = editVisSignature(ctx);
|
|
329
|
+
if (med.editVis?.sig !== sig && !med.gizmoDragging) {
|
|
330
|
+
clearEditVisuals(med);
|
|
331
|
+
med.editVis = { sig, objects: buildEditVisuals(med, ctx) };
|
|
332
|
+
syncEditProxy(med, ctx);
|
|
333
|
+
}
|
|
334
|
+
const wantGizmo = (med.mode === 'move' || med.mode === 'rotate') && ctx.edit.selection.length > 0;
|
|
335
|
+
if (wantGizmo && med.editProxy) {
|
|
336
|
+
med.gizmo.mode = 'translate';
|
|
337
|
+
if (med.gizmo.object !== med.editProxy) med.gizmo.attach(med.editProxy);
|
|
338
|
+
} else if (med.gizmo.object && !med.gizmoDragging) {
|
|
339
|
+
med.gizmo.detach();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function refreshEditVisuals(med, ctx, keepProxy) {
|
|
344
|
+
disposeEditVisObjects(med);
|
|
345
|
+
med.editVis = { sig: editVisSignature(ctx), objects: buildEditVisuals(med, ctx) };
|
|
346
|
+
if (!keepProxy) syncEditProxy(med, ctx);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function syncEditProxy(med, ctx) {
|
|
350
|
+
if (!med.editProxy) {
|
|
351
|
+
med.editProxy = new THREE.Object3D();
|
|
352
|
+
ctx.group.add(med.editProxy);
|
|
353
|
+
} else if (med.editProxy.parent !== ctx.group) {
|
|
354
|
+
med.editProxy.parent?.remove(med.editProxy);
|
|
355
|
+
ctx.group.add(med.editProxy);
|
|
356
|
+
}
|
|
357
|
+
const indices = selectedVertexIndices(ctx);
|
|
358
|
+
if (indices.length === 0) return;
|
|
359
|
+
const c = [0, 0, 0];
|
|
360
|
+
for (const index of indices) {
|
|
361
|
+
const v = ctx.part.vertices[index] ?? [0, 0, 0];
|
|
362
|
+
c[0] += v[0];
|
|
363
|
+
c[1] += v[1];
|
|
364
|
+
c[2] += v[2];
|
|
365
|
+
}
|
|
366
|
+
med.editProxy.position.set(
|
|
367
|
+
c[0] / indices.length + ctx.part.offset[0],
|
|
368
|
+
c[1] / indices.length + ctx.part.offset[1],
|
|
369
|
+
c[2] / indices.length + ctx.part.offset[2]
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function buildEditVisuals(med, ctx) {
|
|
374
|
+
const objects = {};
|
|
375
|
+
const offset = ctx.part.offset;
|
|
376
|
+
const positions = [];
|
|
377
|
+
for (const v of ctx.part.vertices) {
|
|
378
|
+
positions.push(v[0] + offset[0], v[1] + offset[1], v[2] + offset[2]);
|
|
379
|
+
}
|
|
380
|
+
const allGeometry = new THREE.BufferGeometry();
|
|
381
|
+
allGeometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
382
|
+
objects.points = new THREE.Points(
|
|
383
|
+
allGeometry,
|
|
384
|
+
new THREE.PointsMaterial({ color: 0x8a92a8, size: 6, sizeAttenuation: false, depthTest: false })
|
|
385
|
+
);
|
|
386
|
+
objects.points.renderOrder = 10;
|
|
387
|
+
ctx.group.add(objects.points);
|
|
388
|
+
|
|
389
|
+
const selectedVerts = selectedVertexIndices(ctx);
|
|
390
|
+
if (selectedVerts.length) {
|
|
391
|
+
const selPositions = [];
|
|
392
|
+
for (const index of selectedVerts) {
|
|
393
|
+
const v = ctx.part.vertices[index] ?? [0, 0, 0];
|
|
394
|
+
selPositions.push(v[0] + offset[0], v[1] + offset[1], v[2] + offset[2]);
|
|
395
|
+
}
|
|
396
|
+
const selGeometry = new THREE.BufferGeometry();
|
|
397
|
+
selGeometry.setAttribute('position', new THREE.Float32BufferAttribute(selPositions, 3));
|
|
398
|
+
objects.selectedPoints = new THREE.Points(
|
|
399
|
+
selGeometry,
|
|
400
|
+
new THREE.PointsMaterial({
|
|
401
|
+
color: 0xffffff,
|
|
402
|
+
size: 10,
|
|
403
|
+
sizeAttenuation: false,
|
|
404
|
+
depthTest: false,
|
|
405
|
+
})
|
|
406
|
+
);
|
|
407
|
+
objects.selectedPoints.renderOrder = 11;
|
|
408
|
+
ctx.group.add(objects.selectedPoints);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (ctx.edit.mode === 'face' && ctx.edit.selection.length) {
|
|
412
|
+
const facePositions = [];
|
|
413
|
+
for (const faceIndex of ctx.edit.selection) {
|
|
414
|
+
const face = ctx.part.faces[faceIndex];
|
|
415
|
+
if (!face) continue;
|
|
416
|
+
for (let i = 1; i + 1 < face.length; i++) {
|
|
417
|
+
for (const index of [face[0], face[i], face[i + 1]]) {
|
|
418
|
+
const v = ctx.part.vertices[index] ?? [0, 0, 0];
|
|
419
|
+
facePositions.push(v[0] + offset[0], v[1] + offset[1], v[2] + offset[2]);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const faceGeometry = new THREE.BufferGeometry();
|
|
424
|
+
faceGeometry.setAttribute('position', new THREE.Float32BufferAttribute(facePositions, 3));
|
|
425
|
+
objects.faceOverlay = new THREE.Mesh(
|
|
426
|
+
faceGeometry,
|
|
427
|
+
new THREE.MeshBasicMaterial({
|
|
428
|
+
color: 0xffe066,
|
|
429
|
+
transparent: true,
|
|
430
|
+
opacity: 0.3,
|
|
431
|
+
side: THREE.DoubleSide,
|
|
432
|
+
depthTest: false,
|
|
433
|
+
})
|
|
434
|
+
);
|
|
435
|
+
objects.faceOverlay.renderOrder = 9;
|
|
436
|
+
ctx.group.add(objects.faceOverlay);
|
|
437
|
+
}
|
|
438
|
+
return objects;
|
|
439
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// `.pxmodel` files in the live deck file map. The imported engine's file
|
|
2
|
+
// globs are compile-time literals that don't know this kit's model format, so
|
|
3
|
+
// this module (a) adds its own glob so models are seeded at build time and
|
|
4
|
+
// inlined into the published bundle, and (b) forks the imported
|
|
5
|
+
// useLiveDeckFiles with `.pxmodel` treated as a DATA file -- edited live over
|
|
6
|
+
// the files API instead of forcing a context reload (the glob puts models in
|
|
7
|
+
// vite's module graph, which the imported hook would read as "code changed").
|
|
8
|
+
import { useEffect, useRef, useState } from 'react';
|
|
9
|
+
import { onFilesChanged, requestReload } from 'castle-web-sdk';
|
|
10
|
+
import { initialFiles, initialMedia, isImageFile } from '@imports/castle.physics-2d/engine/files';
|
|
11
|
+
import { parseFull } from '@imports/castle.physics-2d/engine/pxart';
|
|
12
|
+
import { imageArt } from '@imports/castle.physics-2d/engine/art';
|
|
13
|
+
|
|
14
|
+
// Both the deck's own models and every import's -- same shape as the imported
|
|
15
|
+
// engine's own file globs: when this kit is itself an import, its models live
|
|
16
|
+
// under imports/<alias>/models/ in the deck being served, and a map without
|
|
17
|
+
// them can't resolve a kit blueprint's Model ref.
|
|
18
|
+
const modelModules = import.meta.glob(['/models/**/*.pxmodel', '/imports/*/models/**/*.pxmodel'], {
|
|
19
|
+
query: '?raw',
|
|
20
|
+
import: 'default',
|
|
21
|
+
eager: true,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const initialFiles3D = {
|
|
25
|
+
...initialFiles,
|
|
26
|
+
...Object.fromEntries(
|
|
27
|
+
Object.entries(modelModules).map(([globPath, text]) => [globPath.replace(/^\//, ''), text])
|
|
28
|
+
),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const DATA_EXTS = ['.scene', '.pxart', '.drawing', '.pxmodel'];
|
|
32
|
+
const CODE_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.css'];
|
|
33
|
+
|
|
34
|
+
export function isDataFile3D(path) {
|
|
35
|
+
return DATA_EXTS.some((ext) => path.endsWith(ext));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function needsContextReload(change) {
|
|
39
|
+
if (isDataFile3D(change.path)) return false;
|
|
40
|
+
if (change.path.endsWith('.html')) return true;
|
|
41
|
+
if (change.affected.length > 0) return true;
|
|
42
|
+
if (change.event !== 'change') return CODE_EXTS.some((ext) => change.path.endsWith(ext));
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readDeckFile(path) {
|
|
47
|
+
const res = await fetch(`/__castle/files/read?path=${encodeURIComponent(path)}`);
|
|
48
|
+
if (!res.ok) throw new Error(`read failed: ${res.status}`);
|
|
49
|
+
const data = await res.json();
|
|
50
|
+
return typeof data.contents === 'string' ? data.contents : '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function applyDataChanges(changes, setFiles, setDataVersion) {
|
|
54
|
+
const updates = await Promise.all(
|
|
55
|
+
changes.map(async (change) => {
|
|
56
|
+
if (change.event === 'delete') return { path: change.path, text: null };
|
|
57
|
+
try {
|
|
58
|
+
return { path: change.path, text: await readDeckFile(change.path) };
|
|
59
|
+
} catch {
|
|
60
|
+
return null; // read raced a delete / serve hiccup -- skip this path
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
);
|
|
64
|
+
const applied = updates.filter(Boolean);
|
|
65
|
+
if (applied.length === 0) return;
|
|
66
|
+
setFiles((current) => {
|
|
67
|
+
const next = { ...current };
|
|
68
|
+
for (const { path, text } of applied) {
|
|
69
|
+
if (text === null) delete next[path];
|
|
70
|
+
else next[path] = text;
|
|
71
|
+
}
|
|
72
|
+
return next;
|
|
73
|
+
});
|
|
74
|
+
setDataVersion((version) => version + 1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Live deck files including models. Same contract as the imported
|
|
78
|
+
// useLiveDeckFiles: `shouldSkipPath` keeps an editor's in-flight writes from
|
|
79
|
+
// being clobbered by their own fs echo; `dataVersion` bumps on applied data
|
|
80
|
+
// changes.
|
|
81
|
+
export function useLiveDeckFiles3D({ shouldSkipPath } = {}) {
|
|
82
|
+
const [files, setFiles] = useState(initialFiles3D);
|
|
83
|
+
const [dataVersion, setDataVersion] = useState(0);
|
|
84
|
+
const skipRef = useRef(shouldSkipPath);
|
|
85
|
+
skipRef.current = shouldSkipPath;
|
|
86
|
+
|
|
87
|
+
useEffect(
|
|
88
|
+
() =>
|
|
89
|
+
onFilesChanged((event) => {
|
|
90
|
+
if (event.changes.some(needsContextReload)) {
|
|
91
|
+
requestReload();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const dataChanges = event.changes.filter(
|
|
95
|
+
(change) => isDataFile3D(change.path) && !skipRef.current?.(change.path)
|
|
96
|
+
);
|
|
97
|
+
if (dataChanges.length === 0) return;
|
|
98
|
+
void applyDataChanges(dataChanges, setFiles, setDataVersion);
|
|
99
|
+
}),
|
|
100
|
+
[]
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return { files, setFiles, dataVersion };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The sprites map with parsed pxarts CACHED BY TEXT: re-parsing every file on
|
|
107
|
+
// every edit would mint new art objects, changing every node signature and
|
|
108
|
+
// rebuilding the whole three scene per keystroke. Only a pxart whose text
|
|
109
|
+
// actually changed re-parses. Shared by the scene and model editors.
|
|
110
|
+
export function useStableSprites(files) {
|
|
111
|
+
const cacheRef = useRef(new Map());
|
|
112
|
+
const sprites = {};
|
|
113
|
+
for (const [path, url] of Object.entries(initialMedia)) {
|
|
114
|
+
if (isImageFile(path)) sprites[path] = imageArt(path, url);
|
|
115
|
+
}
|
|
116
|
+
for (const [path, text] of Object.entries(files)) {
|
|
117
|
+
if (!path.endsWith('.pxart')) continue;
|
|
118
|
+
let cached = cacheRef.current.get(path);
|
|
119
|
+
if (!cached || cached.text !== text) {
|
|
120
|
+
const art = parseFull(text);
|
|
121
|
+
if (!art) continue;
|
|
122
|
+
cached = { text, art };
|
|
123
|
+
cacheRef.current.set(path, cached);
|
|
124
|
+
}
|
|
125
|
+
sprites[path] = cached.art;
|
|
126
|
+
}
|
|
127
|
+
return sprites;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// One-shot fetch for a path the build-time glob missed (a model created after
|
|
131
|
+
// the last reload); folds it into files state.
|
|
132
|
+
export async function fetchMissingFile(path, setFiles) {
|
|
133
|
+
try {
|
|
134
|
+
const text = await readDeckFile(path);
|
|
135
|
+
setFiles((current) => (current[path] === undefined ? { ...current, [path]: text } : current));
|
|
136
|
+
} catch {
|
|
137
|
+
// stays missing; the editor shows its error state
|
|
138
|
+
}
|
|
139
|
+
}
|