@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. Read-only: no drag, no gizmo, no apply-edit — the one
20
- * interaction is click-to-pick a world coordinate, posted to the host so a
21
- * person can point at a real spot instead of guessing one blind when telling
22
- * the AI where to place something.
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. Read-only: no drag, no gizmo, no apply-edit — the one
9
- * interaction is click-to-pick a world coordinate, posted to the host so a
10
- * person can point at a real spot instead of guessing one blind when telling
11
- * the AI where to place something.
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
- const rect = renderer.domElement.getBoundingClientRect();
58
- const mouse = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
59
- const raycaster = new THREE.Raycaster();
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
- window.parent.postMessage({ type: 'umicat:editor:pickPoint3d', x: hit.point.x, y: hit.point.y, z: hit.point.z, entityId }, '*');
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
- renderer.render(scene, camera);
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.1",
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",