@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.
- package/README.md +5 -3
- package/context/sdk7-cheat-sheet.md +4 -0
- package/dist/index.js +0 -12
- package/dist/index.js.map +1 -1
- package/extensions/dcl-init.ts +58 -6
- package/package.json +3 -3
- package/prompts/system.md +71 -41
- package/skills/add-3d-models/SKILL.md +120 -70
- package/skills/add-interactivity/SKILL.md +74 -2
- package/skills/advanced-input/SKILL.md +34 -1
- package/skills/advanced-rendering/SKILL.md +82 -9
- package/skills/animations-tweens/SKILL.md +203 -98
- package/skills/audio-analysis/SKILL.md +164 -0
- package/skills/audio-video/SKILL.md +184 -83
- package/skills/build-ui/SKILL.md +25 -2
- package/skills/camera-control/SKILL.md +78 -7
- package/skills/create-scene/SKILL.md +56 -13
- package/skills/deploy-scene/SKILL.md +12 -0
- package/skills/deploy-worlds/SKILL.md +35 -0
- package/skills/editor-gizmo/.gitignore +11 -0
- package/skills/editor-gizmo/SKILL.md +222 -0
- package/skills/editor-gizmo/src/__editor/camera.ts +277 -0
- package/skills/editor-gizmo/src/__editor/discovery.ts +210 -0
- package/skills/editor-gizmo/src/__editor/drag.ts +265 -0
- package/skills/editor-gizmo/src/__editor/gizmo.ts +496 -0
- package/skills/editor-gizmo/src/__editor/history.ts +72 -0
- package/skills/editor-gizmo/src/__editor/index.ts +138 -0
- package/skills/editor-gizmo/src/__editor/input.ts +55 -0
- package/skills/editor-gizmo/src/__editor/math-utils.ts +114 -0
- package/skills/editor-gizmo/src/__editor/persistence.ts +113 -0
- package/skills/editor-gizmo/src/__editor/selection.ts +157 -0
- package/skills/editor-gizmo/src/__editor/state.ts +117 -0
- package/skills/editor-gizmo/src/__editor/ui.tsx +699 -0
- package/skills/game-design/SKILL.md +1 -2
- package/skills/lighting-environment/SKILL.md +103 -56
- package/skills/multiplayer-sync/SKILL.md +31 -2
- package/skills/nft-blockchain/SKILL.md +45 -40
- package/skills/npcs/SKILL.md +180 -0
- package/skills/optimize-scene/SKILL.md +7 -2
- package/skills/particle-system/SKILL.md +222 -0
- package/skills/player-avatar/SKILL.md +133 -7
- package/skills/player-physics/SKILL.md +93 -0
- package/skills/scene-runtime/SKILL.md +9 -5
- package/skills/visual-feedback/SKILL.md +1 -0
- 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
|
+
}
|