@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,55 @@
1
+ /** Key bindings, mode toggle, gizmo click flag, undo/redo. */
2
+
3
+ import { inputSystem, InputAction, PointerEventType } from '@dcl/sdk/ecs'
4
+ import { state, gizmoClickConsumed, setGizmoClickConsumed } from './state'
5
+ import { createGizmo } from './gizmo'
6
+ import { deselectEntity } from './selection'
7
+ import { toggleEditorCamera, deactivateEditorCamera, focusSelectedEntity } from './camera'
8
+ import { undo, redo } from './history'
9
+
10
+ export function modeToggleSystem() {
11
+ if (!state.editorActive) return
12
+
13
+ if (inputSystem.isTriggered(InputAction.IA_PRIMARY, PointerEventType.PET_DOWN)) {
14
+ if (state.isDragging) return
15
+ state.gizmoMode = state.gizmoMode === 'translate' ? 'rotate' : 'translate'
16
+ console.log(`[editor] mode: ${state.gizmoMode}`)
17
+ if (state.selectedEntity !== undefined) createGizmo()
18
+ }
19
+
20
+ // F = deselect (select mode)
21
+ if (inputSystem.isTriggered(InputAction.IA_SECONDARY, PointerEventType.PET_DOWN)) {
22
+ if (state.isDragging) return
23
+ if (state.selectedEntity !== undefined) {
24
+ deselectEntity()
25
+ }
26
+ }
27
+
28
+ // 1 = toggle editor camera
29
+ if (inputSystem.isTriggered(InputAction.IA_ACTION_3, PointerEventType.PET_DOWN)) {
30
+ toggleEditorCamera()
31
+ }
32
+
33
+ // 2 = focus selected entity
34
+ if (inputSystem.isTriggered(InputAction.IA_ACTION_4, PointerEventType.PET_DOWN)) {
35
+ if (state.isDragging) return
36
+ if (state.selectedEntity !== undefined) {
37
+ if (!state.editorCamActive) toggleEditorCamera()
38
+ focusSelectedEntity()
39
+ }
40
+ }
41
+
42
+ // 4 = undo, Shift+4 = redo
43
+ if (inputSystem.isTriggered(InputAction.IA_ACTION_6, PointerEventType.PET_DOWN)) {
44
+ if (state.isDragging) return
45
+ if (inputSystem.isPressed(InputAction.IA_WALK)) {
46
+ redo()
47
+ } else {
48
+ undo()
49
+ }
50
+ }
51
+ }
52
+
53
+ export function resetGizmoClickFlag() {
54
+ if (gizmoClickConsumed) setGizmoClickConsumed(false)
55
+ }
@@ -0,0 +1,114 @@
1
+ /** Pure math utilities — no ECS imports, fully unit-testable. */
2
+
3
+ import { Vector3 } from '@dcl/sdk/math'
4
+ import { Axis } from './state'
5
+
6
+ export function axisToVector(axis: Axis): Vector3 {
7
+ switch (axis) {
8
+ case 'x': return Vector3.Right()
9
+ case 'y': return Vector3.Up()
10
+ case 'z': return Vector3.Forward()
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Pick the drag plane normal most perpendicular to the camera forward.
16
+ * Avoids grazing-angle intersections.
17
+ *
18
+ * When worldAxis is provided (parent-rotated), candidates are computed
19
+ * as the two axes perpendicular to it. Otherwise falls back to world axes.
20
+ */
21
+ export function getDragPlaneNormal(axis: Axis, cameraForward: Vector3, worldAxis?: Vector3): Vector3 {
22
+ let candidates: Vector3[]
23
+
24
+ if (worldAxis) {
25
+ // Build two axes perpendicular to the (potentially rotated) drag axis
26
+ const up = Vector3.Up()
27
+ let perp1 = Vector3.cross(worldAxis, up)
28
+ if (Vector3.length(perp1) < 0.001) {
29
+ // worldAxis is nearly vertical — use Forward as fallback
30
+ perp1 = Vector3.cross(worldAxis, Vector3.Forward())
31
+ }
32
+ perp1 = Vector3.normalize(perp1)
33
+ const perp2 = Vector3.normalize(Vector3.cross(worldAxis, perp1))
34
+ candidates = [perp1, perp2]
35
+ } else {
36
+ const others = getOtherAxes(axis)
37
+ candidates = [axisToVector(others[0]), axisToVector(others[1])]
38
+ }
39
+
40
+ let best = candidates[0]
41
+ let bestDot = 0
42
+ for (const n of candidates) {
43
+ const d = Math.abs(Vector3.dot(cameraForward, n))
44
+ if (d > bestDot) { bestDot = d; best = n }
45
+ }
46
+ return best
47
+ }
48
+
49
+ /** Cast a ray against an infinite plane. Returns hit point or null. */
50
+ export function rayPlaneIntersect(
51
+ rayOrigin: Vector3, rayDir: Vector3, planePoint: Vector3, planeNormal: Vector3
52
+ ): Vector3 | null {
53
+ const denom = Vector3.dot(planeNormal, rayDir)
54
+ if (Math.abs(denom) < 1e-6) return null
55
+ const diff = Vector3.subtract(planePoint, rayOrigin)
56
+ const t = Vector3.dot(diff, planeNormal) / denom
57
+ if (t < 0) return null
58
+ return Vector3.add(rayOrigin, Vector3.scale(rayDir, t))
59
+ }
60
+
61
+ /** Compute the angle of a hit point on the rotation plane via atan2. */
62
+ export function hitAngleOnPlane(hit: Vector3, center: Vector3, axis: Axis): number {
63
+ const d = Vector3.subtract(hit, center)
64
+ switch (axis) {
65
+ case 'x': return Math.atan2(d.z, d.y)
66
+ case 'y': return Math.atan2(d.x, d.z)
67
+ case 'z': return Math.atan2(d.y, d.x)
68
+ }
69
+ }
70
+
71
+ export function copyVec3(dst: { x: number; y: number; z: number }, src: { x: number; y: number; z: number }) {
72
+ dst.x = src.x; dst.y = src.y; dst.z = src.z
73
+ }
74
+
75
+ export function copyQuat(dst: { x: number; y: number; z: number; w: number }, src: { x: number; y: number; z: number; w: number }) {
76
+ dst.x = src.x; dst.y = src.y; dst.z = src.z; dst.w = src.w
77
+ }
78
+
79
+ /**
80
+ * Compute the angle of a hit point on an arbitrarily oriented rotation plane.
81
+ * worldAxis is the plane normal (rotation axis in world space).
82
+ */
83
+ export function hitAngleOnWorldPlane(hit: Vector3, center: Vector3, worldAxis: Vector3): number {
84
+ const d = Vector3.subtract(hit, center)
85
+ // Build two perpendicular vectors in the plane
86
+ const up = Math.abs(Vector3.dot(worldAxis, Vector3.Up())) < 0.99
87
+ ? Vector3.Up() : Vector3.Forward()
88
+ const u = Vector3.normalize(Vector3.cross(worldAxis, up))
89
+ const v = Vector3.normalize(Vector3.cross(worldAxis, u))
90
+ return Math.atan2(Vector3.dot(d, v), Vector3.dot(d, u))
91
+ }
92
+
93
+ export function getOtherAxes(axis: Axis): [Axis, Axis] {
94
+ switch (axis) {
95
+ case 'x': return ['y', 'z']
96
+ case 'y': return ['x', 'z']
97
+ case 'z': return ['x', 'y']
98
+ }
99
+ }
100
+
101
+ export function applyFlatTransform(
102
+ t: { position: {x:number;y:number;z:number}; rotation: {x:number;y:number;z:number;w:number}; scale: {x:number;y:number;z:number} },
103
+ s: { px:number; py:number; pz:number; rx:number; ry:number; rz:number; rw:number; sx:number; sy:number; sz:number }
104
+ ) {
105
+ t.position.x = s.px; t.position.y = s.py; t.position.z = s.pz
106
+ t.rotation.x = s.rx; t.rotation.y = s.ry; t.rotation.z = s.rz; t.rotation.w = s.rw
107
+ t.scale.x = s.sx; t.scale.y = s.sy; t.scale.z = s.sz
108
+ }
109
+
110
+ /** Round a number to N decimal places. */
111
+ export function round(v: number, decimals: number = 2): number {
112
+ const f = Math.pow(10, decimals)
113
+ return Math.round(v * f) / f
114
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Persistence — saves entity transforms to the preview server via HTTP.
3
+ *
4
+ * POST {baseUrl}/editor/changes → merge an entity update into main-entities.ts
5
+ *
6
+ * The server (sdk-commands in preview, opendcl-studio in web) is responsible
7
+ * for writing main-entities.ts on disk and triggering main.crdt regeneration.
8
+ *
9
+ * Uses `signedFetch` instead of plain `fetch` because opendcl-studio
10
+ * auth-gates this endpoint (signer address must match the scene owner).
11
+ * In the CLI preview server the endpoint is unauthenticated; the extra
12
+ * AuthChain headers signedFetch sends are simply ignored there. So the
13
+ * same call shape works in both contexts.
14
+ */
15
+
16
+ import { Entity, Transform, engine, RealmInfo } from '@dcl/sdk/ecs'
17
+ import { signedFetch } from '~system/SignedFetch'
18
+ import { selectableInfoMap } from './state'
19
+ import { round } from './math-utils'
20
+
21
+ interface TransformPayload {
22
+ position: { x: number; y: number; z: number }
23
+ rotation: { x: number; y: number; z: number; w: number }
24
+ scale: { x: number; y: number; z: number }
25
+ }
26
+
27
+ interface ChangePayload {
28
+ components: { Transform: TransformPayload }
29
+ }
30
+
31
+ type ChangesMap = Record<string, ChangePayload>
32
+
33
+ // Resolve baseUrl lazily at each send. RealmInfo is populated by the
34
+ // runtime AFTER scene-module-load completes, so caching it once at
35
+ // init time was racing with module init and pinning `null` forever —
36
+ // the gizmo would work but every drag-end silently dropped the request.
37
+ export function initPersistence(): void {
38
+ // Kept for symmetry with the editor's bootstrap call; no longer
39
+ // resolves baseUrl up front (see comment above).
40
+ const baseUrl = RealmInfo.getOrNull(engine.RootEntity)?.baseUrl
41
+ console.log(`[editor] persistence ready (baseUrl=${baseUrl ?? 'unresolved'})`)
42
+ }
43
+
44
+ function resolveBaseUrl(): string | null {
45
+ return RealmInfo.getOrNull(engine.RootEntity)?.baseUrl ?? null
46
+ }
47
+
48
+ /** Send the current transform of an entity to the server for persistence. */
49
+ export function sendEntityUpdate(entity: Entity) {
50
+ const baseUrl = resolveBaseUrl()
51
+ if (!baseUrl) {
52
+ console.log('[editor] sendEntityUpdate: no realm baseUrl yet')
53
+ return
54
+ }
55
+ if (!Transform.has(entity)) return
56
+ const info = selectableInfoMap.get(entity)
57
+ if (!info) return
58
+
59
+ const t = Transform.get(entity)
60
+ const payload: ChangesMap = {
61
+ [info.name]: {
62
+ components: {
63
+ Transform: {
64
+ position: { x: round(t.position.x), y: round(t.position.y), z: round(t.position.z) },
65
+ rotation: {
66
+ x: round(t.rotation.x, 4),
67
+ y: round(t.rotation.y, 4),
68
+ z: round(t.rotation.z, 4),
69
+ w: round(t.rotation.w, 4),
70
+ },
71
+ scale: { x: round(t.scale.x), y: round(t.scale.y), z: round(t.scale.z) },
72
+ },
73
+ },
74
+ },
75
+ }
76
+
77
+ const url = `${baseUrl}/editor/changes`
78
+ const body = JSON.stringify(payload)
79
+
80
+ // signedFetch refuses non-https URLs by design. In local dev the studio
81
+ // serves http://localhost:3001 — fall back to plain fetch there. The
82
+ // server-side auth gate is correspondingly relaxed when DEV=true: in
83
+ // prod the editor-changes endpoint requires signedFetch + ownership.
84
+ if (url.startsWith('http://')) {
85
+ fetch(url, {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/json' },
88
+ body,
89
+ })
90
+ .then((res) => {
91
+ if (!res.ok) console.log(`[editor] save returned ${res.status}`)
92
+ })
93
+ .catch((e) => console.log(`[editor] save failed: ${e}`))
94
+ return
95
+ }
96
+
97
+ signedFetch({
98
+ url,
99
+ init: {
100
+ method: 'POST',
101
+ headers: { 'Content-Type': 'application/json' },
102
+ body,
103
+ },
104
+ })
105
+ .then((res) => {
106
+ // signedFetch does NOT reject on non-2xx — log status so silent
107
+ // 401 (signer ≠ scene owner) or 400 (validation) is visible.
108
+ if (res.status !== undefined && (res.status < 200 || res.status >= 300)) {
109
+ console.log(`[editor] save returned ${res.status}: ${res.body ?? ''}`)
110
+ }
111
+ })
112
+ .catch((e) => console.log(`[editor] save failed: ${e}`))
113
+ }
@@ -0,0 +1,157 @@
1
+ /** Select/deselect entities, highlight, collider management. */
2
+
3
+ import {
4
+ Entity,
5
+ Transform,
6
+ MeshCollider,
7
+ Material,
8
+ MaterialTransparencyMode,
9
+ GltfContainer,
10
+ GltfNodeModifiers,
11
+ ColliderLayer,
12
+ PointerEvents,
13
+ pointerEventsSystem,
14
+ InputAction,
15
+ } from '@dcl/sdk/ecs'
16
+ import { Color4, Color3 } from '@dcl/sdk/math'
17
+ import { state, selectableInfoMap, originalMaterials, gizmoClickConsumed } from './state'
18
+ import { createGizmo, destroyGizmo } from './gizmo'
19
+
20
+ const HIGHLIGHT_EMISSIVE = 0.6
21
+ const HIGHLIGHT_ALPHA = 0.35
22
+
23
+ /** Remove all collision so clicks pass through to gizmo handles behind the entity. */
24
+ function disableCollider(entity: Entity) {
25
+ if (MeshCollider.has(entity)) {
26
+ MeshCollider.deleteFrom(entity)
27
+ }
28
+ if (GltfContainer.has(entity)) {
29
+ const gltf = GltfContainer.getMutable(entity)
30
+ gltf.visibleMeshesCollisionMask = 0
31
+ gltf.invisibleMeshesCollisionMask = 0
32
+ }
33
+ }
34
+
35
+ function restoreCollider(entity: Entity) {
36
+ const info = selectableInfoMap.get(entity)
37
+ if (!info) return
38
+
39
+ switch (info.colliderShape) {
40
+ case 'box':
41
+ MeshCollider.setBox(entity, ColliderLayer.CL_POINTER)
42
+ break
43
+ case 'sphere':
44
+ MeshCollider.setSphere(entity, ColliderLayer.CL_POINTER)
45
+ break
46
+ case 'cylinder':
47
+ MeshCollider.setCylinder(entity, undefined, undefined, ColliderLayer.CL_POINTER)
48
+ break
49
+ }
50
+
51
+ if (GltfContainer.has(entity) && info.originalVisibleMask !== undefined) {
52
+ const gltf = GltfContainer.getMutable(entity)
53
+ gltf.visibleMeshesCollisionMask = info.originalVisibleMask
54
+ gltf.invisibleMeshesCollisionMask = info.originalInvisibleMask ?? 0
55
+ }
56
+ }
57
+
58
+ function highlight(entity: Entity) {
59
+ // GLB models: use GltfNodeModifiers with empty path to make all nodes semi-transparent
60
+ if (GltfContainer.has(entity)) {
61
+ GltfNodeModifiers.createOrReplace(entity, {
62
+ modifiers: [{
63
+ path: '',
64
+ material: {
65
+ material: {
66
+ $case: 'pbr' as const,
67
+ pbr: {
68
+ albedoColor: Color4.create(1, 1, 1, HIGHLIGHT_ALPHA),
69
+ transparencyMode: MaterialTransparencyMode.MTM_ALPHA_BLEND,
70
+ metallic: 0.1,
71
+ roughness: 0.5,
72
+ }
73
+ }
74
+ }
75
+ }]
76
+ })
77
+ return
78
+ }
79
+
80
+ // Primitives: override Material directly
81
+ const m = originalMaterials.get(entity)
82
+ if (!m) return
83
+ Material.setPbrMaterial(entity, {
84
+ albedoColor: Color4.create(m.r, m.g, m.b, HIGHLIGHT_ALPHA),
85
+ emissiveColor: Color3.create(m.r * 0.4, m.g * 0.4, m.b * 0.4),
86
+ emissiveIntensity: HIGHLIGHT_EMISSIVE,
87
+ metallic: 0.1,
88
+ roughness: 0.4,
89
+ transparencyMode: MaterialTransparencyMode.MTM_ALPHA_BLEND,
90
+ })
91
+ }
92
+
93
+ function unhighlight(entity: Entity) {
94
+ // GLB models: remove the modifier
95
+ if (GltfContainer.has(entity) && GltfNodeModifiers.has(entity)) {
96
+ GltfNodeModifiers.deleteFrom(entity)
97
+ return
98
+ }
99
+
100
+ // Primitives: restore original material
101
+ const m = originalMaterials.get(entity)
102
+ if (!m) return
103
+ Material.setPbrMaterial(entity, {
104
+ albedoColor: Color4.create(m.r, m.g, m.b, m.a),
105
+ metallic: 0.1,
106
+ roughness: 0.5,
107
+ })
108
+ }
109
+
110
+ export function selectEntity(entity: Entity) {
111
+ if (state.selectedEntity === entity) {
112
+ deselectEntity()
113
+ return
114
+ }
115
+
116
+ const info = selectableInfoMap.get(entity)
117
+ if (!info) return
118
+
119
+ // Deselect previous
120
+ if (state.selectedEntity !== undefined) {
121
+ deselectEntity()
122
+ }
123
+
124
+ state.selectedEntity = entity
125
+ state.selectedName = info.name
126
+ // Remove pointer events completely so model doesn't intercept gizmo clicks
127
+ pointerEventsSystem.removeOnPointerDown(entity)
128
+ if (PointerEvents.has(entity)) PointerEvents.deleteFrom(entity)
129
+ highlight(entity)
130
+ disableCollider(entity)
131
+ createGizmo()
132
+ console.log(`[editor] selected: ${info.name}`)
133
+ }
134
+
135
+ export function deselectEntity() {
136
+ if (state.selectedEntity === undefined) return
137
+
138
+ const entity = state.selectedEntity
139
+ const info = selectableInfoMap.get(entity)
140
+ unhighlight(entity)
141
+ restoreCollider(entity)
142
+ destroyGizmo()
143
+
144
+ // Restore pointer event so entity is clickable again
145
+ if (info) {
146
+ pointerEventsSystem.onPointerDown(
147
+ { entity, opts: { button: InputAction.IA_POINTER, hoverText: `Select ${info.name}`, maxDistance: 100 } },
148
+ () => {
149
+ if (!state.editorActive || state.isDragging || gizmoClickConsumed) return
150
+ selectEntity(entity)
151
+ }
152
+ )
153
+ }
154
+
155
+ state.selectedEntity = undefined
156
+ state.selectedName = ''
157
+ }
@@ -0,0 +1,117 @@
1
+ /** Shared editor state — the single source of truth for all editor modules. */
2
+
3
+ import { Entity } from '@dcl/sdk/ecs'
4
+ import { Vector3, Quaternion } from '@dcl/sdk/math'
5
+
6
+ export const EDITOR_VERSION = '0.6.0'
7
+
8
+ export type Axis = 'x' | 'y' | 'z'
9
+ export type GizmoMode = 'translate' | 'rotate'
10
+
11
+ /** Maximum depth for parent chain walking (prevents infinite loops). */
12
+ export const MAX_PARENT_DEPTH = 16
13
+
14
+ // ── Per-entity info (populated by discovery) ────────────
15
+
16
+ export interface SelectableInfo {
17
+ name: string
18
+ centerOffset: { x: number; y: number; z: number }
19
+ boundsSize: { x: number; y: number; z: number }
20
+ isModel: boolean
21
+ colliderShape: 'box' | 'sphere' | 'cylinder'
22
+ originalVisibleMask?: number
23
+ originalInvisibleMask?: number
24
+ src?: string
25
+ meshType?: 'box' | 'sphere' | 'cylinder'
26
+ parentEntity?: number
27
+ }
28
+
29
+ // ── Editor state ────────────────────────────────────────
30
+
31
+ export interface EditorState {
32
+ selectedEntity: Entity | undefined
33
+ selectedName: string
34
+ gizmoMode: GizmoMode
35
+ isDragging: boolean
36
+ dragAxis: Axis
37
+
38
+ // Translate drag
39
+ dragPlaneMode: Axis | undefined // when set, drag on plane perpendicular to this axis
40
+ dragStartPos: { x: number; y: number; z: number }
41
+ dragStartWorldPos: { x: number; y: number; z: number }
42
+ dragStartHit: { x: number; y: number; z: number }
43
+ dragPlaneNormal: { x: number; y: number; z: number }
44
+
45
+ // Rotate drag
46
+ dragStartRot: { x: number; y: number; z: number; w: number }
47
+ dragStartAngle: number
48
+ dragRotCenter: { x: number; y: number; z: number }
49
+
50
+ // Camera
51
+ editorCamActive: boolean
52
+
53
+ // Editor toggle (starts ON in preview)
54
+ editorActive: boolean
55
+
56
+ // Whether the scene is running in preview mode (set on init)
57
+ isPreview: boolean
58
+ }
59
+
60
+ export const state: EditorState = {
61
+ selectedEntity: undefined,
62
+ selectedName: '',
63
+ gizmoMode: 'translate',
64
+ isDragging: false,
65
+ dragPlaneMode: undefined,
66
+ dragAxis: 'x',
67
+ dragStartPos: Vector3.Zero(),
68
+ dragStartWorldPos: Vector3.Zero(),
69
+ dragStartHit: Vector3.Zero(),
70
+ dragPlaneNormal: Vector3.Up(),
71
+ dragStartRot: Quaternion.Identity(),
72
+ dragStartAngle: 0,
73
+ dragRotCenter: Vector3.Zero(),
74
+ editorCamActive: false,
75
+ editorActive: true,
76
+ isPreview: false,
77
+ }
78
+
79
+ // ── Entity tracking ─────────────────────────────────────
80
+
81
+ /** Entities created by the editor (gizmo, ground plane) — skipped by discovery. */
82
+ export const editorEntities = new Set<Entity>()
83
+
84
+ /** Discovered scene entities → their info. */
85
+ export const selectableInfoMap = new Map<Entity, SelectableInfo>()
86
+
87
+ /** Original material colors for primitive highlight/unhighlight. */
88
+ export const originalMaterials = new Map<Entity, { r: number; g: number; b: number; a: number }>()
89
+
90
+ // ── Gizmo entities ──────────────────────────────────────
91
+
92
+ export const gizmoEntities: Entity[] = []
93
+
94
+ export let gizmoRoot: Entity | undefined
95
+ export function setGizmoRoot(e: Entity | undefined) { gizmoRoot = e }
96
+
97
+ export const handleAxisMap = new Map<Entity, Axis>()
98
+ export const handleDiscMap = new Map<Entity, Entity>()
99
+ export const handleArrowMap = new Map<Entity, Entity[]>()
100
+
101
+ // ── Editor toggle callback ──────────────────────────────
102
+
103
+ /** Set by index.ts to handle toggle cleanup (deselect, camera off, etc.) */
104
+ let _onToggle: (() => void) | undefined
105
+ export function setToggleHandler(fn: () => void) { _onToggle = fn }
106
+
107
+ /** Toggle the editor on/off. Safe to call from UI — no circular deps. */
108
+ export function toggleEditorActive() {
109
+ if (!state.isPreview) return
110
+ if (_onToggle) _onToggle()
111
+ else state.editorActive = !state.editorActive
112
+ }
113
+
114
+ // ── Click consumption flag ──────────────────────────────
115
+
116
+ export let gizmoClickConsumed = false
117
+ export function setGizmoClickConsumed(v: boolean) { gizmoClickConsumed = v }