@dcl-regenesislabs/opendcl 0.2.1 → 0.2.2-26850672477.commit-99ffd91

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 (45) hide show
  1. package/README.md +5 -3
  2. package/context/sdk7-cheat-sheet.md +4 -0
  3. package/dist/index.js +0 -12
  4. package/dist/index.js.map +1 -1
  5. package/extensions/dcl-init.ts +58 -6
  6. package/package.json +3 -3
  7. package/prompts/system.md +71 -41
  8. package/skills/add-3d-models/SKILL.md +120 -70
  9. package/skills/add-interactivity/SKILL.md +74 -2
  10. package/skills/advanced-input/SKILL.md +34 -1
  11. package/skills/advanced-rendering/SKILL.md +82 -9
  12. package/skills/animations-tweens/SKILL.md +203 -98
  13. package/skills/audio-analysis/SKILL.md +164 -0
  14. package/skills/audio-video/SKILL.md +184 -83
  15. package/skills/build-ui/SKILL.md +25 -2
  16. package/skills/camera-control/SKILL.md +78 -7
  17. package/skills/create-scene/SKILL.md +56 -13
  18. package/skills/deploy-scene/SKILL.md +12 -0
  19. package/skills/deploy-worlds/SKILL.md +35 -0
  20. package/skills/editor-gizmo/.gitignore +11 -0
  21. package/skills/editor-gizmo/SKILL.md +222 -0
  22. package/skills/editor-gizmo/src/__editor/camera.ts +277 -0
  23. package/skills/editor-gizmo/src/__editor/discovery.ts +210 -0
  24. package/skills/editor-gizmo/src/__editor/drag.ts +265 -0
  25. package/skills/editor-gizmo/src/__editor/gizmo.ts +496 -0
  26. package/skills/editor-gizmo/src/__editor/history.ts +72 -0
  27. package/skills/editor-gizmo/src/__editor/index.ts +138 -0
  28. package/skills/editor-gizmo/src/__editor/input.ts +55 -0
  29. package/skills/editor-gizmo/src/__editor/math-utils.ts +114 -0
  30. package/skills/editor-gizmo/src/__editor/persistence.ts +113 -0
  31. package/skills/editor-gizmo/src/__editor/selection.ts +157 -0
  32. package/skills/editor-gizmo/src/__editor/state.ts +117 -0
  33. package/skills/editor-gizmo/src/__editor/ui.tsx +699 -0
  34. package/skills/game-design/SKILL.md +1 -2
  35. package/skills/lighting-environment/SKILL.md +103 -56
  36. package/skills/multiplayer-sync/SKILL.md +31 -2
  37. package/skills/nft-blockchain/SKILL.md +45 -40
  38. package/skills/npcs/SKILL.md +180 -0
  39. package/skills/optimize-scene/SKILL.md +7 -2
  40. package/skills/particle-system/SKILL.md +222 -0
  41. package/skills/player-avatar/SKILL.md +133 -7
  42. package/skills/player-physics/SKILL.md +93 -0
  43. package/skills/scene-runtime/SKILL.md +9 -5
  44. package/skills/visual-feedback/SKILL.md +1 -0
  45. package/extensions/dcl-setup-ollama.ts +0 -312
