@umicat/three-sdk 0.17.2 → 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
@@ -67,64 +77,143 @@ export async function runEditorDesignPlayer3D(renderer, opts = {}) {
67
77
  };
68
78
  resize();
69
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
+ };
70
107
  // Orbit-vs-click: a real drag moves the pointer more than a few px.
71
108
  let downX = 0;
72
109
  let downY = 0;
73
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
74
128
  const onPointerDown = (e) => {
75
129
  downX = e.clientX;
76
130
  downY = e.clientY;
77
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
+ }
78
162
  };
79
163
  const onPointerMove = (e) => {
80
164
  if (Math.abs(e.clientX - downX) > 4 || Math.abs(e.clientY - downY) > 4)
81
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);
82
177
  };
83
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;
84
202
  if (moved || !scene)
85
203
  return;
86
- const rect = renderer.domElement.getBoundingClientRect();
87
- const mouse = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
88
- const raycaster = new THREE.Raycaster();
89
- raycaster.setFromCamera(mouse, camera);
90
- // Only cast against VISIBLE entity roots. three.js's Mesh.raycast does
91
- // NOT check `.visible` on its own (confirmed against the pinned three
92
- // source — no such check in Raycaster.js or Object3D.raycast), and a
93
- // real scene can ship an invisible collision box alongside separately-
94
- // placed visible ground models — without this filter, a click that
95
- // visibly lands on the rendered ground can silently return the
96
- // collider's coordinates instead.
97
- const targets = Array.from(entityByObject.keys()).filter((o) => o.visible !== false);
98
- const hits = raycaster.intersectObjects(targets, true);
99
- if (hits.length === 0)
204
+ // A plain click (no drag) — the existing read-a-coordinate behavior.
205
+ const hit = pickEntityAt(e);
206
+ if (!hit)
100
207
  return;
101
- const hit = hits[0];
102
- let node = hit.object;
103
- let entityId = null;
104
- let entityRoot = null;
105
- while (node) {
106
- const id = entityByObject.get(node);
107
- if (id) {
108
- entityId = id;
109
- entityRoot = node;
110
- break;
111
- }
112
- node = node.parent;
113
- }
114
208
  marker.position.copy(hit.point);
115
209
  marker.visible = true;
116
210
  // Frame the whole entity the click landed on, not just the point on its
117
211
  // surface — `setFromObject` walks the root's full subtree, so a
118
212
  // multi-mesh entity (a model with separate parts) gets one box around
119
213
  // all of it, matching what "entityId" actually refers to.
120
- if (entityRoot) {
121
- selectionBox.setFromObject(entityRoot);
122
- selectionBox.visible = true;
123
- }
124
- else {
125
- selectionBox.visible = false;
126
- }
127
- window.parent.postMessage({ type: 'umicat:editor:pickPoint3d', x: hit.point.x, y: hit.point.y, z: hit.point.z, entityId }, '*');
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 }, '*');
128
217
  };
129
218
  renderer.domElement.addEventListener('pointerdown', onPointerDown);
130
219
  renderer.domElement.addEventListener('pointermove', onPointerMove);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umicat/three-sdk",
3
- "version": "0.17.2",
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",