@umicat/three-sdk 0.17.1 → 0.17.3
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.
|
@@ -16,10 +16,20 @@ export interface EditorDesignPlayer3DOptions {
|
|
|
16
16
|
* The 3D counterpart of `@umicat/phaser-sdk`'s `EditorDesignScene` (ADR-021)
|
|
17
17
|
* — renders a scene's AUTHORED data with no game code and no save, so the
|
|
18
18
|
* platform's Edit tab can show a 3D game's layout the same way it already
|
|
19
|
-
* does for 2D.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* does for 2D.
|
|
20
|
+
*
|
|
21
|
+
* Two interactions:
|
|
22
|
+
* - **Click** an entity to select it — a bounding box frames it and a
|
|
23
|
+
* marker reads off the exact world coordinate, posted to the host so a
|
|
24
|
+
* person can point at a real spot instead of guessing one blind when
|
|
25
|
+
* telling the AI where to place something.
|
|
26
|
+
* - **Drag** an entity to move it (Phase 1 of real editing, ground-plane
|
|
27
|
+
* constrained — position only, no rotation/scale, no gizmo, no
|
|
28
|
+
* Inspector). This function itself never writes anything — it only
|
|
29
|
+
* moves the live THREE.Object3D and posts the final LOCAL position on
|
|
30
|
+
* release (`umicat:editor:dragEnd3d`); the host owns turning that into a
|
|
31
|
+
* disk write. Grabbing empty space/sky orbits instead, same convention
|
|
32
|
+
* as Unity/Blender.
|
|
23
33
|
*
|
|
24
34
|
* Takes over `renderer`'s animation loop entirely for the rest of the page's
|
|
25
35
|
* life — there is no scene-swap API and no teardown, matching how the
|
|
@@ -5,10 +5,20 @@ import { loadScene3D } from '../SceneLoader3D.js';
|
|
|
5
5
|
* The 3D counterpart of `@umicat/phaser-sdk`'s `EditorDesignScene` (ADR-021)
|
|
6
6
|
* — renders a scene's AUTHORED data with no game code and no save, so the
|
|
7
7
|
* platform's Edit tab can show a 3D game's layout the same way it already
|
|
8
|
-
* does for 2D.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* does for 2D.
|
|
9
|
+
*
|
|
10
|
+
* Two interactions:
|
|
11
|
+
* - **Click** an entity to select it — a bounding box frames it and a
|
|
12
|
+
* marker reads off the exact world coordinate, posted to the host so a
|
|
13
|
+
* person can point at a real spot instead of guessing one blind when
|
|
14
|
+
* telling the AI where to place something.
|
|
15
|
+
* - **Drag** an entity to move it (Phase 1 of real editing, ground-plane
|
|
16
|
+
* constrained — position only, no rotation/scale, no gizmo, no
|
|
17
|
+
* Inspector). This function itself never writes anything — it only
|
|
18
|
+
* moves the live THREE.Object3D and posts the final LOCAL position on
|
|
19
|
+
* release (`umicat:editor:dragEnd3d`); the host owns turning that into a
|
|
20
|
+
* disk write. Grabbing empty space/sky orbits instead, same convention
|
|
21
|
+
* as Unity/Blender.
|
|
12
22
|
*
|
|
13
23
|
* Takes over `renderer`'s animation loop entirely for the rest of the page's
|
|
14
24
|
* life — there is no scene-swap API and no teardown, matching how the
|
|
@@ -25,10 +35,39 @@ export async function runEditorDesignPlayer3D(renderer, opts = {}) {
|
|
|
25
35
|
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.05, 2000);
|
|
26
36
|
const controls = new OrbitControls(camera, renderer.domElement);
|
|
27
37
|
controls.enableDamping = true;
|
|
28
|
-
// A single persistent marker, repositioned per click
|
|
38
|
+
// A single persistent marker, repositioned per click — the exact world
|
|
39
|
+
// point clicked (what "put a torch at the spot I clicked" needs), distinct
|
|
40
|
+
// from the box below (which entity was hit, not just where).
|
|
29
41
|
const marker = new THREE.Mesh(new THREE.SphereGeometry(0.12, 16, 12), new THREE.MeshBasicMaterial({ color: '#ff3b6b', depthTest: false }));
|
|
30
42
|
marker.visible = false;
|
|
31
43
|
marker.renderOrder = 999;
|
|
44
|
+
// Selection box — Unity/Blender-style "frame the object you clicked", not
|
|
45
|
+
// just a point on its surface. `BoxHelper.setFromObject` (three.js core,
|
|
46
|
+
// no custom geometry math needed) recomputes a world-space AABB wireframe
|
|
47
|
+
// from scratch each call, so retargeting it to a different entity per
|
|
48
|
+
// click is just calling it again — it does not need to track a moving
|
|
49
|
+
// object (this scene never animates in design mode).
|
|
50
|
+
const selectionBox = new THREE.BoxHelper(new THREE.Object3D(), 0xffd23f);
|
|
51
|
+
selectionBox.visible = false;
|
|
52
|
+
selectionBox.renderOrder = 998;
|
|
53
|
+
selectionBox.material.depthTest = false;
|
|
54
|
+
// Orientation gizmo — a small corner compass tracking the main camera's
|
|
55
|
+
// rotation only (never its position/zoom), same convention every 3D DCC
|
|
56
|
+
// uses: X=red, Y=green, Z=blue. Rendered as a second, tiny scene into a
|
|
57
|
+
// scissored corner viewport each frame (see the render loop below) — the
|
|
58
|
+
// simplest way to get a HUD-anchored 3D readout without a second canvas.
|
|
59
|
+
const GIZMO_SIZE = 84;
|
|
60
|
+
const GIZMO_MARGIN = 14;
|
|
61
|
+
const gizmoScene = new THREE.Scene();
|
|
62
|
+
const gizmoCamera = new THREE.OrthographicCamera(-1.6, 1.6, 1.6, -1.6, 0.1, 10);
|
|
63
|
+
const AXES = [
|
|
64
|
+
[new THREE.Vector3(1, 0, 0), 0xff4d4d], // X — red
|
|
65
|
+
[new THREE.Vector3(0, 1, 0), 0x4dff88], // Y — green
|
|
66
|
+
[new THREE.Vector3(0, 0, 1), 0x4d9fff], // Z — blue
|
|
67
|
+
];
|
|
68
|
+
for (const [dir, color] of AXES) {
|
|
69
|
+
gizmoScene.add(new THREE.ArrowHelper(dir, new THREE.Vector3(0, 0, 0), 1, color, 0.35, 0.22));
|
|
70
|
+
}
|
|
32
71
|
let scene = null;
|
|
33
72
|
const entityByObject = new Map();
|
|
34
73
|
const resize = () => {
|
|
@@ -38,51 +77,143 @@ export async function runEditorDesignPlayer3D(renderer, opts = {}) {
|
|
|
38
77
|
};
|
|
39
78
|
resize();
|
|
40
79
|
window.addEventListener('resize', resize);
|
|
80
|
+
const raycaster = new THREE.Raycaster();
|
|
81
|
+
const ndcFromEvent = (e) => {
|
|
82
|
+
const rect = renderer.domElement.getBoundingClientRect();
|
|
83
|
+
return new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
|
|
84
|
+
};
|
|
85
|
+
// Only VISIBLE entity roots are ever raycast against. three.js's
|
|
86
|
+
// Mesh.raycast does NOT check `.visible` on its own (confirmed against the
|
|
87
|
+
// pinned three source — no such check in Raycaster.js or Object3D.raycast),
|
|
88
|
+
// and a real scene can ship an invisible collision box alongside
|
|
89
|
+
// separately-placed visible ground models — without this filter, a click
|
|
90
|
+
// that visibly lands on the rendered ground can silently return the
|
|
91
|
+
// collider's coordinates instead.
|
|
92
|
+
const pickEntityAt = (e) => {
|
|
93
|
+
raycaster.setFromCamera(ndcFromEvent(e), camera);
|
|
94
|
+
const targets = Array.from(entityByObject.keys()).filter((o) => o.visible !== false);
|
|
95
|
+
const hits = raycaster.intersectObjects(targets, true);
|
|
96
|
+
if (hits.length === 0)
|
|
97
|
+
return null;
|
|
98
|
+
let node = hits[0].object;
|
|
99
|
+
while (node) {
|
|
100
|
+
const id = entityByObject.get(node);
|
|
101
|
+
if (id)
|
|
102
|
+
return { entityId: id, entityRoot: node, point: hits[0].point };
|
|
103
|
+
node = node.parent;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
};
|
|
41
107
|
// Orbit-vs-click: a real drag moves the pointer more than a few px.
|
|
42
108
|
let downX = 0;
|
|
43
109
|
let downY = 0;
|
|
44
110
|
let moved = false;
|
|
111
|
+
// Entity drag — Phase 1 of real editing (translate only, ground-plane
|
|
112
|
+
// constrained). Grabbing on an entity moves it; grabbing empty space/sky
|
|
113
|
+
// still orbits, same convention as Unity/Blender. Whether THIS gesture is
|
|
114
|
+
// a drag is decided synchronously in pointerdown (a raycast hit or not) —
|
|
115
|
+
// `OrbitControls`'s own pointerdown listener runs first (registered
|
|
116
|
+
// earlier) and always starts its internal drag bookkeeping, but its
|
|
117
|
+
// `onPointerMove`/related handlers re-check `this.enabled` on every event
|
|
118
|
+
// rather than only at attach time (confirmed against the pinned
|
|
119
|
+
// OrbitControls source), so disabling it HERE still correctly suppresses
|
|
120
|
+
// every subsequent move for this gesture; its `onPointerUp` cleanup does
|
|
121
|
+
// not check `enabled` at all, so re-enabling afterward never leaves it
|
|
122
|
+
// stuck. No drag state survives a scene reload — it's cleared on release,
|
|
123
|
+
// and a scene swap only ever happens via a full iframe reload anyway.
|
|
124
|
+
let dragEntity = null;
|
|
125
|
+
let dragEntityId = null;
|
|
126
|
+
let dragPlane = null;
|
|
127
|
+
let dragGrabOffset = null; // world-space, plane-point minus object position at grab time
|
|
45
128
|
const onPointerDown = (e) => {
|
|
46
129
|
downX = e.clientX;
|
|
47
130
|
downY = e.clientY;
|
|
48
131
|
moved = false;
|
|
132
|
+
if (!scene)
|
|
133
|
+
return;
|
|
134
|
+
const hit = pickEntityAt(e);
|
|
135
|
+
if (!hit)
|
|
136
|
+
return; // empty space — let OrbitControls own this gesture
|
|
137
|
+
const objectPos = hit.entityRoot.getWorldPosition(new THREE.Vector3());
|
|
138
|
+
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -objectPos.y);
|
|
139
|
+
const planePoint = new THREE.Vector3();
|
|
140
|
+
// Looking exactly parallel to the ground has no plane intersection —
|
|
141
|
+
// vanishingly rare for this camera (auto-framed, always angled down at
|
|
142
|
+
// the scene) but cheap to guard: fall through to orbit rather than
|
|
143
|
+
// start a drag from a garbage grab offset.
|
|
144
|
+
if (!raycaster.ray.intersectPlane(plane, planePoint))
|
|
145
|
+
return;
|
|
146
|
+
dragEntity = hit.entityRoot;
|
|
147
|
+
dragEntityId = hit.entityId;
|
|
148
|
+
dragPlane = plane;
|
|
149
|
+
dragGrabOffset = planePoint.sub(objectPos);
|
|
150
|
+
controls.enabled = false;
|
|
151
|
+
try {
|
|
152
|
+
renderer.domElement.setPointerCapture(e.pointerId);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Multi-touch / an already-released pointer can throw here — never
|
|
156
|
+
// let that leave the gesture half-started (see the board-game touch
|
|
157
|
+
// lesson: an uncaught throw from setPointerCapture stranded a finger
|
|
158
|
+
// as permanently "down"). The drag still works via normal bubbling;
|
|
159
|
+
// capture is an enhancement (keeps it live if the cursor leaves the
|
|
160
|
+
// canvas mid-drag), not a requirement.
|
|
161
|
+
}
|
|
49
162
|
};
|
|
50
163
|
const onPointerMove = (e) => {
|
|
51
164
|
if (Math.abs(e.clientX - downX) > 4 || Math.abs(e.clientY - downY) > 4)
|
|
52
165
|
moved = true;
|
|
166
|
+
if (!dragEntity || !dragPlane || !dragGrabOffset || !scene)
|
|
167
|
+
return;
|
|
168
|
+
raycaster.setFromCamera(ndcFromEvent(e), camera);
|
|
169
|
+
const planePoint = new THREE.Vector3();
|
|
170
|
+
if (!raycaster.ray.intersectPlane(dragPlane, planePoint))
|
|
171
|
+
return; // looking parallel to the plane
|
|
172
|
+
const newWorldPos = planePoint.sub(dragGrabOffset);
|
|
173
|
+
if (dragEntity.parent)
|
|
174
|
+
dragEntity.parent.worldToLocal(newWorldPos);
|
|
175
|
+
dragEntity.position.copy(newWorldPos);
|
|
176
|
+
selectionBox.setFromObject(dragEntity);
|
|
53
177
|
};
|
|
54
178
|
const onPointerUp = (e) => {
|
|
179
|
+
try {
|
|
180
|
+
renderer.domElement.releasePointerCapture(e.pointerId);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Already released, or never captured (e.g. this gesture never hit an
|
|
184
|
+
// entity) — nothing to do either way.
|
|
185
|
+
}
|
|
186
|
+
// A drag only really happened if the pointer actually moved — grabbing
|
|
187
|
+
// an entity and releasing without moving is a CLICK on it (case below),
|
|
188
|
+
// not a same-position no-op save.
|
|
189
|
+
if (moved && dragEntity && dragEntityId) {
|
|
190
|
+
window.parent.postMessage({
|
|
191
|
+
type: 'umicat:editor:dragEnd3d',
|
|
192
|
+
sceneId: opts.sceneId,
|
|
193
|
+
entityId: dragEntityId,
|
|
194
|
+
position: { x: dragEntity.position.x, y: dragEntity.position.y, z: dragEntity.position.z },
|
|
195
|
+
}, '*');
|
|
196
|
+
}
|
|
197
|
+
dragEntity = null;
|
|
198
|
+
dragEntityId = null;
|
|
199
|
+
dragPlane = null;
|
|
200
|
+
dragGrabOffset = null;
|
|
201
|
+
controls.enabled = true;
|
|
55
202
|
if (moved || !scene)
|
|
56
203
|
return;
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
raycaster.setFromCamera(mouse, camera);
|
|
61
|
-
// Only cast against VISIBLE entity roots. three.js's Mesh.raycast does
|
|
62
|
-
// NOT check `.visible` on its own (confirmed against the pinned three
|
|
63
|
-
// source — no such check in Raycaster.js or Object3D.raycast), and a
|
|
64
|
-
// real scene can ship an invisible collision box alongside separately-
|
|
65
|
-
// placed visible ground models — without this filter, a click that
|
|
66
|
-
// visibly lands on the rendered ground can silently return the
|
|
67
|
-
// collider's coordinates instead.
|
|
68
|
-
const targets = Array.from(entityByObject.keys()).filter((o) => o.visible !== false);
|
|
69
|
-
const hits = raycaster.intersectObjects(targets, true);
|
|
70
|
-
if (hits.length === 0)
|
|
204
|
+
// A plain click (no drag) — the existing read-a-coordinate behavior.
|
|
205
|
+
const hit = pickEntityAt(e);
|
|
206
|
+
if (!hit)
|
|
71
207
|
return;
|
|
72
|
-
const hit = hits[0];
|
|
73
|
-
let node = hit.object;
|
|
74
|
-
let entityId = null;
|
|
75
|
-
while (node) {
|
|
76
|
-
const id = entityByObject.get(node);
|
|
77
|
-
if (id) {
|
|
78
|
-
entityId = id;
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
81
|
-
node = node.parent;
|
|
82
|
-
}
|
|
83
208
|
marker.position.copy(hit.point);
|
|
84
209
|
marker.visible = true;
|
|
85
|
-
|
|
210
|
+
// Frame the whole entity the click landed on, not just the point on its
|
|
211
|
+
// surface — `setFromObject` walks the root's full subtree, so a
|
|
212
|
+
// multi-mesh entity (a model with separate parts) gets one box around
|
|
213
|
+
// all of it, matching what "entityId" actually refers to.
|
|
214
|
+
selectionBox.setFromObject(hit.entityRoot);
|
|
215
|
+
selectionBox.visible = true;
|
|
216
|
+
window.parent.postMessage({ type: 'umicat:editor:pickPoint3d', x: hit.point.x, y: hit.point.y, z: hit.point.z, entityId: hit.entityId }, '*');
|
|
86
217
|
};
|
|
87
218
|
renderer.domElement.addEventListener('pointerdown', onPointerDown);
|
|
88
219
|
renderer.domElement.addEventListener('pointermove', onPointerMove);
|
|
@@ -92,8 +223,30 @@ export async function runEditorDesignPlayer3D(renderer, opts = {}) {
|
|
|
92
223
|
// step, so the loop only ever needs to render.
|
|
93
224
|
renderer.setAnimationLoop(() => {
|
|
94
225
|
controls.update();
|
|
95
|
-
if (scene)
|
|
96
|
-
|
|
226
|
+
if (!scene)
|
|
227
|
+
return;
|
|
228
|
+
renderer.setScissorTest(false);
|
|
229
|
+
renderer.render(scene, camera);
|
|
230
|
+
// Second pass: the orientation gizmo, into a small scissored corner
|
|
231
|
+
// viewport. Its camera copies the MAIN camera's rotation only (not
|
|
232
|
+
// position/zoom) so it reads as "which way am I looking", exactly what
|
|
233
|
+
// orbiting the main view is meant to answer. three.js viewport Y is
|
|
234
|
+
// bottom-up, so "top-right on screen" is `height - size - margin`.
|
|
235
|
+
const w = renderer.domElement.clientWidth;
|
|
236
|
+
const h = renderer.domElement.clientHeight;
|
|
237
|
+
gizmoCamera.position.set(0, 0, 4).applyQuaternion(camera.quaternion);
|
|
238
|
+
gizmoCamera.up.copy(camera.up);
|
|
239
|
+
gizmoCamera.lookAt(0, 0, 0);
|
|
240
|
+
const gx = w - GIZMO_SIZE - GIZMO_MARGIN;
|
|
241
|
+
const gy = h - GIZMO_SIZE - GIZMO_MARGIN;
|
|
242
|
+
renderer.setScissorTest(true);
|
|
243
|
+
renderer.setScissor(gx, gy, GIZMO_SIZE, GIZMO_SIZE);
|
|
244
|
+
renderer.setViewport(gx, gy, GIZMO_SIZE, GIZMO_SIZE);
|
|
245
|
+
// render()'s own autoClear respects the scissor rect just set, so this
|
|
246
|
+
// only wipes the corner box, not the scene pass above.
|
|
247
|
+
renderer.render(gizmoScene, gizmoCamera);
|
|
248
|
+
renderer.setScissorTest(false);
|
|
249
|
+
renderer.setViewport(0, 0, w, h);
|
|
97
250
|
});
|
|
98
251
|
const sceneId = opts.sceneId;
|
|
99
252
|
if (!sceneId)
|
|
@@ -108,6 +261,8 @@ export async function runEditorDesignPlayer3D(renderer, opts = {}) {
|
|
|
108
261
|
entityByObject.set(obj, id);
|
|
109
262
|
marker.visible = false;
|
|
110
263
|
scene.add(marker);
|
|
264
|
+
selectionBox.visible = false;
|
|
265
|
+
scene.add(selectionBox);
|
|
111
266
|
// Frame the whole scene — auto-fit, since a small template scene and a
|
|
112
267
|
// real level's dozens of entities need wildly different orbit distances.
|
|
113
268
|
const box = new THREE.Box3().setFromObject(scene);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umicat/three-sdk",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.3",
|
|
4
4
|
"description": "Three.js runtime for Umicat games: the scene3d design format, its loader with physics, a kinematic character controller, and the Umicat platform via @umicat/platform-sdk.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|