@@ -0,0 +1,210 @@
1
+ /** Auto-discovery of scene entities. */
2
+
3
+ import {
4
+ engine,
5
+ Entity,
6
+ Transform,
7
+ MeshRenderer,
8
+ MeshCollider,
9
+ Material,
10
+ GltfContainer,
11
+ Name,
12
+ PointerEvents,
13
+ PointerEventType,
14
+ inputSystem,
15
+ InputAction,
16
+ ColliderLayer,
17
+ } from '@dcl/sdk/ecs'
18
+ import { Vector3 } from '@dcl/sdk/math'
19
+ import {
20
+ SelectableInfo,
21
+ state,
22
+ editorEntities,
23
+ selectableInfoMap,
24
+ originalMaterials,
25
+ gizmoClickConsumed,
26
+ } from './state'
27
+ import { selectEntity } from './selection'
28
+
29
+
30
+ /** Built-in entities to never register */
31
+ export const SKIP_ENTITIES = new Set<Entity>()
32
+
33
+ /** Entity names to skip (case-insensitive). Add names here to prevent selection. */
34
+ const SKIP_NAMES = new Set(['ground', 'floor'])
35
+
36
+ type PointerEventsValue = ReturnType<typeof PointerEvents.get>
37
+ const originalPointerEvents = new Map<Entity, PointerEventsValue | null>()
38
+
39
+ function addEditorHover(entity: Entity, name: string) {
40
+ const existing = PointerEvents.getMutableOrNull(entity)
41
+ const entry = {
42
+ eventType: PointerEventType.PET_DOWN,
43
+ eventInfo: { button: InputAction.IA_POINTER, hoverText: `Select ${name}`, maxDistance: 100 },
44
+ }
45
+ if (existing) {
46
+ existing.pointerEvents.push(entry)
47
+ } else {
48
+ PointerEvents.create(entity, { pointerEvents: [entry] })
49
+ }
50
+ }
51
+
52
+ function getEntityName(entity: Entity): string {
53
+ if (Name.has(entity)) {
54
+ return Name.get(entity).value
55
+ }
56
+ if (GltfContainer.has(entity)) {
57
+ const src = GltfContainer.get(entity).src
58
+ const filename = src.split('/').pop() ?? src
59
+ return filename.replace(/\.(glb|gltf)$/i, '')
60
+ }
61
+ if (MeshRenderer.has(entity)) {
62
+ const mr = MeshRenderer.get(entity) as any
63
+ const meshCase = mr?.mesh?.$case ?? 'mesh'
64
+ return `${meshCase} #${entity}`
65
+ }
66
+ return `entity #${entity}`
67
+ }
68
+
69
+ function detectMeshType(entity: Entity): 'box' | 'sphere' | 'cylinder' {
70
+ if (!MeshRenderer.has(entity)) return 'box'
71
+ try {
72
+ const mr = MeshRenderer.get(entity) as any
73
+ const c = mr?.mesh?.$case
74
+ if (c === 'sphere') return 'sphere'
75
+ if (c === 'cylinder') return 'cylinder'
76
+ } catch {}
77
+ return 'box'
78
+ }
79
+
80
+ function estimateBounds(entity: Entity): { centerOffset: Vector3; boundsSize: Vector3; isModel: boolean } {
81
+ const t = Transform.get(entity)
82
+ const s = t.scale ?? Vector3.One()
83
+
84
+ if (GltfContainer.has(entity)) {
85
+ return {
86
+ centerOffset: Vector3.create(0, Math.max(s.y * 0.5, 0.3), 0),
87
+ boundsSize: Vector3.create(Math.max(s.x, 0.5), Math.max(s.y, 0.5), Math.max(s.z, 0.5)),
88
+ isModel: true,
89
+ }
90
+ }
91
+
92
+ return {
93
+ centerOffset: Vector3.Zero(),
94
+ boundsSize: Vector3.create(s.x, s.y, s.z),
95
+ isModel: false,
96
+ }
97
+ }
98
+
99
+ export function registerEntity(entity: Entity) {
100
+ if (selectableInfoMap.has(entity)) return
101
+ if (editorEntities.has(entity)) return
102
+ if (SKIP_ENTITIES.has(entity)) return
103
+
104
+ // Only declared entities (those with a Name) are editable. Dynamic
105
+ // entities created at runtime via engine.addEntity() are intentionally
106
+ // skipped — by convention they have no Name component.
107
+ if (!Name.has(entity)) return
108
+
109
+ const n = Name.get(entity).value.toLowerCase()
110
+ if (SKIP_NAMES.has(n)) return
111
+
112
+ const { centerOffset, boundsSize, isModel } = estimateBounds(entity)
113
+ const name = getEntityName(entity)
114
+ const colliderShape = isModel ? 'box' : detectMeshType(entity)
115
+
116
+ const hadMeshCollider = MeshCollider.has(entity)
117
+ if (!hadMeshCollider) {
118
+ MeshCollider.setBox(entity, ColliderLayer.CL_POINTER)
119
+ }
120
+
121
+ let originalVisibleMask: number | undefined
122
+ let originalInvisibleMask: number | undefined
123
+ if (GltfContainer.has(entity)) {
124
+ const gltf = GltfContainer.get(entity)
125
+ originalVisibleMask = gltf.visibleMeshesCollisionMask
126
+ originalInvisibleMask = gltf.invisibleMeshesCollisionMask
127
+ }
128
+
129
+ if (!isModel && Material.has(entity)) {
130
+ try {
131
+ const mat = Material.get(entity) as any
132
+ const pbr = mat?.pbr ?? mat?.material?.pbr
133
+ if (pbr?.albedoColor) {
134
+ const c = pbr.albedoColor
135
+ originalMaterials.set(entity, { r: c.r ?? 0, g: c.g ?? 0, b: c.b ?? 0, a: c.a ?? 1 })
136
+ }
137
+ } catch {}
138
+ }
139
+
140
+ // Read parent entity from Transform
141
+ let parentEntity: number | undefined
142
+ const t = Transform.get(entity)
143
+ const rawParent = t.parent
144
+ if (rawParent !== undefined && rawParent !== 0 && rawParent !== (entity as number)) {
145
+ parentEntity = rawParent as number
146
+ }
147
+
148
+ const info: SelectableInfo = {
149
+ name,
150
+ centerOffset,
151
+ boundsSize,
152
+ isModel,
153
+ colliderShape,
154
+ originalVisibleMask,
155
+ originalInvisibleMask,
156
+ src: GltfContainer.has(entity) ? GltfContainer.get(entity).src : undefined,
157
+ meshType: !isModel ? colliderShape : undefined,
158
+ parentEntity,
159
+ }
160
+
161
+ selectableInfoMap.set(entity, info)
162
+
163
+ const snapshot = PointerEvents.getOrNull(entity)
164
+ originalPointerEvents.set(entity, snapshot ? structuredClone(snapshot) : null)
165
+ addEditorHover(entity, name)
166
+ }
167
+
168
+ export function discoverySystem() {
169
+ if (!state.editorActive) return
170
+
171
+ for (const [entity] of engine.getEntitiesWith(Transform, MeshRenderer)) {
172
+ registerEntity(entity)
173
+ }
174
+ for (const [entity] of engine.getEntitiesWith(Transform, GltfContainer)) {
175
+ registerEntity(entity)
176
+ }
177
+ }
178
+
179
+ /** Polls pointer-down input per selectable entity. Runs only while editor is
180
+ * active so scene click handlers behave normally otherwise. */
181
+ export function editorClickSystem() {
182
+ if (!state.editorActive || state.isDragging || gizmoClickConsumed) return
183
+ for (const [entity] of selectableInfoMap) {
184
+ if (inputSystem.isTriggered(InputAction.IA_POINTER, PointerEventType.PET_DOWN, entity)) {
185
+ selectEntity(entity)
186
+ return
187
+ }
188
+ }
189
+ }
190
+
191
+ /** Restore each discovered entity's PointerEvents to the snapshot taken at
192
+ * registration time. Called on editor-off so the scene's own click handlers
193
+ * see their unmodified component again. */
194
+ export function removeAllPointerEvents() {
195
+ for (const [entity, original] of originalPointerEvents) {
196
+ if (original === null) {
197
+ PointerEvents.deleteFrom(entity)
198
+ } else {
199
+ PointerEvents.createOrReplace(entity, original)
200
+ }
201
+ }
202
+ }
203
+
204
+ /** Re-add the editor's hover affordance to each registered entity, leaving
205
+ * any user-added entries intact. Inverse of removeAllPointerEvents. */
206
+ export function restoreAllPointerEvents() {
207
+ for (const [entity, info] of selectableInfoMap) {
208
+ addEditorHover(entity, info.name)
209
+ }
210
+ }
@@ -0,0 +1,265 @@
1
+ /** Ray-plane intersection drag system for translate and rotate. */
2
+
3
+ import {
4
+ engine,
5
+ Entity,
6
+ Transform,
7
+ inputSystem,
8
+ InputAction,
9
+ PointerEventType,
10
+ PrimaryPointerInfo,
11
+ } from '@dcl/sdk/ecs'
12
+ import { Vector3, Quaternion } from '@dcl/sdk/math'
13
+ import { Axis, MAX_PARENT_DEPTH, state, selectableInfoMap, handleAxisMap, handleDiscMap, handleArrowMap } from './state'
14
+ import { axisToVector, getDragPlaneNormal, rayPlaneIntersect, hitAngleOnPlane, hitAngleOnWorldPlane, copyVec3, copyQuat, getOtherAxes } from './math-utils'
15
+ import { getActiveCameraTransform, lockCamera, unlockCamera } from './camera'
16
+ import { getGizmoCenter, getParentWorldRotation, setArrowMaterial, setRingMaterial } from './gizmo'
17
+ import { sendEntityUpdate } from './persistence'
18
+ import { captureTransform, pushHistory, TransformSnapshot } from './history'
19
+
20
+ /**
21
+ * Convert a world-space displacement vector to local space,
22
+ * accounting for parent rotation and cumulative scale chain.
23
+ * Returns the local displacement vector to add to local position.
24
+ */
25
+ function worldToLocalDelta(entity: Entity, worldDelta: Vector3): Vector3 {
26
+ // Rotate world delta into local space using inverse parent rotation
27
+ const parentRot = getParentWorldRotation(entity)
28
+ const invParent = Quaternion.create(-parentRot.x, -parentRot.y, -parentRot.z, parentRot.w)
29
+ let local = Vector3.rotate(worldDelta, invParent)
30
+
31
+ // Walk up parent chain and accumulate scale per axis
32
+ const info = selectableInfoMap.get(entity)
33
+ let parentId = info?.parentEntity
34
+ let depth = 0
35
+ let sx = 1, sy = 1, sz = 1
36
+
37
+ while (parentId && depth < MAX_PARENT_DEPTH) {
38
+ const pe = parentId as Entity
39
+ if (!Transform.has(pe)) break
40
+ const pt = Transform.get(pe)
41
+ sx *= pt.scale.x; sy *= pt.scale.y; sz *= pt.scale.z
42
+ const parentInfo = selectableInfoMap.get(pe)
43
+ parentId = parentInfo?.parentEntity
44
+ depth++
45
+ }
46
+
47
+ return Vector3.create(
48
+ sx !== 0 ? local.x / sx : local.x,
49
+ sy !== 0 ? local.y / sy : local.y,
50
+ sz !== 0 ? local.z / sz : local.z,
51
+ )
52
+ }
53
+
54
+ let dragBeforeSnapshot: TransformSnapshot | undefined
55
+ /** World-space axis direction for single-axis translate drag (parent-rotated). */
56
+ let dragWorldAxis: Vector3 = Vector3.Right()
57
+ /** World-space rotation axis for rotate drag. */
58
+ let dragRotWorldAxis: Vector3 = Vector3.Up()
59
+ /** Parent world rotation at drag start (for converting world rot back to local). */
60
+ let dragParentWorldRot: { x: number; y: number; z: number; w: number } = Quaternion.Identity()
61
+ /** World-space local axes for plane drag (parent-rotated). */
62
+ let dragLocalAxes: { a1: Axis; d1: Vector3; a2: Axis; d2: Vector3 } = {
63
+ a1: 'x', d1: Vector3.Right(), a2: 'z', d2: Vector3.Forward()
64
+ }
65
+
66
+ /**
67
+ * Start a plane-constrained drag. normalAxis is the axis perpendicular to the plane:
68
+ * - normalAxis 'y' → drag on XZ plane (horizontal movement)
69
+ * - normalAxis 'z' → drag on XY plane
70
+ * - normalAxis 'x' → drag on YZ plane
71
+ */
72
+ export function startPlaneDrag(normalAxis: Axis) {
73
+ if (state.selectedEntity === undefined || !Transform.has(state.selectedEntity)) return
74
+
75
+ dragBeforeSnapshot = captureTransform(state.selectedEntity)
76
+
77
+ const entityPos = Transform.get(state.selectedEntity).position
78
+ const gizmoCenter = getGizmoCenter(state.selectedEntity)
79
+ const cameraT = getActiveCameraTransform()
80
+
81
+ const pointer = PrimaryPointerInfo.getOrNull(engine.RootEntity)
82
+ if (!pointer || !pointer.worldRayDirection) return
83
+
84
+ // World-aligned plane normal (gizmos are world-aligned)
85
+ const worldNormal = axisToVector(normalAxis)
86
+
87
+ const hit = rayPlaneIntersect(cameraT.position, pointer.worldRayDirection, gizmoCenter, worldNormal)
88
+ if (!hit) return
89
+
90
+ // Determine the two world axes that lie in the plane
91
+ const axes = getOtherAxes(normalAxis)
92
+ dragLocalAxes = {
93
+ a1: axes[0], d1: axisToVector(axes[0]),
94
+ a2: axes[1], d2: axisToVector(axes[1]),
95
+ }
96
+
97
+ state.isDragging = true
98
+ state.dragPlaneMode = normalAxis
99
+ state.dragAxis = 'x' // unused, needs a value
100
+ state.dragStartPos = Vector3.create(entityPos.x, entityPos.y, entityPos.z)
101
+ state.dragStartWorldPos = Vector3.create(gizmoCenter.x, gizmoCenter.y, gizmoCenter.z)
102
+ state.dragStartHit = hit
103
+ state.dragPlaneNormal = Vector3.create(worldNormal.x, worldNormal.y, worldNormal.z)
104
+
105
+ lockCamera()
106
+ }
107
+
108
+ export function startDrag(axis: Axis) {
109
+ if (state.selectedEntity === undefined || !Transform.has(state.selectedEntity)) return
110
+
111
+ // Capture transform before any changes for undo
112
+ dragBeforeSnapshot = captureTransform(state.selectedEntity)
113
+
114
+ const entityPos = Transform.get(state.selectedEntity).position
115
+ const gizmoCenter = getGizmoCenter(state.selectedEntity)
116
+ const cameraT = getActiveCameraTransform()
117
+ const cameraForward = Vector3.rotate(Vector3.Forward(), cameraT.rotation)
118
+
119
+ const pointer = PrimaryPointerInfo.getOrNull(engine.RootEntity)
120
+ if (!pointer || !pointer.worldRayDirection) return
121
+
122
+ if (state.gizmoMode === 'translate') {
123
+ // World-aligned arrows: drag along world axes directly
124
+ dragWorldAxis = axisToVector(axis)
125
+
126
+ const planeNormal = getDragPlaneNormal(axis, cameraForward, dragWorldAxis)
127
+ // Use world position (gizmoCenter) for plane intersection, but track local pos for delta
128
+ const hit = rayPlaneIntersect(cameraT.position, pointer.worldRayDirection, gizmoCenter, planeNormal)
129
+ if (!hit) return
130
+
131
+ state.isDragging = true
132
+ state.dragPlaneMode = undefined
133
+ state.dragAxis = axis
134
+ state.dragStartPos = Vector3.create(entityPos.x, entityPos.y, entityPos.z)
135
+ state.dragStartWorldPos = Vector3.create(gizmoCenter.x, gizmoCenter.y, gizmoCenter.z)
136
+ state.dragStartHit = hit
137
+ state.dragPlaneNormal = Vector3.create(planeNormal.x, planeNormal.y, planeNormal.z)
138
+ } else {
139
+ const center = getGizmoCenter(state.selectedEntity)
140
+ // World-aligned rotation: rings always point along world X, Y, Z
141
+ const parentRot = getParentWorldRotation(state.selectedEntity)
142
+ const worldAxis = axisToVector(axis)
143
+
144
+ const hit = rayPlaneIntersect(cameraT.position, pointer.worldRayDirection, center, worldAxis)
145
+ if (!hit) return
146
+
147
+ dragRotWorldAxis = worldAxis
148
+ dragParentWorldRot = Quaternion.create(parentRot.x, parentRot.y, parentRot.z, parentRot.w)
149
+
150
+ state.isDragging = true
151
+ state.dragAxis = axis
152
+ state.dragPlaneNormal = Vector3.create(worldAxis.x, worldAxis.y, worldAxis.z)
153
+ state.dragRotCenter = Vector3.create(center.x, center.y, center.z)
154
+ const entRot = Transform.get(state.selectedEntity).rotation
155
+ state.dragStartRot = Quaternion.create(entRot.x, entRot.y, entRot.z, entRot.w)
156
+ state.dragStartAngle = hitAngleOnWorldPlane(hit, center, worldAxis)
157
+ }
158
+
159
+ lockCamera()
160
+ }
161
+
162
+ export function dragSystem(_dt: number) {
163
+ if (!state.editorActive) return
164
+ if (!state.isDragging || state.selectedEntity === undefined || !Transform.has(state.selectedEntity))
165
+ return
166
+
167
+ if (inputSystem.isTriggered(InputAction.IA_POINTER, PointerEventType.PET_UP)) { endDrag(); return }
168
+ if (!inputSystem.isPressed(InputAction.IA_POINTER)) { endDrag(); return }
169
+
170
+ const pointer = PrimaryPointerInfo.getOrNull(engine.RootEntity)
171
+ if (!pointer || !pointer.worldRayDirection) return
172
+ const cameraT = getActiveCameraTransform()
173
+
174
+ if (state.gizmoMode === 'translate') {
175
+ // Intersect on the world-space plane (using world position, not local)
176
+ const hit = rayPlaneIntersect(cameraT.position, pointer.worldRayDirection, state.dragStartWorldPos, state.dragPlaneNormal)
177
+ if (!hit) return
178
+
179
+ const worldDelta = Vector3.subtract(hit, state.dragStartHit)
180
+
181
+ let constrainedWorld: Vector3
182
+ if (state.dragPlaneMode !== undefined) {
183
+ // Plane drag: keep only the 2 world axes that lie in the plane
184
+ const comp1 = Vector3.dot(worldDelta, dragLocalAxes.d1)
185
+ const comp2 = Vector3.dot(worldDelta, dragLocalAxes.d2)
186
+ constrainedWorld = Vector3.add(
187
+ Vector3.scale(dragLocalAxes.d1, comp1),
188
+ Vector3.scale(dragLocalAxes.d2, comp2),
189
+ )
190
+ } else {
191
+ // Single axis: project onto world axis, convert to local
192
+ const worldDisplacement = Vector3.dot(worldDelta, dragWorldAxis)
193
+ constrainedWorld = Vector3.scale(dragWorldAxis, worldDisplacement)
194
+ }
195
+
196
+ const localDelta = worldToLocalDelta(state.selectedEntity, constrainedWorld)
197
+ const t = Transform.getMutable(state.selectedEntity)
198
+ t.position.x = state.dragStartPos.x + localDelta.x
199
+ t.position.y = state.dragStartPos.y + localDelta.y
200
+ t.position.z = state.dragStartPos.z + localDelta.z
201
+ } else {
202
+ const hit = rayPlaneIntersect(cameraT.position, pointer.worldRayDirection, state.dragRotCenter, state.dragPlaneNormal)
203
+ if (!hit) return
204
+
205
+ const currentAngle = hitAngleOnWorldPlane(hit, state.dragRotCenter, dragRotWorldAxis)
206
+ const degrees = (currentAngle - state.dragStartAngle) * (180 / Math.PI)
207
+
208
+ // Compute incremental rotation in world space, then convert to local
209
+ const worldIncremental = Quaternion.fromAngleAxis(degrees, dragRotWorldAxis)
210
+ // localIncremental = inv(parentWorldRot) * worldIncremental * parentWorldRot
211
+ // For unit quaternions: inverse = conjugate (negate xyz, keep w)
212
+ const ip = dragParentWorldRot
213
+ const invParent = Quaternion.create(-ip.x, -ip.y, -ip.z, ip.w)
214
+ const localIncremental = Quaternion.multiply(
215
+ Quaternion.multiply(invParent, worldIncremental),
216
+ dragParentWorldRot
217
+ )
218
+ const newRot = Quaternion.multiply(localIncremental, state.dragStartRot)
219
+
220
+ const t = Transform.getMutable(state.selectedEntity)
221
+ copyQuat(t.rotation, newRot)
222
+ }
223
+ }
224
+
225
+ function endDrag() {
226
+ if (!state.isDragging) return
227
+ state.isDragging = false
228
+ state.dragPlaneMode = undefined
229
+ unlockCamera()
230
+
231
+ // Restore gizmo visuals
232
+ if (state.gizmoMode === 'rotate') {
233
+ for (const [h] of handleDiscMap) {
234
+ const a = handleAxisMap.get(h)
235
+ if (a) setRingMaterial(h, a, false)
236
+ }
237
+ } else {
238
+ for (const [h, parts] of handleArrowMap) {
239
+ const a = handleAxisMap.get(h)
240
+ if (!a) continue
241
+ for (const p of parts) setArrowMaterial(p, a, false)
242
+ }
243
+ }
244
+
245
+ // Send update, push to history, log
246
+ if (state.selectedEntity !== undefined && Transform.has(state.selectedEntity)) {
247
+ const afterSnapshot = captureTransform(state.selectedEntity)
248
+
249
+ if (dragBeforeSnapshot) {
250
+ pushHistory(state.selectedEntity, dragBeforeSnapshot, afterSnapshot)
251
+ dragBeforeSnapshot = undefined
252
+ }
253
+
254
+ sendEntityUpdate(state.selectedEntity)
255
+
256
+ const t = Transform.get(state.selectedEntity)
257
+ if (state.gizmoMode === 'translate') {
258
+ const label = state.dragPlaneMode !== undefined ? `plane(${state.dragPlaneMode})` : state.dragAxis
259
+ console.log(`[editor] move ${label}: pos=(${t.position.x.toFixed(2)}, ${t.position.y.toFixed(2)}, ${t.position.z.toFixed(2)})`)
260
+ } else {
261
+ const euler = Quaternion.toEulerAngles(t.rotation)
262
+ console.log(`[editor] rotate ${state.dragAxis}: rot=(${euler.x.toFixed(1)}, ${euler.y.toFixed(1)}, ${euler.z.toFixed(1)})`)
263
+ }
264
+ }
265
+ }