@dcl-regenesislabs/opendcl 0.2.1-26165320302.commit-e6effe4 → 0.2.1-26502482653.commit-5089b10
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,496 @@
|
|
|
1
|
+
/** Gizmo creation — translate arrows and rotate discs. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
engine,
|
|
5
|
+
Entity,
|
|
6
|
+
Transform,
|
|
7
|
+
MeshRenderer,
|
|
8
|
+
MeshCollider,
|
|
9
|
+
Material,
|
|
10
|
+
MaterialTransparencyMode,
|
|
11
|
+
pointerEventsSystem,
|
|
12
|
+
InputAction,
|
|
13
|
+
ColliderLayer,
|
|
14
|
+
} from '@dcl/sdk/ecs'
|
|
15
|
+
import { Vector3, Quaternion, Color4, Color3 } from '@dcl/sdk/math'
|
|
16
|
+
import {
|
|
17
|
+
Axis,
|
|
18
|
+
MAX_PARENT_DEPTH,
|
|
19
|
+
state,
|
|
20
|
+
editorEntities,
|
|
21
|
+
selectableInfoMap,
|
|
22
|
+
gizmoEntities,
|
|
23
|
+
gizmoRoot,
|
|
24
|
+
setGizmoRoot,
|
|
25
|
+
handleAxisMap,
|
|
26
|
+
handleDiscMap,
|
|
27
|
+
handleArrowMap,
|
|
28
|
+
setGizmoClickConsumed,
|
|
29
|
+
} from './state'
|
|
30
|
+
import { copyVec3, copyQuat, getOtherAxes } from './math-utils'
|
|
31
|
+
import { getActiveCameraTransform } from './camera'
|
|
32
|
+
// startDrag/startPlaneDrag injected to avoid circular dependency with drag.ts
|
|
33
|
+
let _startDrag: ((axis: Axis) => void) | undefined
|
|
34
|
+
let _startPlaneDrag: ((normalAxis: Axis) => void) | undefined
|
|
35
|
+
export function setStartDragHandler(fn: (axis: Axis) => void) { _startDrag = fn }
|
|
36
|
+
export function setStartPlaneDragHandler(fn: (normalAxis: Axis) => void) { _startPlaneDrag = fn }
|
|
37
|
+
|
|
38
|
+
// ---- Constants ----
|
|
39
|
+
|
|
40
|
+
const SHAFT_LENGTH = 1.2
|
|
41
|
+
const SHAFT_RADIUS = 0.04
|
|
42
|
+
const TIP_LENGTH = 0.3
|
|
43
|
+
const TIP_RADIUS = 0.12
|
|
44
|
+
const HANDLE_RADIUS = 0.18
|
|
45
|
+
|
|
46
|
+
const RING_RADIUS = 1.0
|
|
47
|
+
const RING_THICKNESS = 0.05
|
|
48
|
+
const RING_COLLIDER_THICKNESS = 0.2
|
|
49
|
+
|
|
50
|
+
// Gizmo scales with camera distance — this factor controls apparent size
|
|
51
|
+
// At 10m distance, gizmo scale = 10 * 0.12 = 1.2 (roughly 1.5m arrows)
|
|
52
|
+
const GIZMO_SCALE_FACTOR = 0.12
|
|
53
|
+
const GIZMO_MIN_SCALE = 0.8
|
|
54
|
+
const GIZMO_MAX_SCALE = 5.0
|
|
55
|
+
|
|
56
|
+
const AXIS_COLORS: Record<Axis, { c4: Color4; c3: Color3 }> = {
|
|
57
|
+
x: { c4: Color4.create(0.95, 0.15, 0.15, 1), c3: Color3.create(0.95, 0.15, 0.15) },
|
|
58
|
+
y: { c4: Color4.create(0.2, 0.9, 0.2, 1), c3: Color3.create(0.2, 0.9, 0.2) },
|
|
59
|
+
z: { c4: Color4.create(0.2, 0.3, 0.95, 1), c3: Color3.create(0.2, 0.3, 0.95) },
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const AXIS_ROTATION: Record<Axis, { x: number; y: number; z: number; w: number }> = {
|
|
63
|
+
x: Quaternion.fromEulerDegrees(0, 0, -90),
|
|
64
|
+
y: Quaternion.Identity(),
|
|
65
|
+
z: Quaternion.fromEulerDegrees(90, 0, 0),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ARROW_EMISSIVE_DEFAULT = 0.6
|
|
69
|
+
const ARROW_EMISSIVE_HOVER = 2.5
|
|
70
|
+
const DISC_ALPHA_DEFAULT = 0.2
|
|
71
|
+
const DISC_ALPHA_HOVER = 0.6
|
|
72
|
+
const DISC_EMISSIVE_DEFAULT = 0.4
|
|
73
|
+
const DISC_EMISSIVE_HOVER = 1.5
|
|
74
|
+
|
|
75
|
+
// ---- Public ----
|
|
76
|
+
|
|
77
|
+
/** Compute cumulative world rotation of an entity's parent chain.
|
|
78
|
+
* For root entities (no parent), returns identity. */
|
|
79
|
+
export function getParentWorldRotation(entity: Entity): { x: number; y: number; z: number; w: number } {
|
|
80
|
+
let worldRot = Quaternion.Identity()
|
|
81
|
+
const info = selectableInfoMap.get(entity)
|
|
82
|
+
let parentId = info?.parentEntity
|
|
83
|
+
let depth = 0
|
|
84
|
+
|
|
85
|
+
// Collect parent rotations bottom-up, then multiply top-down
|
|
86
|
+
const rotations: { x: number; y: number; z: number; w: number }[] = []
|
|
87
|
+
while (parentId && depth < MAX_PARENT_DEPTH) {
|
|
88
|
+
const pe = parentId as Entity
|
|
89
|
+
if (!Transform.has(pe)) break
|
|
90
|
+
rotations.push(Transform.get(pe).rotation)
|
|
91
|
+
const parentInfo = selectableInfoMap.get(pe)
|
|
92
|
+
parentId = parentInfo?.parentEntity
|
|
93
|
+
depth++
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Multiply top-down: grandparent * parent * ...
|
|
97
|
+
for (let i = rotations.length - 1; i >= 0; i--) {
|
|
98
|
+
worldRot = Quaternion.multiply(worldRot, rotations[i])
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return worldRot
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Compute world position of an entity by walking up the parent chain.
|
|
105
|
+
* Accounts for parent scale: childWorldPos = parentPos + parentRot * (parentScale * childLocalPos) */
|
|
106
|
+
function getWorldPosition(entity: Entity): Vector3 {
|
|
107
|
+
const t = Transform.get(entity)
|
|
108
|
+
let worldPos = Vector3.create(t.position.x, t.position.y, t.position.z)
|
|
109
|
+
|
|
110
|
+
const info = selectableInfoMap.get(entity)
|
|
111
|
+
let parentId = info?.parentEntity
|
|
112
|
+
let depth = 0
|
|
113
|
+
|
|
114
|
+
while (parentId && depth < MAX_PARENT_DEPTH) {
|
|
115
|
+
const pe = parentId as Entity
|
|
116
|
+
if (!Transform.has(pe)) break
|
|
117
|
+
const pt = Transform.get(pe)
|
|
118
|
+
// Scale the position by parent's scale, then rotate, then translate
|
|
119
|
+
const scaled = Vector3.create(
|
|
120
|
+
worldPos.x * pt.scale.x,
|
|
121
|
+
worldPos.y * pt.scale.y,
|
|
122
|
+
worldPos.z * pt.scale.z,
|
|
123
|
+
)
|
|
124
|
+
worldPos = Vector3.add(pt.position, Vector3.rotate(scaled, pt.rotation))
|
|
125
|
+
const parentInfo = selectableInfoMap.get(pe)
|
|
126
|
+
parentId = parentInfo?.parentEntity
|
|
127
|
+
depth++
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return worldPos
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function getGizmoCenter(entity: Entity): Vector3 {
|
|
134
|
+
const worldPos = getWorldPosition(entity)
|
|
135
|
+
const info = selectableInfoMap.get(entity)
|
|
136
|
+
const offset = info?.centerOffset ?? Vector3.Zero()
|
|
137
|
+
return Vector3.create(worldPos.x + offset.x, worldPos.y + offset.y, worldPos.z + offset.z)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createGizmo() {
|
|
141
|
+
destroyGizmo()
|
|
142
|
+
if (state.selectedEntity === undefined) return
|
|
143
|
+
|
|
144
|
+
const center = getGizmoCenter(state.selectedEntity)
|
|
145
|
+
const root = engine.addEntity()
|
|
146
|
+
Transform.create(root, { position: center })
|
|
147
|
+
gizmoEntities.push(root)
|
|
148
|
+
editorEntities.add(root)
|
|
149
|
+
setGizmoRoot(root)
|
|
150
|
+
|
|
151
|
+
if (state.gizmoMode === 'translate') {
|
|
152
|
+
createArrow('x', root)
|
|
153
|
+
createArrow('y', root)
|
|
154
|
+
createArrow('z', root)
|
|
155
|
+
// Plane handles between each pair of axes
|
|
156
|
+
createPlaneHandle('y', root) // XZ plane (horizontal)
|
|
157
|
+
createPlaneHandle('z', root) // XY plane
|
|
158
|
+
createPlaneHandle('x', root) // YZ plane
|
|
159
|
+
} else {
|
|
160
|
+
createRotationHandle('x', root)
|
|
161
|
+
createRotationHandle('y', root)
|
|
162
|
+
createRotationHandle('z', root)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function destroyGizmo() {
|
|
167
|
+
for (const e of gizmoEntities) {
|
|
168
|
+
if (handleAxisMap.has(e) || planeHandleMap.has(e)) {
|
|
169
|
+
pointerEventsSystem.removeOnPointerDown(e)
|
|
170
|
+
pointerEventsSystem.removeOnPointerHoverEnter(e)
|
|
171
|
+
pointerEventsSystem.removeOnPointerHoverLeave(e)
|
|
172
|
+
handleDiscMap.delete(e)
|
|
173
|
+
handleArrowMap.delete(e)
|
|
174
|
+
handleAxisMap.delete(e)
|
|
175
|
+
planeHandleMap.delete(e)
|
|
176
|
+
ringSegmentsMap.delete(e)
|
|
177
|
+
}
|
|
178
|
+
editorEntities.delete(e)
|
|
179
|
+
engine.removeEntity(e)
|
|
180
|
+
}
|
|
181
|
+
gizmoEntities.length = 0
|
|
182
|
+
setGizmoRoot(undefined)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function gizmoFollowSystem() {
|
|
186
|
+
if (gizmoRoot === undefined || state.selectedEntity === undefined) return
|
|
187
|
+
if (!Transform.has(state.selectedEntity)) return
|
|
188
|
+
|
|
189
|
+
const entityT = Transform.get(state.selectedEntity)
|
|
190
|
+
const g = Transform.getMutable(gizmoRoot)
|
|
191
|
+
|
|
192
|
+
const center = getGizmoCenter(state.selectedEntity)
|
|
193
|
+
copyVec3(g.position, center)
|
|
194
|
+
|
|
195
|
+
// Gizmo always world-aligned (both translate arrows and rotate rings)
|
|
196
|
+
g.rotation.x = 0; g.rotation.y = 0; g.rotation.z = 0; g.rotation.w = 1
|
|
197
|
+
|
|
198
|
+
// Scale gizmo: max of camera-distance-based and entity-size-based
|
|
199
|
+
// 1) Camera distance → constant screen size
|
|
200
|
+
const camT = getActiveCameraTransform()
|
|
201
|
+
const dx = camT.position.x - center.x
|
|
202
|
+
const dy = camT.position.y - center.y
|
|
203
|
+
const dz = camT.position.z - center.z
|
|
204
|
+
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz)
|
|
205
|
+
const distScale = dist * GIZMO_SCALE_FACTOR
|
|
206
|
+
|
|
207
|
+
// 2) Entity size → arrows must extend beyond the mesh
|
|
208
|
+
const sc = entityT.scale
|
|
209
|
+
const maxDim = Math.max(Math.abs(sc.x), Math.abs(sc.y), Math.abs(sc.z))
|
|
210
|
+
// Arrow total length is ~1.5 units at scale 1, we want arrows to extend ~50% beyond the mesh radius
|
|
211
|
+
// meshRadius ≈ maxDim * 0.5 (for primitives), arrow reaches gizmoScale * 1.5
|
|
212
|
+
// gizmoScale * 1.5 > maxDim * 0.5 * 1.5 → gizmoScale > maxDim * 0.5
|
|
213
|
+
const sizeScale = maxDim * 0.55
|
|
214
|
+
|
|
215
|
+
const s = Math.min(GIZMO_MAX_SCALE, Math.max(GIZMO_MIN_SCALE, distScale, sizeScale))
|
|
216
|
+
g.scale.x = s
|
|
217
|
+
g.scale.y = s
|
|
218
|
+
g.scale.z = s
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- Translate arrows ----
|
|
222
|
+
|
|
223
|
+
export function setArrowMaterial(entity: Entity, axis: Axis, hovered: boolean) {
|
|
224
|
+
const { c4, c3 } = AXIS_COLORS[axis]
|
|
225
|
+
Material.setPbrMaterial(entity, {
|
|
226
|
+
albedoColor: c4,
|
|
227
|
+
emissiveColor: c3,
|
|
228
|
+
emissiveIntensity: hovered ? ARROW_EMISSIVE_HOVER : ARROW_EMISSIVE_DEFAULT,
|
|
229
|
+
metallic: 0.7,
|
|
230
|
+
roughness: 0.25,
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function createArrow(axis: Axis, root: Entity) {
|
|
235
|
+
const rot = AXIS_ROTATION[axis]
|
|
236
|
+
|
|
237
|
+
const container = engine.addEntity()
|
|
238
|
+
Transform.create(container, { position: Vector3.Zero(), rotation: rot, parent: root })
|
|
239
|
+
gizmoEntities.push(container)
|
|
240
|
+
editorEntities.add(container)
|
|
241
|
+
|
|
242
|
+
const shaft = engine.addEntity()
|
|
243
|
+
Transform.create(shaft, {
|
|
244
|
+
position: Vector3.create(0, SHAFT_LENGTH / 2, 0),
|
|
245
|
+
scale: Vector3.create(1, SHAFT_LENGTH, 1),
|
|
246
|
+
parent: container,
|
|
247
|
+
})
|
|
248
|
+
MeshRenderer.setCylinder(shaft, SHAFT_RADIUS, SHAFT_RADIUS)
|
|
249
|
+
setArrowMaterial(shaft, axis, false)
|
|
250
|
+
gizmoEntities.push(shaft)
|
|
251
|
+
editorEntities.add(shaft)
|
|
252
|
+
|
|
253
|
+
const tip = engine.addEntity()
|
|
254
|
+
Transform.create(tip, {
|
|
255
|
+
position: Vector3.create(0, SHAFT_LENGTH + TIP_LENGTH / 2, 0),
|
|
256
|
+
scale: Vector3.create(1, TIP_LENGTH, 1),
|
|
257
|
+
parent: container,
|
|
258
|
+
})
|
|
259
|
+
MeshRenderer.setCylinder(tip, TIP_RADIUS, 0)
|
|
260
|
+
setArrowMaterial(tip, axis, false)
|
|
261
|
+
gizmoEntities.push(tip)
|
|
262
|
+
editorEntities.add(tip)
|
|
263
|
+
|
|
264
|
+
const handle = engine.addEntity()
|
|
265
|
+
Transform.create(handle, {
|
|
266
|
+
position: Vector3.create(0, (SHAFT_LENGTH + TIP_LENGTH) / 2, 0),
|
|
267
|
+
scale: Vector3.create(1, SHAFT_LENGTH + TIP_LENGTH, 1),
|
|
268
|
+
parent: container,
|
|
269
|
+
})
|
|
270
|
+
MeshCollider.setCylinder(handle, HANDLE_RADIUS, HANDLE_RADIUS, ColliderLayer.CL_POINTER)
|
|
271
|
+
gizmoEntities.push(handle)
|
|
272
|
+
editorEntities.add(handle)
|
|
273
|
+
|
|
274
|
+
handleAxisMap.set(handle, axis)
|
|
275
|
+
handleArrowMap.set(handle, [shaft, tip])
|
|
276
|
+
|
|
277
|
+
pointerEventsSystem.onPointerDown(
|
|
278
|
+
{ entity: handle, opts: { button: InputAction.IA_POINTER, hoverText: `Move ${axis.toUpperCase()}`, maxDistance: 100 } },
|
|
279
|
+
() => { setGizmoClickConsumed(true); _startDrag?.(axis) }
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
pointerEventsSystem.onPointerHoverEnter({ entity: handle, opts: { maxDistance: 100 } }, () => {
|
|
283
|
+
for (const [h, parts] of handleArrowMap) {
|
|
284
|
+
const a = handleAxisMap.get(h)
|
|
285
|
+
if (!a) continue
|
|
286
|
+
for (const p of parts) setArrowMaterial(p, a, h === handle)
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
pointerEventsSystem.onPointerHoverLeave({ entity: handle, opts: { maxDistance: 100 } }, () => {
|
|
291
|
+
if (state.isDragging && state.dragAxis === axis) return
|
|
292
|
+
for (const [h, parts] of handleArrowMap) {
|
|
293
|
+
const a = handleAxisMap.get(h)
|
|
294
|
+
if (!a) continue
|
|
295
|
+
for (const p of parts) setArrowMaterial(p, a, false)
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---- Plane handles (two-axis drag) ----
|
|
301
|
+
|
|
302
|
+
// Offset from center along each axis (fraction of shaft length)
|
|
303
|
+
const PLANE_OFFSET = 0.4
|
|
304
|
+
const PLANE_SIZE = 0.3
|
|
305
|
+
const PLANE_ALPHA = 0.55
|
|
306
|
+
const PLANE_ALPHA_HOVER = 0.8
|
|
307
|
+
const PLANE_EMISSIVE = 1.2
|
|
308
|
+
const PLANE_EMISSIVE_HOVER = 2.5
|
|
309
|
+
|
|
310
|
+
/** Map from plane handle entity → its visual entity + normal axis */
|
|
311
|
+
const planeHandleMap = new Map<Entity, { visual: Entity; normalAxis: Axis }>()
|
|
312
|
+
|
|
313
|
+
/** Get blended color for a plane from the two axes it spans. */
|
|
314
|
+
function planeColor(normalAxis: Axis): { c4: Color4; c3: Color3 } {
|
|
315
|
+
// The plane's color is a mix of the two axes it contains
|
|
316
|
+
const axes = getOtherAxes(normalAxis)
|
|
317
|
+
const a = AXIS_COLORS[axes[0]]
|
|
318
|
+
const b = AXIS_COLORS[axes[1]]
|
|
319
|
+
return {
|
|
320
|
+
c4: Color4.create((a.c4.r + b.c4.r) * 0.5, (a.c4.g + b.c4.g) * 0.5, (a.c4.b + b.c4.b) * 0.5, 1),
|
|
321
|
+
c3: Color3.create((a.c3.r + b.c3.r) * 0.5, (a.c3.g + b.c3.g) * 0.5, (a.c3.b + b.c3.b) * 0.5),
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function setPlaneMaterial(visual: Entity, normalAxis: Axis, hovered: boolean) {
|
|
326
|
+
const { c4, c3 } = planeColor(normalAxis)
|
|
327
|
+
Material.setPbrMaterial(visual, {
|
|
328
|
+
albedoColor: Color4.create(c4.r, c4.g, c4.b, hovered ? PLANE_ALPHA_HOVER : PLANE_ALPHA),
|
|
329
|
+
emissiveColor: c3,
|
|
330
|
+
emissiveIntensity: hovered ? PLANE_EMISSIVE_HOVER : PLANE_EMISSIVE,
|
|
331
|
+
metallic: 0.5,
|
|
332
|
+
roughness: 0.3,
|
|
333
|
+
transparencyMode: MaterialTransparencyMode.MTM_ALPHA_BLEND,
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Create a plane handle. normalAxis = axis perpendicular to the plane.
|
|
339
|
+
* e.g. normalAxis='y' → XZ plane handle, positioned between X and Z arrows.
|
|
340
|
+
*/
|
|
341
|
+
function createPlaneHandle(normalAxis: Axis, root: Entity) {
|
|
342
|
+
// Position: offset along the two in-plane axes
|
|
343
|
+
const axes = getOtherAxes(normalAxis)
|
|
344
|
+
const pos = Vector3.create(
|
|
345
|
+
axes.includes('x') ? PLANE_OFFSET : 0,
|
|
346
|
+
axes.includes('y') ? PLANE_OFFSET : 0,
|
|
347
|
+
axes.includes('z') ? PLANE_OFFSET : 0,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
// Visual: flat box aligned to the plane
|
|
351
|
+
const visual = engine.addEntity()
|
|
352
|
+
const scale = Vector3.create(
|
|
353
|
+
normalAxis === 'x' ? 0.02 : PLANE_SIZE,
|
|
354
|
+
normalAxis === 'y' ? 0.02 : PLANE_SIZE,
|
|
355
|
+
normalAxis === 'z' ? 0.02 : PLANE_SIZE,
|
|
356
|
+
)
|
|
357
|
+
Transform.create(visual, { position: pos, scale, parent: root })
|
|
358
|
+
MeshRenderer.setBox(visual)
|
|
359
|
+
setPlaneMaterial(visual, normalAxis, false)
|
|
360
|
+
gizmoEntities.push(visual)
|
|
361
|
+
editorEntities.add(visual)
|
|
362
|
+
|
|
363
|
+
// Collider: slightly larger for easier clicking
|
|
364
|
+
const handle = engine.addEntity()
|
|
365
|
+
const colliderScale = Vector3.create(
|
|
366
|
+
normalAxis === 'x' ? 0.08 : PLANE_SIZE * 1.4,
|
|
367
|
+
normalAxis === 'y' ? 0.08 : PLANE_SIZE * 1.4,
|
|
368
|
+
normalAxis === 'z' ? 0.08 : PLANE_SIZE * 1.4,
|
|
369
|
+
)
|
|
370
|
+
Transform.create(handle, { position: pos, scale: colliderScale, parent: root })
|
|
371
|
+
MeshCollider.setBox(handle, ColliderLayer.CL_POINTER)
|
|
372
|
+
gizmoEntities.push(handle)
|
|
373
|
+
editorEntities.add(handle)
|
|
374
|
+
|
|
375
|
+
// Label: show which plane
|
|
376
|
+
const planeLabel = axes.map(a => a.toUpperCase()).join('')
|
|
377
|
+
planeHandleMap.set(handle, { visual, normalAxis })
|
|
378
|
+
|
|
379
|
+
pointerEventsSystem.onPointerDown(
|
|
380
|
+
{ entity: handle, opts: { button: InputAction.IA_POINTER, hoverText: `Move ${planeLabel}`, maxDistance: 100 } },
|
|
381
|
+
() => { setGizmoClickConsumed(true); _startPlaneDrag?.(normalAxis) }
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
pointerEventsSystem.onPointerHoverEnter({ entity: handle, opts: { maxDistance: 100 } }, () => {
|
|
385
|
+
setPlaneMaterial(visual, normalAxis, true)
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
pointerEventsSystem.onPointerHoverLeave({ entity: handle, opts: { maxDistance: 100 } }, () => {
|
|
389
|
+
if (state.isDragging && state.dragPlaneMode === normalAxis) return
|
|
390
|
+
setPlaneMaterial(visual, normalAxis, false)
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ---- Rotation rings ----
|
|
395
|
+
|
|
396
|
+
const RING_SEGMENTS = 24
|
|
397
|
+
const RING_SEGMENT_THICKNESS = 0.025
|
|
398
|
+
const RING_ALPHA_DEFAULT = 0.85
|
|
399
|
+
const RING_ALPHA_HOVER = 1.0
|
|
400
|
+
const RING_EMISSIVE_DEFAULT = 1.0
|
|
401
|
+
const RING_EMISSIVE_HOVER = 3.0
|
|
402
|
+
|
|
403
|
+
/** Map from handle entity → array of segment visual entities */
|
|
404
|
+
const ringSegmentsMap = new Map<Entity, Entity[]>()
|
|
405
|
+
|
|
406
|
+
export function setDiscMaterial(segmentOrDisc: Entity, axis: Axis, hovered: boolean) {
|
|
407
|
+
const { c4, c3 } = AXIS_COLORS[axis]
|
|
408
|
+
Material.setPbrMaterial(segmentOrDisc, {
|
|
409
|
+
albedoColor: Color4.create(c4.r, c4.g, c4.b, hovered ? RING_ALPHA_HOVER : RING_ALPHA_DEFAULT),
|
|
410
|
+
emissiveColor: c3,
|
|
411
|
+
emissiveIntensity: hovered ? RING_EMISSIVE_HOVER : RING_EMISSIVE_DEFAULT,
|
|
412
|
+
metallic: 0.7,
|
|
413
|
+
roughness: 0.2,
|
|
414
|
+
transparencyMode: MaterialTransparencyMode.MTM_ALPHA_BLEND,
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function setRingMaterial(handle: Entity, axis: Axis, hovered: boolean) {
|
|
419
|
+
const segments = ringSegmentsMap.get(handle)
|
|
420
|
+
if (segments) {
|
|
421
|
+
for (const seg of segments) setDiscMaterial(seg, axis, hovered)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function createRotationHandle(axis: Axis, root: Entity) {
|
|
426
|
+
const rot = AXIS_ROTATION[axis]
|
|
427
|
+
|
|
428
|
+
const container = engine.addEntity()
|
|
429
|
+
Transform.create(container, { position: Vector3.Zero(), rotation: rot, parent: root })
|
|
430
|
+
gizmoEntities.push(container)
|
|
431
|
+
editorEntities.add(container)
|
|
432
|
+
|
|
433
|
+
// Build ring from segments (thin boxes placed around a circle in the XZ plane)
|
|
434
|
+
const segAngle = (Math.PI * 2) / RING_SEGMENTS
|
|
435
|
+
const segLength = RING_RADIUS * 2 * Math.sin(segAngle / 2) * 1.05 // slight overlap
|
|
436
|
+
const segments: Entity[] = []
|
|
437
|
+
|
|
438
|
+
for (let i = 0; i < RING_SEGMENTS; i++) {
|
|
439
|
+
const angle = i * segAngle
|
|
440
|
+
const x = Math.cos(angle) * RING_RADIUS
|
|
441
|
+
const z = Math.sin(angle) * RING_RADIUS
|
|
442
|
+
// Tangent direction for rotation
|
|
443
|
+
const tangentAngle = angle + Math.PI / 2
|
|
444
|
+
|
|
445
|
+
const seg = engine.addEntity()
|
|
446
|
+
Transform.create(seg, {
|
|
447
|
+
position: Vector3.create(x, 0, z),
|
|
448
|
+
rotation: Quaternion.fromEulerDegrees(0, -tangentAngle * (180 / Math.PI), 0),
|
|
449
|
+
scale: Vector3.create(segLength, RING_SEGMENT_THICKNESS, RING_SEGMENT_THICKNESS),
|
|
450
|
+
parent: container,
|
|
451
|
+
})
|
|
452
|
+
MeshRenderer.setBox(seg)
|
|
453
|
+
setDiscMaterial(seg, axis, false)
|
|
454
|
+
gizmoEntities.push(seg)
|
|
455
|
+
editorEntities.add(seg)
|
|
456
|
+
segments.push(seg)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Invisible collider cylinder for clicking (same as before)
|
|
460
|
+
const handle = engine.addEntity()
|
|
461
|
+
Transform.create(handle, {
|
|
462
|
+
position: Vector3.Zero(),
|
|
463
|
+
scale: Vector3.create(RING_RADIUS * 2, RING_COLLIDER_THICKNESS, RING_RADIUS * 2),
|
|
464
|
+
parent: container,
|
|
465
|
+
})
|
|
466
|
+
MeshCollider.setCylinder(handle, 0.5, 0.5, ColliderLayer.CL_POINTER)
|
|
467
|
+
gizmoEntities.push(handle)
|
|
468
|
+
editorEntities.add(handle)
|
|
469
|
+
|
|
470
|
+
handleAxisMap.set(handle, axis)
|
|
471
|
+
ringSegmentsMap.set(handle, segments)
|
|
472
|
+
// Keep handleDiscMap working by pointing to the first segment (for endDrag reset)
|
|
473
|
+
handleDiscMap.set(handle, handle)
|
|
474
|
+
|
|
475
|
+
pointerEventsSystem.onPointerDown(
|
|
476
|
+
{ entity: handle, opts: { button: InputAction.IA_POINTER, hoverText: `Rotate ${axis.toUpperCase()}`, maxDistance: 100 } },
|
|
477
|
+
() => { setGizmoClickConsumed(true); _startDrag?.(axis) }
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
pointerEventsSystem.onPointerHoverEnter({ entity: handle, opts: { maxDistance: 100 } }, () => {
|
|
481
|
+
for (const [h] of ringSegmentsMap) {
|
|
482
|
+
const a = handleAxisMap.get(h)
|
|
483
|
+
if (!a) continue
|
|
484
|
+
setRingMaterial(h, a, h === handle)
|
|
485
|
+
}
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
pointerEventsSystem.onPointerHoverLeave({ entity: handle, opts: { maxDistance: 100 } }, () => {
|
|
489
|
+
if (state.isDragging && state.dragAxis === axis) return
|
|
490
|
+
for (const [h] of ringSegmentsMap) {
|
|
491
|
+
const a = handleAxisMap.get(h)
|
|
492
|
+
if (!a) continue
|
|
493
|
+
setRingMaterial(h, a, false)
|
|
494
|
+
}
|
|
495
|
+
})
|
|
496
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Undo/redo history for transform changes. */
|
|
2
|
+
|
|
3
|
+
import { Entity, Transform } from '@dcl/sdk/ecs'
|
|
4
|
+
import { selectableInfoMap } from './state'
|
|
5
|
+
import { sendEntityUpdate } from './persistence'
|
|
6
|
+
import { applyFlatTransform } from './math-utils'
|
|
7
|
+
|
|
8
|
+
export interface TransformSnapshot {
|
|
9
|
+
px: number; py: number; pz: number
|
|
10
|
+
rx: number; ry: number; rz: number; rw: number
|
|
11
|
+
sx: number; sy: number; sz: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface HistoryEntry {
|
|
15
|
+
entity: Entity
|
|
16
|
+
before: TransformSnapshot
|
|
17
|
+
after: TransformSnapshot
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const stack: HistoryEntry[] = []
|
|
21
|
+
let cursor = -1
|
|
22
|
+
const MAX_HISTORY = 50
|
|
23
|
+
|
|
24
|
+
export function captureTransform(entity: Entity): TransformSnapshot {
|
|
25
|
+
const t = Transform.get(entity)
|
|
26
|
+
return {
|
|
27
|
+
px: t.position.x, py: t.position.y, pz: t.position.z,
|
|
28
|
+
rx: t.rotation.x, ry: t.rotation.y, rz: t.rotation.z, rw: t.rotation.w,
|
|
29
|
+
sx: t.scale.x, sy: t.scale.y, sz: t.scale.z,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function applySnapshot(entity: Entity, snap: TransformSnapshot) {
|
|
34
|
+
if (!Transform.has(entity)) return
|
|
35
|
+
const t = Transform.getMutable(entity)
|
|
36
|
+
applyFlatTransform(t, snap)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function pushHistory(entity: Entity, before: TransformSnapshot, after: TransformSnapshot) {
|
|
40
|
+
stack.length = cursor + 1
|
|
41
|
+
stack.push({ entity, before, after })
|
|
42
|
+
cursor = stack.length - 1
|
|
43
|
+
if (stack.length > MAX_HISTORY) {
|
|
44
|
+
stack.shift()
|
|
45
|
+
cursor--
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function undo(): boolean {
|
|
50
|
+
if (cursor < 0) return false
|
|
51
|
+
const entry = stack[cursor]
|
|
52
|
+
cursor--
|
|
53
|
+
applySnapshot(entry.entity, entry.before)
|
|
54
|
+
sendEntityUpdate(entry.entity)
|
|
55
|
+
const info = selectableInfoMap.get(entry.entity)
|
|
56
|
+
console.log(`[editor] undo ${info?.name ?? 'entity'}`)
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function redo(): boolean {
|
|
61
|
+
if (cursor >= stack.length - 1) return false
|
|
62
|
+
const next = stack[cursor + 1]
|
|
63
|
+
cursor++
|
|
64
|
+
applySnapshot(next.entity, next.after)
|
|
65
|
+
sendEntityUpdate(next.entity)
|
|
66
|
+
const info = selectableInfoMap.get(next.entity)
|
|
67
|
+
console.log(`[editor] redo ${info?.name ?? 'entity'}`)
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function undoCount(): number { return cursor + 1 }
|
|
72
|
+
export function redoCount(): number { return stack.length - 1 - cursor }
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene Editor — the single entry point.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { enableEditor } from './__editor'
|
|
6
|
+
* enableEditor()
|
|
7
|
+
*
|
|
8
|
+
* Pure client-side editor. On preview, lets the user toggle the editor on,
|
|
9
|
+
* select entities declared in main-entities.ts, drag them around, and persist
|
|
10
|
+
* changes to the preview server (which writes main-entities.ts on disk).
|
|
11
|
+
*
|
|
12
|
+
* Only available in preview — deployed scenes never show editor UI.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
engine,
|
|
17
|
+
Transform,
|
|
18
|
+
MeshCollider,
|
|
19
|
+
pointerEventsSystem,
|
|
20
|
+
InputAction,
|
|
21
|
+
ColliderLayer,
|
|
22
|
+
RealmInfo,
|
|
23
|
+
} from '@dcl/sdk/ecs'
|
|
24
|
+
import { Vector3 } from '@dcl/sdk/math'
|
|
25
|
+
import { state, editorEntities, gizmoClickConsumed, setToggleHandler } from './state'
|
|
26
|
+
import { setupEditorUi } from './ui'
|
|
27
|
+
import { createEditorCamera, createLockCamera, deactivateEditorCamera, editorCameraSystem } from './camera'
|
|
28
|
+
import { SKIP_ENTITIES, discoverySystem, editorClickSystem, removeAllPointerEvents, restoreAllPointerEvents } from './discovery'
|
|
29
|
+
import { deselectEntity } from './selection'
|
|
30
|
+
import { startDrag, startPlaneDrag, dragSystem } from './drag'
|
|
31
|
+
import { gizmoFollowSystem, setStartDragHandler, setStartPlaneDragHandler } from './gizmo'
|
|
32
|
+
import { modeToggleSystem, resetGizmoClickFlag } from './input'
|
|
33
|
+
import { initPersistence } from './persistence'
|
|
34
|
+
|
|
35
|
+
let initialized = false
|
|
36
|
+
|
|
37
|
+
export function enableEditor() {
|
|
38
|
+
if (initialized) return
|
|
39
|
+
initialized = true
|
|
40
|
+
|
|
41
|
+
// UI + systems wire up immediately; visibility is gated on `state.isPreview`
|
|
42
|
+
// which starts false. A one-shot polling system flips it true once
|
|
43
|
+
// RealmInfo is published and the realm looks editable.
|
|
44
|
+
setupEditorUi()
|
|
45
|
+
setupClientEditor()
|
|
46
|
+
initPersistence()
|
|
47
|
+
engine.addSystem(realmDetectSystem)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* RealmInfo isn't populated synchronously at scene-module-load — the runtime
|
|
52
|
+
* publishes it a few ticks in. Poll until it's available, then decide:
|
|
53
|
+
*
|
|
54
|
+
* - deployed world (baseUrl on worlds-content-server) → never editable,
|
|
55
|
+
* even if some future SDK build flips `isPreview` true there.
|
|
56
|
+
* - `isPreview === true` → CLI `sdk-commands start` preview → editable.
|
|
57
|
+
* - studio realm (`/scenes/<id>/snapshots/<id>` in baseUrl) → editable.
|
|
58
|
+
* opendcl-studio doesn't set isPreview=true even though scenes there are
|
|
59
|
+
* editable; the path pattern is unique to studio's realm handler.
|
|
60
|
+
* - anything else (Genesis City, custom catalysts, etc.) → leave isPreview
|
|
61
|
+
* false so the toolbar/toggle never renders.
|
|
62
|
+
*
|
|
63
|
+
* Removes itself after the first decision — no need to keep polling.
|
|
64
|
+
*/
|
|
65
|
+
function realmDetectSystem(_dt: number) {
|
|
66
|
+
const info = RealmInfo.getOrNull(engine.RootEntity)
|
|
67
|
+
if (!info) return
|
|
68
|
+
engine.removeSystem(realmDetectSystem)
|
|
69
|
+
const baseUrl = info.baseUrl ?? ''
|
|
70
|
+
if (baseUrl.startsWith('https://worlds-content-server.decentraland.org/')) {
|
|
71
|
+
console.log('[editor] disabled: deployed world')
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (info.isPreview) {
|
|
75
|
+
state.isPreview = true
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
if (/\/scenes\/[^/]+\/snapshots\/[^/]+/.test(baseUrl)) {
|
|
79
|
+
state.isPreview = true
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
console.log('[editor] disabled: not a preview/studio realm')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function handleToggle() {
|
|
86
|
+
if (state.editorActive) {
|
|
87
|
+
deselectEntity()
|
|
88
|
+
if (state.editorCamActive) deactivateEditorCamera()
|
|
89
|
+
removeAllPointerEvents()
|
|
90
|
+
state.editorActive = false
|
|
91
|
+
console.log('[editor] editor OFF')
|
|
92
|
+
} else {
|
|
93
|
+
state.editorActive = true
|
|
94
|
+
restoreAllPointerEvents()
|
|
95
|
+
console.log('[editor] editor ON')
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function setupClientEditor() {
|
|
100
|
+
SKIP_ENTITIES.add(engine.RootEntity)
|
|
101
|
+
SKIP_ENTITIES.add(engine.CameraEntity)
|
|
102
|
+
SKIP_ENTITIES.add(engine.PlayerEntity)
|
|
103
|
+
|
|
104
|
+
setToggleHandler(handleToggle)
|
|
105
|
+
createDeselectGround()
|
|
106
|
+
setStartDragHandler(startDrag)
|
|
107
|
+
setStartPlaneDragHandler(startPlaneDrag)
|
|
108
|
+
createEditorCamera()
|
|
109
|
+
createLockCamera()
|
|
110
|
+
|
|
111
|
+
engine.addSystem(editorCameraSystem, 102)
|
|
112
|
+
engine.addSystem(discoverySystem, 100)
|
|
113
|
+
engine.addSystem(editorClickSystem, 99)
|
|
114
|
+
engine.addSystem(dragSystem)
|
|
115
|
+
engine.addSystem(gizmoFollowSystem)
|
|
116
|
+
engine.addSystem(modeToggleSystem)
|
|
117
|
+
engine.addSystem(resetGizmoClickFlag, Number.MAX_SAFE_INTEGER)
|
|
118
|
+
|
|
119
|
+
console.log('[editor] ready — click to select, E toggle Move/Rotate, F deselect')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function createDeselectGround() {
|
|
123
|
+
const ground = engine.addEntity()
|
|
124
|
+
Transform.create(ground, {
|
|
125
|
+
position: Vector3.create(0, -0.05, 0),
|
|
126
|
+
scale: Vector3.create(300, 0.1, 300),
|
|
127
|
+
})
|
|
128
|
+
MeshCollider.setBox(ground, ColliderLayer.CL_POINTER)
|
|
129
|
+
editorEntities.add(ground)
|
|
130
|
+
|
|
131
|
+
pointerEventsSystem.onPointerDown(
|
|
132
|
+
{ entity: ground, opts: { button: InputAction.IA_POINTER, maxDistance: 100, showFeedback: false } },
|
|
133
|
+
() => {
|
|
134
|
+
if (!state.editorActive || state.isDragging || gizmoClickConsumed) return
|
|
135
|
+
deselectEntity()
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
}
|