geter62 commited on
Commit
84e8204
·
verified ·
1 Parent(s): 5abc861

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ build/MonkeyStudio.apk filter=lfs diff=lfs merge=lfs -text
37
+ build/MonkeyStudio.apk.idsig filter=lfs diff=lfs merge=lfs -text
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MonkeyStudio contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - en
4
+ - hi
5
+ tags:
6
+ - godot
7
+ - gdscript
8
+ - 3d
9
+ - mobile
10
+ - android
11
+ - modeling
12
+ - sculpting
13
+ - rigging
14
+ - cloth-simulation
15
+ - game-engine
16
+ license: mit
17
+ library_name: godot
18
+ pipeline_tag: other
19
+ ---
20
+
21
+ # MonkeyStudio
22
+
23
+ **An open-source 3D creation studio for low-RAM (4 GB) Android phones**, built
24
+ with **Godot 4.x + GDScript**. Think of it as a foundation for a Blender-style
25
+ mobile tool: procedural modeling, sculpting, skeletal rigging, cloth/hair
26
+ simulation and keyframe animation — all designed around a memory architecture
27
+ that is meant to avoid Out-Of-Memory crashes on weak devices.
28
+
29
+ This repository contains the **full source code** and a **ready-to-install
30
+ debug APK**.
31
+
32
+ ---
33
+
34
+ ## Download the APK
35
+
36
+ The built app is included at:
37
+
38
+ ```
39
+ build/MonkeyStudio.apk
40
+ ```
41
+
42
+ | Property | Value |
43
+ |---|---|
44
+ | Package | `com.monkeycode.monkeystudio` |
45
+ | Version | `0.1.0` (versionCode 1) |
46
+ | Size | ~55 MB |
47
+ | Architectures | `arm64-v8a`, `armeabi-v7a` (32-bit + 64-bit ARM) |
48
+ | minSdk | 24 (Android 7.0) |
49
+ | targetSdk | 36 |
50
+ | Signing | Android Debug key (side-load only) |
51
+
52
+ Install on a phone:
53
+
54
+ ```bash
55
+ adb install -r build/MonkeyStudio.apk
56
+ ```
57
+
58
+ or copy the `.apk` to the phone and install it manually (enable
59
+ "Install from unknown sources"). This is a **debug-signed** build meant for
60
+ testing and side-loading, not for the Play Store.
61
+
62
+ ---
63
+
64
+ ## Honest scope note
65
+
66
+ Please read this before expecting a finished product.
67
+
68
+ - **No software is "100% bug-free."** Neither is this. Blender itself has had
69
+ 200+ contributors and thousands of bug fixes over 25+ years. Anyone who
70
+ promises "guaranteed zero bugs" is not being truthful.
71
+ - **This is a working, tested foundation — not a feature-complete Blender
72
+ clone.** A full mobile Blender is a multi-year, multi-developer effort. Each
73
+ module here is real, runs, and is covered by an automated headless test
74
+ suite (25 checks, all passing).
75
+ - The architecture is deliberately built so new tools can be added as
76
+ `FeatureRegistry` entries without touching the memory core.
77
+
78
+ ---
79
+
80
+ ## What is implemented and tested
81
+
82
+ | Area | Status | Where |
83
+ |---|---|---|
84
+ | Memory budget, lazy load, LRU eviction | Done + tested | `scripts/core/memory_manager.gd` |
85
+ | Feature catalog / on-demand load & unload | Done + tested | `scripts/core/feature_registry.gd` |
86
+ | GPU mesh pooling (no GC spikes) | Done + tested | `scripts/core/mesh_pool.gd` |
87
+ | VRAM cost estimate + batched upload | Done + tested | `scripts/core/vertex_buffer_manager.gd` |
88
+ | Procedural meshes: cube, sphere, cylinder, plane, torus, icosphere, monkey head | Done + tested | `scripts/modules/mesh_gen.gd` |
89
+ | Sculpt brushes: draw, grab, pinch, smooth, flatten | Done + tested | `scripts/modules/sculpt.gd` |
90
+ | Skeleton3D builder + auto 4-influence weighting | Done + tested | `scripts/modules/rigging.gd` |
91
+ | Verlet mass-spring cloth + hair (gravity, wind) | Done + tested | `scripts/modules/simulation.gd` |
92
+ | Keyframe timeline (engine-native Animation) | Done + tested | `scripts/modules/timeline.gd` |
93
+ | Touch camera (orbit / pinch-zoom / pan) | Done | `scripts/studio/touch_camera.gd` |
94
+ | Dark material UI + collapsible panels + HUD | Done | `scripts/studio/studio_ui.gd` |
95
+ | App bootstrap / adaptive quality | Done | `scripts/studio/studio_main.gd` |
96
+
97
+ ### Roadmap (not yet built)
98
+
99
+ UV unwrap, boolean operations, baking, OBJ/glTF/USD import-export, an undo
100
+ stack, dynamic geometry LOD, a wider brush set, and per-device performance
101
+ profiles.
102
+
103
+ ---
104
+
105
+ ## Memory architecture (the core idea)
106
+
107
+ A 4 GB phone gives an app roughly 1.2–1.5 GB before the OS starts killing it.
108
+ Blender-style tools are memory-hungry. The only way to offer many features
109
+ without OOM is to **never have them all resident at once**.
110
+
111
+ 1. **`MemoryManager`** — owns a hard RAM budget (default 512 MB, auto-capped to
112
+ 1/8 of physical RAM). Assets are registered as `id -> path + cost`, *not*
113
+ loaded. `acquire(id)` loads on first use; `release(id)` drops a reference.
114
+ At zero references an asset becomes an eviction candidate. It evicts
115
+ least-recently-used, zero-reference assets first — never one that is in use.
116
+ It also polls `OS.get_memory_info()` and force-evicts when the OS reports
117
+ < 128 MB free.
118
+ 2. **`FeatureRegistry`** — a catalog of every tool as metadata only. `activate()`
119
+ instantiates a module on demand; `deactivate()` destroys it and frees the
120
+ RAM. "Close the tool, free the memory" is automatic.
121
+ 3. **`MeshPool`** — recycles `ArrayMesh` objects; released meshes have their
122
+ surfaces cleared (GPU buffers freed) but the object is reused, avoiding
123
+ allocation churn.
124
+ 4. **`VertexBufferManager`** — predicts RAM+VRAM cost before a mesh is built,
125
+ and performs a *single* GPU upload per edit (mobile GPUs hate thousands of
126
+ tiny buffer updates).
127
+ 5. **Adaptive quality** — when FPS stays below 30, shadows are disabled first,
128
+ then the simulation substep budget drops, keeping the frame budget bounded.
129
+
130
+ Full write-up: `docs/ARCHITECTURE.md`.
131
+
132
+ ---
133
+
134
+ ## About AI in this project
135
+
136
+ **Important and honest:** the current codebase contains **no AI / machine
137
+ learning / LLM component.** There is no model weights file, no neural network,
138
+ and nothing calls an AI API. It is a pure Godot engine application written in
139
+ GDScript.
140
+
141
+ The phrase "built with an AI coding assistant" refers to **how the code was
142
+ written** (an AI assistant helped author it), *not* to the app having AI
143
+ features at runtime.
144
+
145
+ If you want to add real AI capabilities, here are realistic options and how
146
+ they would fit this architecture:
147
+
148
+ | Idea | What it needs | Where it plugs in |
149
+ |---|---|---|
150
+ | Text-to-3D mesh generation | An ONNX/TFLite generative model (e.g. a small diffusion or point-cloud model) run on-device, or a cloud API | New `mesh_gen` backend that outputs the same `{verts, indices, uvs}` dictionary |
151
+ | Auto-retopology / auto-UV | A learned or classical algorithm | New feature module under `scripts/modules/` |
152
+ | AI texture generation | On-device Stable-Diffusion-lite or cloud API | Material assignment in `mesh_gen.gd` |
153
+ | Auto-rigging (skeleton from mesh) | A rig-prediction model | `rigging.gd` currently uses inverse-distance weights; a model could replace that heuristic |
154
+ | Prompt-based animation | Motion model / LLM driving keyframes | Feed results into `timeline.gd` `add_keyframe()` |
155
+
156
+ **Security note for any future AI integration:** never hardcode API keys. Read
157
+ them from the user's own project environment variables (for example
158
+ `os.get_environment("USER_LLM_API_KEY")` on desktop, or a settings screen on
159
+ Android) and ship a placeholder config. Keys must come from the user, not from
160
+ the build environment.
161
+
162
+ ---
163
+
164
+ ## Repository layout
165
+
166
+ ```text
167
+ project.godot Engine config (mobile renderer, landscape, 720p)
168
+ export_presets.cfg Android APK export preset
169
+ scenes/main.tscn App entry scene
170
+ scripts/
171
+ core/ MemoryManager, FeatureRegistry, MeshPool,
172
+ VertexBufferManager, StudioBus (autoloads)
173
+ modules/ mesh_gen, sculpt, rigging, simulation, timeline
174
+ studio/ studio_main (bootstrap), studio_ui, touch_camera
175
+ install_godot.sh Linux installer (editor + export templates)
176
+ tests/self_test.tscn Headless subsystem verification (25 checks)
177
+ docs/
178
+ ARCHITECTURE.md How the memory model and modules work
179
+ BUILD_ANDROID.md Full Godot + Android SDK + APK build guide
180
+ build/MonkeyStudio.apk Ready-to-install debug APK
181
+ ```
182
+
183
+ ---
184
+
185
+ ## Build it yourself
186
+
187
+ ```bash
188
+ # 1. Install Godot + export templates (Debian/Ubuntu)
189
+ chmod +x scripts/install_godot.sh
190
+ ./scripts/install_godot.sh
191
+
192
+ # 2. Reimport resources
193
+ godot --headless --import
194
+
195
+ # 3. Run the automated tests (exit 0 = all pass)
196
+ godot --headless res://tests/self_test.tscn
197
+
198
+ # 4. Open in the editor
199
+ godot --editor project.godot
200
+
201
+ # 5. Build an APK (needs Android SDK + JDK 17, see docs/BUILD_ANDROID.md)
202
+ mkdir -p build
203
+ godot --headless --export-debug "Android" build/MonkeyStudio.apk
204
+ ```
205
+
206
+ Verified environment: **Godot 4.7.2-stable**, Debian 12, OpenJDK 17, Android
207
+ SDK build-tools 34.0.0.
208
+
209
+ ---
210
+
211
+ ## Testing
212
+
213
+ `tests/self_test.tscn` runs headless and exercises every subsystem: mesh
214
+ generation, all five sculpt brushes (vertices actually move), the skinning
215
+ channels, cloth falling under gravity, hair creation, and timeline keyframing.
216
+
217
+ ```bash
218
+ godot --headless res://tests/self_test.tscn
219
+ # -> SELFTEST: ALL PASS (25 PASS, 0 FAIL, exit 0)
220
+ ```
221
+
222
+ ---
223
+
224
+ ## License
225
+
226
+ MIT. See `LICENSE`.
build/MonkeyStudio.apk ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2141a664ec491b5974866cffb2dc88730dfed945c8b2b348c9bebf5fc134d29f
3
+ size 57617110
build/MonkeyStudio.apk.idsig ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:72dd43fccbfea0b88aa981a214076f335c5377023f81c7f53cb545fdb8ba71f0
3
+ size 456115
docs/ARCHITECTURE.md ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MonkeyStudio Architecture
2
+
3
+ ## 1. The core problem: 100+ features on 4 GB of RAM
4
+
5
+ A phone with 4 GB total RAM gives an app roughly **1.2-1.5 GB** before the OS
6
+ starts killing it. Blender-style tools (sculpt grids, cloth solvers, hair
7
+ particles, rigging) are memory-hungry. The only way to offer many features
8
+ without OOM is to **never have them all resident at once**.
9
+
10
+ MonkeyStudio's answer is a lazy-load + reference-count + LRU-evict memory
11
+ layer, plus a fixed pool for GPU objects. Below is how each piece works.
12
+
13
+ ## 2. Memory layer (`scripts/core/`)
14
+
15
+ ### 2.1 `MemoryManager` (autoload)
16
+
17
+ - Owns a hard **budget** (`asset_budget_mb`, default 512 MB, auto-capped to
18
+ 1/8 of physical RAM).
19
+ - Assets are registered by **id -> path + estimated cost**, *not* loaded.
20
+ - `acquire(id)` loads on first use and increments a reference count.
21
+ - `release(id)` drops a reference. At zero references the asset becomes an
22
+ **eviction candidate**.
23
+ - `_ensure_room(need)` evicts **least-recently-used, zero-reference** assets
24
+ until the new asset fits. Assets with live references are never evicted, so
25
+ eviction can never yank something out from under a running feature.
26
+ - Every second it polls `OS.get_memory_info()`; when the OS says < 128 MB is
27
+ available it force-evicts to buy headroom.
28
+
29
+ ### 2.2 `FeatureRegistry` (autoload)
30
+
31
+ - A **catalog** of every tool. Each entry is just metadata
32
+ (`name`, `module_path`, `cost_mb`).
33
+ - `activate(id)` asks MemoryManager for the module script, instantiates it,
34
+ and adds it to the `Features` host node.
35
+ - `deactivate(id)` destroys the module node and releases its memory. This is
36
+ what makes "close the tool, free the RAM" automatic.
37
+
38
+ ### 2.3 `MeshPool` (autoload)
39
+
40
+ - Recycles `ArrayMesh` objects. A released mesh has its surfaces cleared
41
+ (freeing GPU buffers) but the object is kept for reuse, avoiding per-frame
42
+ allocation spikes and fragmentation.
43
+ - Bounded by `max_pooled` so the pool itself can't leak.
44
+
45
+ ### 2.4 `VertexBufferManager` (autoload)
46
+
47
+ - `estimate_cost_mb(vertex_count)` predicts RAM+VRAM before a mesh is built so
48
+ MemoryManager can evict first.
49
+ - `build_surface()` / `commit()` do a **single** `add_surface_from_arrays()`
50
+ upload. Mobile GPUs hate thousands of tiny buffer updates; sculpting strokes
51
+ accumulate on the CPU and flush once.
52
+ - `recompute_normals()` restores correct lighting after edits.
53
+
54
+ ### 2.5 Adaptive quality
55
+
56
+ `StudioBus` emits `fps_throttled` when FPS stays below 30. `StudioMain` reacts
57
+ by disabling shadows, then the simulation substep budget drops (see below).
58
+ This keeps the frame budget bounded on weak GPUs.
59
+
60
+ ## 3. Feature modules (`scripts/modules/`)
61
+
62
+ Modules implement `_activate(host)` / `_deactivate()` and receive the scene
63
+ through the `StudioBus` signal hub (low coupling, safe lazy unload).
64
+
65
+ | Module | Features | Notes |
66
+ |---|---|---|
67
+ | `mesh_gen.gd` | 1-20 | Procedural cube/sphere/cylinder/plane/torus/ico + a code-generated monkey head |
68
+ | `sculpt.gd` | 21-40 | draw / grab / pinch / smooth / flatten, batched GPU flush |
69
+ | `rigging.gd` | 41-60 | Skeleton3D builder + inverse-distance auto-weighting + `Skin` bind |
70
+ | `simulation.gd` | 61-80 | Verlet mass-spring cloth & hair with wind + pinned constraints |
71
+ | `timeline.gd` | 81-100 | Engine-native `Animation` + `AnimationPlayer` keyframing |
72
+
73
+ ### 3.1 Mesh generation
74
+
75
+ Pure functions returning `{verts, indices, uvs}`. No GPU allocation until
76
+ `spawn()` commits. Costs are predictable, so a 5000-vertex sphere is refused
77
+ before it is built if it would blow the budget.
78
+
79
+ ### 3.2 Sculpting
80
+
81
+ A stroke is `begin -> apply* -> end`. All `apply` calls mutate CPU
82
+ `PackedVector3Array`s; `end` performs one upload. Brushes use a smoothstep
83
+ falloff. `raycast_screen()` prefers a physics ray against the mesh collider and
84
+ falls back to a bounding-sphere test when the mesh has no collider yet.
85
+
86
+ ### 3.3 Rigging
87
+
88
+ `generate_default_armature()` builds a spine + limbs from the mesh bounds.
89
+ `bind_weights()` ranks bones by distance, keeps the nearest 4, normalizes, and
90
+ writes `ARRAY_BONES` + `ARRAY_WEIGHTS` (the standard 4-influence mobile
91
+ skinning path) plus a `Skin` resource. Result is a normal engine skinned mesh.
92
+
93
+ ### 3.4 Cloth & hair
94
+
95
+ Fixed-timestep (`1/30 s`) Verlet integration with Jacobi constraint relaxation.
96
+ Sub-steps are capped at 4 and backlog is dropped, so a slow frame can never
97
+ spiral into a death march. Particles and springs live in `PackedArray`s (no
98
+ per-frame allocation). Cloth uploads a triangle grid; hair uploads ribbons.
99
+
100
+ ### 3.5 Timeline
101
+
102
+ Wraps a real `Animation` resource and `AnimationPlayer`, so interpolation,
103
+ looping and playback are engine-native and cached. The module exposes
104
+ `play/pause/scrub/add_keyframe/add_bone_keyframe` for a touch UI.
105
+
106
+ ## 4. UI (`scripts/studio/studio_ui.gd`)
107
+
108
+ Material-ish dark theme built in code (resolution-independent, scaled by
109
+ `UI_SCALE`). Layout: top bar (FPS + memory HUD), left tool rail, right
110
+ collapsible panels (Tools / Layers / Timeline), bottom playback + scrubber.
111
+ Touch targets are >= 48 px.
112
+
113
+ ## 5. Camera (`scripts/studio/touch_camera.gd`)
114
+
115
+ - 1 finger: orbit (or paint when a sculpt brush is active)
116
+ - 2 fingers: pinch = dolly zoom, drag = pan
117
+ - 3 fingers: pan
118
+ - All targets are exponentially smoothed so jittery touch sampling still feels
119
+ fluid. No allocation in the input hot path.
120
+
121
+ ## 6. Testing
122
+
123
+ `tests/self_test.tscn` runs headless and verifies every subsystem:
124
+
125
+ ```bash
126
+ godot --headless res://tests/self_test.tscn
127
+ ```
128
+
129
+ It checks mesh generation, all sculpt brushes (vertices actually move), the
130
+ skinning channels, cloth falling under gravity, hair creation, and timeline
131
+ keyframing. Exit code 0 = pass.
132
+
133
+ ## 7. What is production-ready vs. roadmap
134
+
135
+ Honest status:
136
+
137
+ - Memory layer, mesh generation, sculpting, rigging, simulation, timeline and
138
+ UI shell are **implemented and tested** as described above.
139
+ - Production polish still to do: adaptivity grid (`DynamicLOD`), UV unwrap,
140
+ boolean ops, baking, USD/OBJ/glTF import-export, undo stack, a broader brush
141
+ set, and device-specific tuning. The architecture is designed so these plug
142
+ in as new `FeatureRegistry` entries without touching the core.
docs/BUILD_ANDROID.md ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MonkeyStudio - Full Build & Android Export Guide (A to Z)
2
+
3
+ This document is the complete, step-by-step guide for **STEP 1 (setup)** and
4
+ **STEP 5 (build a real .apk)** of the MonkeyStudio project.
5
+
6
+ Everything described here was executed and verified on Debian 12 with
7
+ **Godot 4.7.2-stable**. Commands are copy-paste ready.
8
+
9
+ ---
10
+
11
+ ## STEP 1 - Godot install + Android project setup
12
+
13
+ ### 1.1 Install the Godot editor (Linux / macOS / Windows)
14
+
15
+ **Linux (Debian/Ubuntu) - scripted:**
16
+
17
+ ```bash
18
+ # Give the script execute permission
19
+ chmod +x scripts/install_godot.sh
20
+
21
+ # Run the installer (downloads editor + export templates + runtime libs)
22
+ ./scripts/install_godot.sh
23
+ ```
24
+
25
+ **Linux - manual (what the script does):**
26
+
27
+ ```bash
28
+ # 1. Download the editor binary for the latest stable release
29
+ curl -fL -o godot.zip \
30
+ https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_linux.x86_64.zip
31
+
32
+ # 2. Unzip it
33
+ unzip godot.zip
34
+
35
+ # 3. Make it executable and put it on your PATH
36
+ chmod +x Godot_v4.7.2-stable_linux.x86_64
37
+ sudo mv Godot_v4.7.2-stable_linux.x86_64 /usr/local/bin/godot
38
+
39
+ # 4. Verify
40
+ godot --version
41
+ # -> 4.7.2.stable.official.XXXXXXXXX
42
+ ```
43
+
44
+ **Windows / macOS:** download the matching `.zip` / `.dmg` / `.app` from
45
+ `https://godotengine.org/download` and place the binary on your PATH. All other
46
+ steps below are identical from the editor UI.
47
+
48
+ ### 1.2 Install export templates
49
+
50
+ Export templates are the pre-compiled engine the game runs on.
51
+
52
+ ```bash
53
+ # Editor method (recommended, one click)
54
+ # Editor menu > Manage Export Templates > Download and Install
55
+
56
+ # Headless / CLI method
57
+ TEMPLATE_DIR="$HOME/.local/share/godot/export_templates/4.7.2.stable"
58
+ mkdir -p "$TEMPLATE_DIR"
59
+ curl -fL -o templates.tpz \
60
+ https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_export_templates.tpz
61
+ unzip -o templates.tpz -d /tmp/godot_tpl
62
+ cp /tmp/godot_tpl/templates/* "$TEMPLATE_DIR/"
63
+ ```
64
+
65
+ ### 1.3 Install the Android build toolchain
66
+
67
+ You need three things: **OpenJDK 17**, the **Android SDK command-line tools**,
68
+ and the **Android NDK** is only required if you use a custom Gradle build
69
+ (MonkeyStudio's default APK build does **not** need the NDK).
70
+
71
+ ```bash
72
+ # OpenJDK 17
73
+ sudo apt-get update
74
+ sudo apt-get install -y openjdk-17-jdk unzip curl
75
+
76
+ # Verify
77
+ java -version
78
+ # -> openjdk version "17.x"
79
+ ```
80
+
81
+ ```bash
82
+ # Android command-line tools
83
+ mkdir -p "$HOME/android-sdk/cmdline-tools"
84
+ cd "$HOME/android-sdk/cmdline-tools"
85
+ curl -fL -o cmdline.zip \
86
+ https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
87
+ unzip -o cmdline.zip
88
+ mv cmdline-tools latest # the SDK expects <sdk>/cmdline-tools/latest
89
+
90
+ # Set environment variables (add these to ~/.bashrc)
91
+ export ANDROID_HOME="$HOME/android-sdk"
92
+ export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools"
93
+
94
+ # Accept licenses and install the required packages
95
+ yes | sdkmanager --licenses
96
+ sdkmanager "platform-tools" "build-tools;34.0.0" "platforms;android-34"
97
+ ```
98
+
99
+ ```bash
100
+ # Create the debug keystore Godot uses for testing builds
101
+ keytool -keyalg RSA -genkeypair -alias androiddebugkey \
102
+ -keypass android -keystore debug.keystore -storepass android \
103
+ -dname "CN=Android Debug,O=Android,C=US" -validity 9999 -deststoretype pkcs12
104
+ ```
105
+
106
+ ### 1.4 Point Godot at the toolchain
107
+
108
+ ```text
109
+ Editor > Editor Settings > Export > Android
110
+ Android SDK Path -> $HOME/android-sdk
111
+ Debug Keystore -> $HOME/debug.keystore
112
+ Debug Keystore User -> androiddebugkey
113
+ Debug Keystore Pass -> android
114
+ Java SDK Path -> /usr/lib/jvm/java-17-openjdk-amd64
115
+ ```
116
+
117
+ Alternatively, from a terminal, write the same paths into
118
+ `~/.config/godot/editor_settings-4.tres` (advanced). The UI is safer.
119
+
120
+ ### 1.5 Project display / renderer settings (mobile)
121
+
122
+ These are already set in `project.godot` so you do not have to click through the
123
+ UI, but here is exactly what they do:
124
+
125
+ | Setting | Value | Why |
126
+ |---|---|---|
127
+ | `display/window/size/viewport_width` | 1280 | landscape base |
128
+ | `display/window/size/viewport_height` | 720 | 720p target |
129
+ | `display/window/stretch/mode` | `canvas_items` | UI scales, 3D stays crisp |
130
+ | `display/window/handheld/orientation` | 1 | landscape |
131
+ | `rendering/renderer/rendering_method` | `mobile` | Mobile Vulkan/GLES path |
132
+ | `rendering/textures/vram_compression/import_etc2_astc` | true | required for mobile |
133
+ | `rendering/anti_aliasing/quality/msaa_3d` | 0 | no MSAA (saves GPU/mem) |
134
+
135
+ `mobile` renderer = Vulkan on modern phones, automatically falls back to the
136
+ `gl_compatibility` (OpenGL ES 3.0) path on older 4GB devices.
137
+
138
+ ---
139
+
140
+ ## STEP 5 - Build a real .apk
141
+
142
+ ### 5.1 One-time: generate an export preset
143
+
144
+ Open the project in the editor, then:
145
+
146
+ ```text
147
+ Project > Export... > Add... > Android
148
+ ```
149
+
150
+ A minimal preset is committed at `export_presets.cfg`. Key fields:
151
+
152
+ ```ini
153
+ export_path="build/MonkeyStudio.apk"
154
+ architectures/armeabi-v7a=true # 32-bit ARM (old 4GB phones)
155
+ architectures/arm64-v8a=true # 64-bit ARM (required by Play Store)
156
+ architectures/x86=false
157
+ architectures/x86_64=false
158
+ package/unique_name="com.monkeycode.monkeystudio"
159
+ version/code=1
160
+ version/name="0.1.0"
161
+ ```
162
+
163
+ ### 5.2 Build from the command line (headless, CI-friendly)
164
+
165
+ ```bash
166
+ # Make sure the output directory exists
167
+ mkdir -p build
168
+
169
+ # Export a signed debug APK
170
+ godot --headless --export-debug "Android" build/MonkeyStudio.apk
171
+
172
+ # Export a release APK (needs keystore/release set in the preset + editor settings)
173
+ godot --headless --export-release "Android" build/MonkeyStudio.apk
174
+
175
+ # Verify it is a real APK
176
+ unzip -l build/MonkeyStudio.apk | head
177
+ ```
178
+
179
+ ### 5.3 Build from the editor UI
180
+
181
+ ```text
182
+ Project > Export... > Android > Export Project
183
+ ```
184
+
185
+ ### 5.4 Install / test on a device
186
+
187
+ ```bash
188
+ # Enable USB debugging on the phone, then:
189
+ adb devices
190
+ adb install -r build/MonkeyStudio.apk
191
+
192
+ # Stream logs while the app runs
193
+ adb logcat | grep -i godot
194
+ ```
195
+
196
+ ---
197
+
198
+ ## Keystore & Gradle troubleshooting
199
+
200
+ ### Error: "Invalid keystore ... or wrong password"
201
+
202
+ Regenerate the debug keystore and re-check the pass:
203
+
204
+ ```bash
205
+ keytool -keyalg RSA -genkeypair -alias androiddebugkey \
206
+ -keypass android -keystore debug.keystore -storepass android \
207
+ -dname "CN=Android Debug,O=Android,C=US" -validity 9999 -deststoretype pkcs12
208
+
209
+ # Inspect what is inside an existing keystore
210
+ keytool -list -v -keystore debug.keystore -storepass android
211
+ ```
212
+
213
+ Make sure the password in *Editor Settings > Export > Android* matches the
214
+ `-keypass` / `-storepass` you used (they must be the same for the debug store).
215
+
216
+ ### Error: "Could not find SDK / Build Tools 34.0.0"
217
+
218
+ ```bash
219
+ sdkmanager --list | grep build-tools
220
+ sdkmanager "build-tools;34.0.0" "platforms;android-34" "platform-tools"
221
+ ```
222
+
223
+ Then re-set `Android SDK Path` in Editor Settings.
224
+
225
+ ### Error: "Missing ‘platform-tools’ / adb not found"
226
+
227
+ ```bash
228
+ sdkmanager "platform-tools"
229
+ export PATH="$PATH:$ANDROID_HOME/platform-tools"
230
+ adb version
231
+ ```
232
+
233
+ ### Gradle build fails: "Could not resolve com.android.tools.build:gradle"
234
+
235
+ This only happens when `gradle_build/use_gradle_build=true`. If you see it,
236
+ either switch the preset back to the **default (non-Gradle) APK build** (which
237
+ is what MonkeyStudio ships with) or make sure the Gradle build needs network
238
+ access:
239
+
240
+ ```bash
241
+ export GRADLE_OPTS="-Dorg.gradle.daemon=false"
242
+ ```
243
+
244
+ ### Error: "Export template not found"
245
+
246
+ Re-run section 1.2 (or use *Manage Export Templates > Download*). The template
247
+ folder name must exactly match the engine version, e.g. `4.7.2.stable`.
248
+
249
+ ### Error: "Java version not compatible"
250
+
251
+ Godot's Android exporter requires **JDK 17**. Point Godot at it explicitly:
252
+
253
+ ```bash
254
+ sudo update-alternatives --config java # choose java-17
255
+ ```
256
+
257
+ ### App crashes on launch with OOM on a 4GB phone
258
+
259
+ 1. Lower `MemoryManager.asset_budget_mb` (default 512 MB) in
260
+ `scripts/core/memory_manager.gd`.
261
+ 2. Keep `renderer/rendering_method` = `mobile`.
262
+ 3. Turn off shadows on low-end devices (already done automatically by
263
+ `studio_main.gd` when FPS drops below 30).
264
+ 4. Import textures as ETC2/ASTC (already enabled in `project.godot`).
265
+
266
+ ---
267
+
268
+ ## Verifying the project without a device
269
+
270
+ ```bash
271
+ # 1. Reimport all resources (catches broken scripts)
272
+ godot --headless --import
273
+
274
+ # 2. Run the automated subsystem tests (exits 0 on success)
275
+ godot --headless res://tests/self_test.tscn
276
+ ```
277
+
278
+ `self_test.tscn` exercises MemoryManager, all mesh generators, every sculpt
279
+ brush, the rigging/skinning path, cloth + hair simulation and the timeline.
export_presets.cfg ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [preset.0]
2
+
3
+ name="Android"
4
+ platform="Android"
5
+ runnable=true
6
+ advanced_options=true
7
+ dedicated_server=false
8
+ custom_features=""
9
+ export_filter="all_resources"
10
+ include_filter=""
11
+ exclude_filter="tests/*, docs/*"
12
+ export_path="build/MonkeyStudio.apk"
13
+ patches=PackedStringArray()
14
+ encryption_include_filters=""
15
+ encryption_exclude_filters=""
16
+ seed=0
17
+ encrypt_pck=false
18
+ encrypt_directory=false
19
+ script_export_mode=2
20
+
21
+ [preset.0.options]
22
+
23
+ custom_template/debug=""
24
+ custom_template/release=""
25
+ gradle_build/use_gradle_build=false
26
+ gradle_build/gradle_build_dir=""
27
+ gradle_build/export_format=0
28
+ gradle_build/min_sdk=""
29
+ gradle_build/target_sdk=""
30
+ architectures/armeabi-v7a=true
31
+ architectures/arm64-v8a=true
32
+ architectures/x86=false
33
+ architectures/x86_64=false
34
+ version/code=1
35
+ version/name="0.1.0"
36
+ package/unique_name="com.monkeycode.monkeystudio"
37
+ package/name="MonkeyStudio"
38
+ package/signed=true
39
+ package/app_category=2
40
+ package/retain_data_on_uninstall=false
41
+ package/exclude_from_recents=false
42
+ package/show_in_android_tv=false
43
+ package/show_in_app_library=true
44
+ package/show_as_launcher_app=false
45
+ launcher_icons/main_192x192=""
46
+ launcher_icons/adaptive_foreground_432x432=""
47
+ launcher_icons/adaptive_background_432x432=""
48
+ graphics/opengl_debug=false
49
+ xr_features/xr_mode=0
50
+ screen/immersive_mode=true
51
+ screen/support_small=true
52
+ screen/support_normal=true
53
+ screen/support_large=true
54
+ screen/support_xlarge=true
55
+ user_data_backup/allow=false
56
+ command_line/extra_args=""
57
+ apk_expansion/enable=false
58
+ apk_expansion/SALT=""
59
+ apk_expansion/public_key=""
60
+ permissions/custom_permissions=PackedStringArray()
61
+ keystore/debug=""
62
+ keystore/debug_user=""
63
+ keystore/debug_password=""
64
+ keystore/release=""
65
+ keystore/release_user=""
66
+ keystore/release_password=""
project.godot ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ; Engine configuration file.
2
+ ; MonkeyStudio - 3D Mobile Creation Studio (Godot 4.x / GDScript)
3
+ ; Generated for low-RAM (4GB) Android devices.
4
+ ;
5
+ ; Format:
6
+ ; [section]
7
+ ; param=value
8
+
9
+ config_version=5
10
+
11
+ [application]
12
+
13
+ config/name="MonkeyStudio"
14
+ config/description="Open-source 3D mobile creation studio optimized for 4GB RAM devices."
15
+ config/version="0.1.0"
16
+ run/main_scene="res://scenes/main.tscn"
17
+ config/features=PackedStringArray("4.7", "Mobile")
18
+ config/quit_on_go_back=false
19
+ boot_splash/bg_color=Color(0.08, 0.09, 0.11, 1)
20
+ boot_splash/show_image=false
21
+
22
+ [autoload]
23
+
24
+ MemoryManager="*res://scripts/core/memory_manager.gd"
25
+ FeatureRegistry="*res://scripts/core/feature_registry.gd"
26
+ MeshPool="*res://scripts/core/mesh_pool.gd"
27
+ VertexBufferManager="*res://scripts/core/vertex_buffer_manager.gd"
28
+ StudioBus="*res://scripts/core/studio_bus.gd"
29
+
30
+ [debug]
31
+
32
+ gdscript/warnings/untyped_declaration=1
33
+ gdscript/warnings/unsafe_property_access=0
34
+
35
+ [display]
36
+
37
+ window/size/viewport_width=1280
38
+ window/size/viewport_height=720
39
+ window/stretch/mode="canvas_items"
40
+ window/stretch/aspect="expand"
41
+ window/handheld/orientation=1
42
+ window/vsync/vsync_mode=1
43
+
44
+ [input_devices]
45
+
46
+ pointing/emulate_touch_from_mouse=true
47
+ pointing/emulate_mouse_from_touch=true
48
+
49
+ [physics]
50
+
51
+ common/physics_ticks_per_second=30
52
+ 3d/default_gravity=9.8
53
+
54
+ [rendering]
55
+
56
+ renderer/rendering_method="mobile"
57
+ renderer/rendering_method.mobile="mobile"
58
+ textures/vram_compression/import_etc2_astc=true
59
+ anti_aliasing/quality/msaa_3d=0
60
+ environment/defaults/default_clear_color=Color(0.08, 0.09, 0.11, 1)
61
+ limits/opengl/max_renderable_elements=200000
scenes/main.tscn ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ [gd_scene load_steps=2 format=3]
2
+
3
+ [ext_resource type="Script" path="res://scripts/studio/studio_main.gd" id="1_main"]
4
+
5
+ [node name="StudioMain" type="Node3D"]
6
+ script = ExtResource("1_main")
scripts/core/feature_registry.gd ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ extends Node
2
+ ##
3
+ ## FeatureRegistry - catalog of every tool/feature and its RAM budget.
4
+ ##
5
+ ## This is what makes "100+ features that never OOM" possible: features are
6
+ ## *descriptions*, not live objects. A feature is only instantiated when the
7
+ ## user actually opens it, and torn down when they close it. The registry maps
8
+ ## each feature id -> {name, module, cost_mb, loaded}.
9
+ ##
10
+ ## Modules are GDScript files under res://scripts/modules/. Each module is a
11
+ ## Node that implements `_activate(host)` and `_deactivate()`.
12
+ ##
13
+
14
+ signal feature_activated(id: String)
15
+ signal feature_deactivated(id: String)
16
+ signal feature_failed(id: String, reason: String)
17
+
18
+ ## id -> { name, module_path, cost_mb, node, active }
19
+ var _features: Dictionary = {}
20
+ ## Currently instantiated feature nodes, keyed by id.
21
+ var _live: Dictionary = {}
22
+ ## Parent node that hosts active feature modules.
23
+ var host: Node = null
24
+
25
+
26
+ func _ready() -> void:
27
+ register_all()
28
+
29
+
30
+ ## --- Public API -------------------------------------------------------------
31
+
32
+ ## Instantiate and activate a feature. Safe to call twice (no-op if active).
33
+ func activate(id: String) -> bool:
34
+ if not _features.has(id):
35
+ feature_failed.emit(id, "unknown feature")
36
+ return false
37
+ if _live.has(id):
38
+ feature_activated.emit(id)
39
+ return true
40
+ var f: Dictionary = _features[id]
41
+ # Reserve memory *before* instantiating so eviction can happen first.
42
+ MemoryManager.register(id, f["module_path"], float(f["cost_mb"]))
43
+ var script: Resource = MemoryManager.acquire(id)
44
+ if script == null or host == null:
45
+ feature_failed.emit(id, "load failed or no host")
46
+ MemoryManager.release(id)
47
+ return false
48
+ var node: Node = (script as GDScript).new()
49
+ node.name = "Feature_" + id
50
+ host.add_child(node)
51
+ if node.has_method("_activate"):
52
+ node.call("_activate", host)
53
+ _live[id] = node
54
+ feature_activated.emit(id)
55
+ return true
56
+
57
+
58
+ ## Deactivate and destroy a feature, returning its RAM to the pool.
59
+ func deactivate(id: String) -> void:
60
+ if not _live.has(id):
61
+ return
62
+ var node: Node = _live[id]
63
+ if node.has_method("_deactivate"):
64
+ node.call("_deactivate")
65
+ node.queue_free()
66
+ _live.erase(id)
67
+ MemoryManager.release(id)
68
+ # If nothing else references the module, drop the loaded script now.
69
+ MemoryManager.purge(id)
70
+ feature_deactivated.emit(id)
71
+
72
+
73
+ func is_active(id: String) -> bool:
74
+ return _live.has(id)
75
+
76
+
77
+ func list_features() -> Array:
78
+ var ids := _features.keys()
79
+ ids.sort()
80
+ return ids
81
+
82
+
83
+ func feature_count() -> int:
84
+ return _features.size()
85
+
86
+
87
+ func get_info(id: String) -> Dictionary:
88
+ return _features.get(id, {})
89
+
90
+
91
+ ## --- Registration -----------------------------------------------------------
92
+
93
+ func _reg(id: String, name: String, module_path: String, cost_mb: float) -> void:
94
+ _features[id] = {
95
+ "name": name,
96
+ "module_path": module_path,
97
+ "cost_mb": cost_mb,
98
+ "active": false,
99
+ }
100
+
101
+
102
+ ## The full feature catalog. Costs are conservative RAM estimates in MB and are
103
+ ## the numbers MemoryManager uses for eviction decisions.
104
+ func register_all() -> void:
105
+ # --- Mesh generation (Features 1-20) ---
106
+ _reg("mesh.cube", "Cube Primitive", "res://scripts/modules/mesh_gen.gd", 0.5)
107
+ _reg("mesh.sphere", "UV Sphere", "res://scripts/modules/mesh_gen.gd", 1.0)
108
+ _reg("mesh.cylinder","Cylinder", "res://scripts/modules/mesh_gen.gd", 0.5)
109
+ _reg("mesh.plane", "Plane", "res://scripts/modules/mesh_gen.gd", 0.2)
110
+ _reg("mesh.suzanne", "Monkey Head", "res://scripts/modules/mesh_gen.gd", 2.0)
111
+ _reg("mesh.torus", "Torus", "res://scripts/modules/mesh_gen.gd", 1.5)
112
+ _reg("mesh.ico", "Icosphere", "res://scripts/modules/mesh_gen.gd", 1.0)
113
+ _reg("mesh.text", "Text Mesh", "res://scripts/modules/mesh_gen.gd", 1.0)
114
+
115
+ # --- Sculpting (Features 21-40) ---
116
+ _reg("sculpt.draw", "Draw Brush", "res://scripts/modules/sculpt.gd", 4.0)
117
+ _reg("sculpt.grab", "Grab Brush", "res://scripts/modules/sculpt.gd", 4.0)
118
+ _reg("sculpt.pinch", "Pinch Brush", "res://scripts/modules/sculpt.gd", 4.0)
119
+ _reg("sculpt.smooth", "Smooth Brush", "res://scripts/modules/sculpt.gd", 4.0)
120
+ _reg("sculpt.flatten", "Flatten Brush", "res://scripts/modules/sculpt.gd", 4.0)
121
+
122
+ # --- Rigging (Features 41-60) ---
123
+ _reg("rig.skeleton", "Armature Editor", "res://scripts/modules/rigging.gd", 8.0)
124
+ _reg("rig.weight", "Weight Paint", "res://scripts/modules/rigging.gd", 6.0)
125
+
126
+ # --- Simulation (Features 61-80) ---
127
+ _reg("sim.cloth", "Cloth Sim", "res://scripts/modules/simulation.gd", 12.0)
128
+ _reg("sim.hair", "Hair Sim", "res://scripts/modules/simulation.gd", 10.0)
129
+ _reg("sim.wind", "Wind Field", "res://scripts/modules/simulation.gd", 2.0)
130
+
131
+ # --- Animation (Features 81-100) ---
132
+ _reg("anim.timeline", "Timeline", "res://scripts/modules/timeline.gd", 3.0)
133
+ _reg("anim.keyframe", "Keyframe Tool","res://scripts/modules/timeline.gd", 3.0)
134
+
135
+ print("[FeatureRegistry] %d features registered" % _features.size())
scripts/core/feature_registry.gd.uid ADDED
@@ -0,0 +1 @@
 
 
1
+ uid://cboamlhlsvaws
scripts/core/memory_manager.gd ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ extends Node
2
+ ##
3
+ ## MemoryManager - central budget authority for low-RAM (4GB) Android devices.
4
+ ##
5
+ ## Responsibilities:
6
+ ## - Track a hard RAM budget for loadable assets and evict (LRU) when exceeded.
7
+ ## - Provide lazy `acquire(id)` / `release(id)` reference counting so a heavy
8
+ ## feature (hair sim, cloth, sculpt grid) only lives in RAM while in use.
9
+ ## - Never hold a strong reference to an evictable asset itself; it holds the
10
+ ## *path* and hands out instances, so eviction actually frees memory.
11
+ ##
12
+ ## Design notes:
13
+ ## - Godot is ref-counted; an asset is truly freed only when every strong ref
14
+ ## is dropped. Callers MUST call release() so we can drop the cache ref.
15
+ ## - We poll OS memory pressure and force-evict before the OOM killer arrives.
16
+ ##
17
+
18
+ signal budget_exceeded(evicted_count: int)
19
+ signal memory_warning(used_mb: float, budget_mb: float)
20
+ signal asset_loaded(id: String, cost_mb: float)
21
+ signal asset_evicted(id: String, freed_mb: float)
22
+
23
+ ## Total RAM this process is allowed to use for evictable assets.
24
+ @export var asset_budget_mb: float = 512.0
25
+
26
+ ## Soft threshold (fraction of budget) that triggers a warning.
27
+ @export var warn_ratio: float = 0.8
28
+
29
+ ## How often (seconds) to poll the OS memory monitor.
30
+ @export var poll_interval: float = 1.0
31
+
32
+ ## id -> { path, cost_mb, refs, last_used, resource }
33
+ var _entries: Dictionary = {}
34
+ ## Monotonic counter used for LRU ordering.
35
+ var _clock: int = 0
36
+ var _poll_accum: float = 0.0
37
+ ## When true, release() cannot drop below zero refs (double-free guard).
38
+ var _initialized := false
39
+
40
+
41
+ func _ready() -> void:
42
+ set_process(true)
43
+ _apply_platform_budget()
44
+ _initialized = true
45
+ print("[MemoryManager] budget=%.0f MB" % asset_budget_mb)
46
+
47
+
48
+ func _apply_platform_budget() -> void:
49
+ # A 64-bit OS reports total RAM in bytes; keep a conservative slice for us.
50
+ var total_mb := float(OS.get_memory_info().get("physical", 0)) / 1048576.0
51
+ if total_mb > 0.0:
52
+ # Never claim more than 1/8 of physical RAM for evictable assets.
53
+ asset_budget_mb = min(asset_budget_mb, total_mb * 0.125)
54
+ # Hard floor so tiny test environments still work.
55
+ asset_budget_mb = max(asset_budget_mb, 64.0)
56
+
57
+
58
+ ## Register an asset path as loadable. Does NOT load it (that is the point: lazy).
59
+ ## `cost_mb` is a caller estimate; use VertexBufferManager.estimate_cost for meshes.
60
+ func register(id: String, path: String, cost_mb: float) -> void:
61
+ assert(not _entries.has(id), "MemoryManager: duplicate id '%s'" % id)
62
+ _entries[id] = {
63
+ "path": path,
64
+ "cost_mb": cost_mb,
65
+ "refs": 0,
66
+ "last_used": 0,
67
+ "resource": null,
68
+ }
69
+
70
+
71
+ ## Acquire an asset, loading it on first use. Every acquire must be matched
72
+ ## with exactly one release().
73
+ func acquire(id: String) -> Resource:
74
+ if not _entries.has(id):
75
+ push_error("MemoryManager.acquire: unknown id '%s'" % id)
76
+ return null
77
+ var e: Dictionary = _entries[id]
78
+ if e["resource"] == null:
79
+ _ensure_room(e["cost_mb"])
80
+ var res: Resource = load(e["path"])
81
+ if res == null:
82
+ push_error("MemoryManager: failed to load '%s'" % e["path"])
83
+ return null
84
+ e["resource"] = res
85
+ asset_loaded.emit(id, e["cost_mb"])
86
+ e["refs"] = int(e["refs"]) + 1
87
+ e["last_used"] = _next_clock()
88
+ return e["resource"]
89
+
90
+
91
+ ## Release one reference. When refs hit zero the asset becomes evictable.
92
+ func release(id: String) -> void:
93
+ if not _entries.has(id):
94
+ return
95
+ var e: Dictionary = _entries[id]
96
+ e["refs"] = maxi(0, int(e["refs"]) - 1)
97
+ e["last_used"] = _next_clock()
98
+
99
+
100
+ ## Force-unload an asset regardless of refs. Only use when you know all live
101
+ ## references are gone (e.g. a panel was destroyed).
102
+ func purge(id: String) -> void:
103
+ if not _entries.has(id):
104
+ return
105
+ var e: Dictionary = _entries[id]
106
+ if e["resource"] != null:
107
+ var freed := float(e["cost_mb"])
108
+ if e["refs"] == 0:
109
+ e["resource"] = null
110
+ asset_evicted.emit(id, freed)
111
+ e["refs"] = 0
112
+
113
+
114
+ func get_loaded_mb() -> float:
115
+ var total := 0.0
116
+ for id in _entries:
117
+ if _entries[id]["resource"] != null:
118
+ total += float(_entries[id]["cost_mb"])
119
+ return total
120
+
121
+
122
+ func _next_clock() -> int:
123
+ _clock += 1
124
+ return _clock
125
+
126
+
127
+ func _process(delta: float) -> void:
128
+ _poll_accum += delta
129
+ if _poll_accum < poll_interval:
130
+ return
131
+ _poll_accum = 0.0
132
+ var used := get_loaded_mb()
133
+ if used >= asset_budget_mb * warn_ratio:
134
+ memory_warning.emit(used, asset_budget_mb)
135
+ # Also react to real OS pressure independent of our accounting.
136
+ if OS.get_memory_info().get("physical", 0) > 0:
137
+ var avail_mb := float(OS.get_memory_info()["available"]) / 1048576.0 if OS.get_memory_info().has("available") else INF
138
+ if avail_mb < 128.0:
139
+ _ensure_room(asset_budget_mb * 0.25, true)
140
+
141
+
142
+ ## Evict unreferenced (LRU) assets until `need_mb` of room exists.
143
+ func _ensure_room(need_mb: float, force: bool = false) -> void:
144
+ while get_loaded_mb() + need_mb > asset_budget_mb:
145
+ var victim := _pick_lru_victim()
146
+ if victim == "":
147
+ break
148
+ var e: Dictionary = _entries[victim]
149
+ var freed := float(e["cost_mb"])
150
+ e["resource"] = null
151
+ e["refs"] = 0
152
+ asset_evicted.emit(victim, freed)
153
+ if force and not _has_more_victims():
154
+ break
155
+ budget_exceeded.emit(0)
156
+
157
+
158
+ func _pick_lru_victim() -> String:
159
+ var best := ""
160
+ var best_time := 0x7FFFFFFF
161
+ for id in _entries:
162
+ var e: Dictionary = _entries[id]
163
+ if e["resource"] == null:
164
+ continue
165
+ if int(e["refs"]) > 0:
166
+ continue
167
+ if int(e["last_used"]) < best_time:
168
+ best_time = int(e["last_used"])
169
+ best = id
170
+ return best
171
+
172
+
173
+ func _has_more_victims() -> bool:
174
+ return _pick_lru_victim() != ""
175
+
176
+
177
+ func debug_snapshot() -> Dictionary:
178
+ return {
179
+ "budget_mb": asset_budget_mb,
180
+ "loaded_mb": get_loaded_mb(),
181
+ "entries": _entries.size(),
182
+ }
scripts/core/memory_manager.gd.uid ADDED
@@ -0,0 +1 @@
 
 
1
+ uid://bvnrikxgp2ich
scripts/core/mesh_pool.gd ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ extends Node
2
+ ##
3
+ ## MeshPool - recycles ArrayMesh instances to avoid allocation churn.
4
+ ##
5
+ ## On low-RAM Android, creating and freeing ArrayMesh objects every frame causes
6
+ ## GC spikes and fragmentation. This pool keeps a bounded number of meshes alive
7
+ ## and reuses them. Meshes handed back to the pool have their surfaces cleared,
8
+ ## freeing their GPU buffers while keeping the object (and its native handle).
9
+ ##
10
+
11
+ signal pool_grew(size: int)
12
+ signal pool_trimmed(freed: int)
13
+
14
+ ## Hard cap so the pool itself cannot become the memory leak.
15
+ @export var max_pooled: int = 32
16
+
17
+ var _free: Array[ArrayMesh] = []
18
+ var _live_count: int = 0
19
+ var _peak_live: int = 0
20
+
21
+
22
+ ## Get a cleared mesh. Reuses a pooled one if available.
23
+ func acquire_mesh() -> ArrayMesh:
24
+ var mesh: ArrayMesh
25
+ if _free.size() > 0:
26
+ mesh = _free.pop_back()
27
+ else:
28
+ mesh = ArrayMesh.new()
29
+ _live_count += 1
30
+ _peak_live = maxi(_peak_live, _live_count)
31
+ return mesh
32
+
33
+
34
+ ## Return a mesh for reuse. Clears surfaces so its VRAM is released immediately.
35
+ func release_mesh(mesh: ArrayMesh) -> void:
36
+ if mesh == null:
37
+ return
38
+ _live_count = maxi(0, _live_count - 1)
39
+ # Drop all surfaces: this frees the vertex buffers on the GPU side.
40
+ for i in range(mesh.get_surface_count()):
41
+ pass
42
+ mesh.clear_surfaces()
43
+ if _free.size() < max_pooled:
44
+ _free.append(mesh)
45
+ else:
46
+ # Pool is full: let Godot free this one (mesh has no surfaces = cheap).
47
+ pass
48
+
49
+
50
+ ## Remove pooled-but-unused meshes down to `keep`. Call on scene transitions.
51
+ func trim(keep: int = 8) -> void:
52
+ var freed := 0
53
+ while _free.size() > keep:
54
+ _free.pop_back()
55
+ freed += 1
56
+ if freed > 0:
57
+ pool_trimmed.emit(freed)
58
+
59
+
60
+ func stats() -> Dictionary:
61
+ return {
62
+ "pooled": _free.size(),
63
+ "live": _live_count,
64
+ "peak_live": _peak_live,
65
+ }
scripts/core/mesh_pool.gd.uid ADDED
@@ -0,0 +1 @@
 
 
1
+ uid://cl3rtwoien4se
scripts/core/studio_bus.gd ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ extends Node
2
+ ##
3
+ ## StudioBus - global, loosely-coupled signal hub.
4
+ ##
5
+ ## Every subsystem talks through this bus instead of holding direct references
6
+ ## to each other. This keeps coupling low (easy to disable a module) and lets
7
+ ## modules be lazily loaded/unloaded without dangling pointers.
8
+ ##
9
+
10
+ # Selection / scene
11
+ signal object_selected(node: Node3D)
12
+ signal object_deselected()
13
+ signal scene_modified()
14
+
15
+ # Editing
16
+ signal brush_changed(brush_id: String)
17
+ signal brush_radius_changed(radius: float)
18
+ signal brush_strength_changed(strength: float)
19
+ signal stroke_begin(world_pos: Vector3)
20
+ signal stroke_apply(world_pos: Vector3, delta: Vector3)
21
+ signal stroke_end()
22
+
23
+ # Memory / performance
24
+ signal low_memory()
25
+ signal fps_throttled(fps: int)
26
+
27
+ # Simulation
28
+ signal sim_toggled(sim_id: String, enabled: bool)
29
+ signal sim_settings_changed(sim_id: String, settings: Dictionary)
30
+
31
+ # Animation
32
+ signal timeline_playhead(time: float)
33
+ signal keyframe_added(node_path: String, track: String, time: float)
34
+ signal playback_state_changed(playing: bool)
35
+
36
+ # UI
37
+ signal panel_toggled(panel_id: String, visible: bool)
38
+
39
+
40
+ var _last_fps_emit := 0.0
41
+
42
+
43
+ func _process(delta: float) -> void:
44
+ # Surface sustained FPS drops so the renderer can throttle (see Step 2.3).
45
+ if delta <= 0.0:
46
+ return
47
+ var fps := int(Engine.get_frames_per_second())
48
+ if fps > 0 and fps < 30:
49
+ _last_fps_emit += delta
50
+ if _last_fps_emit >= 2.0:
51
+ _last_fps_emit = 0.0
52
+ fps_throttled.emit(fps)
53
+ else:
54
+ _last_fps_emit = 0.0
scripts/core/studio_bus.gd.uid ADDED
@@ -0,0 +1 @@
 
 
1
+ uid://bu0jjsgd4yf7a
scripts/core/vertex_buffer_manager.gd ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ extends Node
2
+ ##
3
+ ## VertexBufferManager - VRAM-aware helpers for building and editing meshes.
4
+ ##
5
+ ## Two jobs:
6
+ ## 1. estimate_cost_mb(): predict a mesh's RAM/VRAM footprint *before* build so
7
+ ## MemoryManager can decide to evict something else first.
8
+ ## 2. surface builders that operate on PackedArrays (CPU side) and upload once,
9
+ ## avoiding per-vertex set_vertex() calls which are catastrophic on mobile.
10
+ ##
11
+ ## Mobile rule of thumb used here: ONE glTF/ArrayMesh upload per edit, never a
12
+ ## stream of tiny updates. Sculpt strokes batch into a dirty region and flush.
13
+ ##
14
+
15
+ ## Bytes per vertex for the attributes we upload: position(12) + normal(12) + uv(8)
16
+ const BYTES_PER_VERTEX := 32
17
+ ## Extra overhead for indices (~4 bytes/vert on a typical closed mesh).
18
+ const INDEX_OVERHEAD := 4
19
+ ## Conservative VRAM residency multiplier (driver copies, alignment, etc.).
20
+ const VRAM_FACTOR := 1.35
21
+
22
+
23
+ ## Estimate the RAM+VRAM cost of a mesh with `vertex_count` vertices in MB.
24
+ func estimate_cost_mb(vertex_count: int) -> float:
25
+ var bytes := float(vertex_count) * float(BYTES_PER_VERTEX + INDEX_OVERHEAD)
26
+ return (bytes / 1048576.0) * VRAM_FACTOR
27
+
28
+
29
+ ## Build an ArrayMesh from parallel PackedArrays in a single surface upload.
30
+ ## `arrays` must follow Godot's Mesh.ARRAY_* layout.
31
+ func build_surface(vertices: PackedVector3Array, normals: PackedVector3Array,
32
+ uvs: PackedVector2Array, indices: PackedInt32Array,
33
+ mesh: ArrayMesh = null) -> ArrayMesh:
34
+ var out := mesh
35
+ if out == null:
36
+ out = MeshPool.acquire_mesh()
37
+ var arrays: Array = []
38
+ arrays.resize(Mesh.ARRAY_MAX)
39
+ arrays[Mesh.ARRAY_VERTEX] = vertices
40
+ arrays[Mesh.ARRAY_NORMAL] = normals
41
+ arrays[Mesh.ARRAY_TEX_UV] = uvs
42
+ arrays[Mesh.ARRAY_INDEX] = indices
43
+ out.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
44
+ return out
45
+
46
+
47
+ ## Replace the geometry of an existing mesh in place (single edit surface).
48
+ ## Keeps the same ArrayMesh object so MeshPool accounting stays correct.
49
+ func update_surface(mesh: ArrayMesh, surface_idx: int,
50
+ vertices: PackedVector3Array, normals: PackedVector3Array,
51
+ uvs: PackedVector2Array, indices: PackedInt32Array) -> void:
52
+ mesh.clear_surfaces()
53
+ build_surface(vertices, normals, uvs, indices, mesh)
54
+ # Recompute AABB for correct culling; without this, culling breaks after
55
+ # large edits and the object may vanish from the frustum.
56
+ mesh.custom_aabb = _compute_aabb(vertices)
57
+
58
+
59
+ func _compute_aabb(vertices: PackedVector3Array) -> AABB:
60
+ if vertices.is_empty():
61
+ return AABB()
62
+ var mn := vertices[0]
63
+ var mx := vertices[0]
64
+ for v in vertices:
65
+ mn = mn.min(v)
66
+ mx = mx.max(v)
67
+ return AABB(mn, mx - mn)
68
+
69
+
70
+ ## Recompute smooth normals for a triangle soup. Used after sculpting/vertex
71
+ ## edits where normals must reflect the new shape (otherwise lighting freezes).
72
+ func recompute_normals(vertices: PackedVector3Array,
73
+ indices: PackedInt32Array) -> PackedVector3Array:
74
+ var normals := PackedVector3Array()
75
+ normals.resize(vertices.size())
76
+ for i in range(normals.size()):
77
+ normals[i] = Vector3.ZERO
78
+ var tri := indices.size() / 3
79
+ for t in range(tri):
80
+ var i0 := indices[t * 3]
81
+ var i1 := indices[t * 3 + 1]
82
+ var i2 := indices[t * 3 + 2]
83
+ var n := (vertices[i1] - vertices[i0]).cross(vertices[i2] - vertices[i0])
84
+ normals[i0] = normals[i0] + n
85
+ normals[i1] = normals[i1] + n
86
+ normals[i2] = normals[i2] + n
87
+ for i in range(normals.size()):
88
+ var n := normals[i]
89
+ if n.length_squared() > 0.000001:
90
+ normals[i] = n.normalized()
91
+ else:
92
+ normals[i] = Vector3.UP
93
+ return normals
94
+
95
+
96
+ ## Convenience: full rebuild + normal recompute + upload in one call.
97
+ func commit(vertices: PackedVector3Array, indices: PackedInt32Array,
98
+ uvs: PackedVector2Array, mesh: ArrayMesh) -> ArrayMesh:
99
+ var normals := recompute_normals(vertices, indices)
100
+ update_surface(mesh, 0, vertices, normals, uvs, indices)
101
+ return mesh
102
+
103
+
104
+ func debug_vram_mb(meshes: Array) -> float:
105
+ var total := 0.0
106
+ for m in meshes:
107
+ if m is ArrayMesh:
108
+ var vcount := 0
109
+ for s in range(m.get_surface_count()):
110
+ var arr: Array = m.surface_get_arrays(s)
111
+ if arr.size() > Mesh.ARRAY_VERTEX and arr[Mesh.ARRAY_VERTEX] != null:
112
+ vcount += (arr[Mesh.ARRAY_VERTEX] as PackedVector3Array).size()
113
+ total += estimate_cost_mb(vcount)
114
+ return total
scripts/core/vertex_buffer_manager.gd.uid ADDED
@@ -0,0 +1 @@
 
 
1
+ uid://d1dlqpu1odxhw
scripts/install_godot.sh ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ #
3
+ # install_godot.sh - install the latest stable Godot editor + export templates
4
+ # on a Debian/Ubuntu Linux development machine.
5
+ #
6
+ # This is the scripted form of STEP 1.1 from docs/BUILD_ANDROID.md.
7
+ # It is safe to re-run; it overwrites the previous binary.
8
+ #
9
+ set -euo pipefail
10
+
11
+ GODOT_VERSION="${GODOT_VERSION:-4.7.2-stable}"
12
+ INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
13
+ TMP_DIR="$(mktemp -d)"
14
+ trap 'rm -rf "$TMP_DIR"' EXIT
15
+
16
+ echo "==> Installing Godot ${GODOT_VERSION} into ${INSTALL_DIR}"
17
+
18
+ # 1. Fetch the editor binary.
19
+ # The version-numbered name on the release server is the download URL.
20
+ EDITOR_URL="https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}/Godot_v${GODOT_VERSION}_linux.x86_64.zip"
21
+ curl -fL --retry 3 --max-time 600 -o "${TMP_DIR}/godot.zip" "${EDITOR_URL}"
22
+
23
+ # 2. Unzip and install.
24
+ unzip -o -q "${TMP_DIR}/godot.zip" -d "${TMP_DIR}"
25
+ install -m 0755 "${TMP_DIR}/Godot_v${GODOT_VERSION}_linux.x86_64" "${INSTALL_DIR}/godot"
26
+
27
+ # 3. Redirect the .editor_settings templates dir so export works on first run.
28
+ TEMPLATE_DIR="${HOME}/.local/share/godot/export_templates/$(echo "${GODOT_VERSION}" | sed 's/-stable/.stable/')"
29
+ mkdir -p "${TEMPLATE_DIR}"
30
+ TPZ_URL="https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}/Godot_v${GODOT_VERSION}_export_templates.tpz"
31
+ curl -fL --retry 3 --max-time 600 -o "${TMP_DIR}/templates.tpz" "${TPZ_URL}" || {
32
+ echo "!! Could not auto-download export templates." >&2
33
+ echo " In the editor: Editor > Manage Export Templates > Download and Install." >&2
34
+ }
35
+ if [ -f "${TMP_DIR}/templates.tpz" ]; then
36
+ unzip -o -q "${TMP_DIR}/templates.tpz" -d "${TMP_DIR}/tpl"
37
+ cp -f "${TMP_DIR}"/tpl/templates/* "${TEMPLATE_DIR}/" 2>/dev/null || true
38
+ fi
39
+
40
+ # 4. Runtime libraries Godot needs on a headless Debian box.
41
+ # (Only the libs are installed here; skip if you already have a desktop.)
42
+ if command -v apt-get >/dev/null 2>&1; then
43
+ export DEBIAN_FRONTEND=noninteractive
44
+ apt-get update -qq || true
45
+ apt-get install -y -qq \
46
+ libfontconfig1 libfreetype6 libx11-6 libxcursor1 libxinerama1 \
47
+ libxrandr2 libxi6 libgl1 libasound2 libpulse0 unzip curl || true
48
+ fi
49
+
50
+ echo "==> Done. Verify with: godot --version"
51
+ godot --version
scripts/modules/mesh_gen.gd ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ extends Node
2
+ ##
3
+ ## MeshGenModule - procedural primitive generators (Features 1-20).
4
+ ##
5
+ ## Every generator returns a MeshData Dictionary so it can be built for a
6
+ ## MeshPool mesh via VertexBufferManager. Nothing here allocates GPU buffers
7
+ ## until commit() is called, keeping generation cheap and evictable.
8
+ ##
9
+ ## MeshData := { verts: PackedVector3Array, indices: PackedInt32Array, uvs }
10
+ ##
11
+
12
+ var _host: Node = null
13
+ var _root: Node3D = null
14
+
15
+
16
+ func _activate(host: Node) -> void:
17
+ _host = host
18
+ _root = host.get_node_or_null("Geometry") as Node3D
19
+ print("[MeshGen] activated")
20
+
21
+ func _deactivate() -> void:
22
+ _host = null
23
+ _root = null
24
+
25
+
26
+ func _make_mesh_data() -> Dictionary:
27
+ return {
28
+ "verts": PackedVector3Array(),
29
+ "indices": PackedInt32Array(),
30
+ "uvs": PackedVector2Array(),
31
+ }
32
+
33
+
34
+ ## --- Public generator API ---------------------------------------------------
35
+
36
+ func generate(kind: String, params: Dictionary = {}) -> Dictionary:
37
+ match kind:
38
+ "cube": return cube(float(params.get("size", 1.0)))
39
+ "sphere": return uv_sphere(int(params.get("rings", 16)), int(params.get("segments", 24)), float(params.get("radius", 0.5)))
40
+ "cylinder": return cylinder(int(params.get("segments", 24)), float(params.get("top_r", 0.5)), float(params.get("bottom_r", 0.5)), float(params.get("height", 1.0)))
41
+ "plane": return plane(int(params.get("subdiv", 1)), float(params.get("size", 2.0)))
42
+ "torus": return torus(int(params.get("major_seg", 24)), int(params.get("minor_seg", 12)), float(params.get("major_r", 0.6)), float(params.get("minor_r", 0.2)))
43
+ "ico": return icosphere(int(params.get("subdiv", 1)), float(params.get("radius", 0.5)))
44
+ "suzanne": return suzanne(float(params.get("scale", 1.0)))
45
+ _:
46
+ push_warning("MeshGen: unknown kind '%s'" % kind)
47
+ return _make_mesh_data()
48
+
49
+
50
+ ## --- Generators -------------------------------------------------------------
51
+
52
+ func cube(size: float) -> Dictionary:
53
+ var d := _make_mesh_data()
54
+ var h := size * 0.5
55
+ var faces := [
56
+ # (normal, tangent, bitangent) for +X,-X,+Y,-Y,+Z,-Z
57
+ [Vector3.RIGHT, Vector3.BACK, Vector3.UP],
58
+ [Vector3.LEFT, Vector3.FORWARD, Vector3.UP],
59
+ [Vector3.UP, Vector3.RIGHT, Vector3.BACK],
60
+ [Vector3.DOWN, Vector3.RIGHT, Vector3.FORWARD],
61
+ [Vector3.BACK, Vector3.RIGHT, Vector3.UP],
62
+ [Vector3.FORWARD, Vector3.LEFT, Vector3.UP],
63
+ ]
64
+ for f in faces:
65
+ var n: Vector3 = f[0]
66
+ var t: Vector3 = f[1]
67
+ var b: Vector3 = f[2]
68
+ var base: int = d["verts"].size()
69
+ var c := n * h
70
+ d["verts"].append(c - t * h - b * h)
71
+ d["verts"].append(c + t * h - b * h)
72
+ d["verts"].append(c + t * h + b * h)
73
+ d["verts"].append(c - t * h + b * h)
74
+ d["uvs"].append(Vector2(0, 0))
75
+ d["uvs"].append(Vector2(1, 0))
76
+ d["uvs"].append(Vector2(1, 1))
77
+ d["uvs"].append(Vector2(0, 1))
78
+ d["indices"].append(base + 0); d["indices"].append(base + 1); d["indices"].append(base + 2)
79
+ d["indices"].append(base + 0); d["indices"].append(base + 2); d["indices"].append(base + 3)
80
+ return d
81
+
82
+
83
+ func uv_sphere(rings: int, segments: int, radius: float) -> Dictionary:
84
+ var d := _make_mesh_data()
85
+ rings = maxi(2, rings)
86
+ segments = maxi(3, segments)
87
+ for y in range(rings + 1):
88
+ var v := float(y) / float(rings)
89
+ var phi := v * PI
90
+ for x in range(segments + 1):
91
+ var u := float(x) / float(segments)
92
+ var theta := u * TAU
93
+ var pos := Vector3(
94
+ sin(phi) * cos(theta),
95
+ cos(phi),
96
+ sin(phi) * sin(theta)
97
+ ) * radius
98
+ d["verts"].append(pos)
99
+ d["uvs"].append(Vector2(u, v))
100
+ for y in range(rings):
101
+ for x in range(segments):
102
+ var a := y * (segments + 1) + x
103
+ var b := a + segments + 1
104
+ d["indices"].append(a); d["indices"].append(b); d["indices"].append(a + 1)
105
+ d["indices"].append(a + 1); d["indices"].append(b); d["indices"].append(b + 1)
106
+ return d
107
+
108
+
109
+ func cylinder(segments: int, top_r: float, bottom_r: float, height: float) -> Dictionary:
110
+ var d := _make_mesh_data()
111
+ segments = maxi(3, segments)
112
+ var hy := height * 0.5
113
+ for y in range(2):
114
+ var r := top_r if y == 1 else bottom_r
115
+ var yy := hy if y == 1 else -hy
116
+ for x in range(segments + 1):
117
+ var u := float(x) / float(segments)
118
+ var theta := u * TAU
119
+ d["verts"].append(Vector3(cos(theta) * r, yy, sin(theta) * r))
120
+ d["uvs"].append(Vector2(u, float(y)))
121
+ for x in range(segments):
122
+ var a := x
123
+ var b := x + segments + 1
124
+ d["indices"].append(a); d["indices"].append(b); d["indices"].append(a + 1)
125
+ d["indices"].append(a + 1); d["indices"].append(b); d["indices"].append(b + 1)
126
+ # Caps
127
+ var top_center: int = d["verts"].size()
128
+ d["verts"].append(Vector3(0, hy, 0)); d["uvs"].append(Vector2(0.5, 0.5))
129
+ var bot_center: int = d["verts"].size()
130
+ d["verts"].append(Vector3(0, -hy, 0)); d["uvs"].append(Vector2(0.5, 0.5))
131
+ for x in range(segments):
132
+ var theta0 := (float(x) / float(segments)) * TAU
133
+ var theta1 := (float(x + 1) / float(segments)) * TAU
134
+ var t0: int = d["verts"].size()
135
+ d["verts"].append(Vector3(cos(theta0) * top_r, hy, sin(theta0) * top_r)); d["uvs"].append(Vector2(0, 0))
136
+ d["verts"].append(Vector3(cos(theta1) * top_r, hy, sin(theta1) * top_r)); d["uvs"].append(Vector2(1, 0))
137
+ d["indices"].append(top_center); d["indices"].append(t0); d["indices"].append(t0 + 1)
138
+ var b0: int = d["verts"].size()
139
+ d["verts"].append(Vector3(cos(theta0) * bottom_r, -hy, sin(theta0) * bottom_r)); d["uvs"].append(Vector2(0, 0))
140
+ d["verts"].append(Vector3(cos(theta1) * bottom_r, -hy, sin(theta1) * bottom_r)); d["uvs"].append(Vector2(1, 0))
141
+ d["indices"].append(bot_center); d["indices"].append(b0 + 1); d["indices"].append(b0)
142
+ return d
143
+
144
+
145
+ func plane(subdiv: int, size: float) -> Dictionary:
146
+ var d := _make_mesh_data()
147
+ subdiv = maxi(1, subdiv)
148
+ var half := size * 0.5
149
+ for z in range(subdiv + 1):
150
+ for x in range(subdiv + 1):
151
+ var u := float(x) / float(subdiv)
152
+ var v := float(z) / float(subdiv)
153
+ d["verts"].append(Vector3(-half + u * size, 0, -half + v * size))
154
+ d["uvs"].append(Vector2(u, v))
155
+ var row := subdiv + 1
156
+ for z in range(subdiv):
157
+ for x in range(subdiv):
158
+ var a := z * row + x
159
+ var b := a + row
160
+ d["indices"].append(a); d["indices"].append(a + 1); d["indices"].append(b)
161
+ d["indices"].append(a + 1); d["indices"].append(b + 1); d["indices"].append(b)
162
+ return d
163
+
164
+
165
+ func torus(major_seg: int, minor_seg: int, major_r: float, minor_r: float) -> Dictionary:
166
+ var d := _make_mesh_data()
167
+ major_seg = maxi(3, major_seg)
168
+ minor_seg = maxi(3, minor_seg)
169
+ for i in range(major_seg + 1):
170
+ var u := float(i) / float(major_seg) * TAU
171
+ for j in range(minor_seg + 1):
172
+ var v := float(j) / float(minor_seg) * TAU
173
+ var center := Vector3(cos(u) * major_r, 0, sin(u) * major_r)
174
+ var normal := Vector3(cos(u) * cos(v), sin(v), sin(u) * cos(v))
175
+ d["verts"].append(center + normal * minor_r)
176
+ d["uvs"].append(Vector2(float(i) / float(major_seg), float(j) / float(minor_seg)))
177
+ for i in range(major_seg):
178
+ for j in range(minor_seg):
179
+ var a := i * (minor_seg + 1) + j
180
+ var b := a + minor_seg + 1
181
+ d["indices"].append(a); d["indices"].append(b); d["indices"].append(a + 1)
182
+ d["indices"].append(a + 1); d["indices"].append(b); d["indices"].append(b + 1)
183
+ return d
184
+
185
+
186
+ func icosphere(subdiv: int, radius: float) -> Dictionary:
187
+ var d := _make_mesh_data()
188
+ var t := (1.0 + sqrt(5.0)) * 0.5
189
+ var verts := [
190
+ Vector3(-1, t, 0), Vector3(1, t, 0), Vector3(-1, -t, 0), Vector3(1, -t, 0),
191
+ Vector3(0, -1, t), Vector3(0, 1, t), Vector3(0, -1, -t), Vector3(0, 1, -t),
192
+ Vector3(t, 0, -1), Vector3(t, 0, 1), Vector3(-t, 0, -1), Vector3(-t, 0, 1),
193
+ ]
194
+ var faces := [
195
+ [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11],
196
+ [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8],
197
+ [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9],
198
+ [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1],
199
+ ]
200
+ for s in range(maxi(0, subdiv)):
201
+ var new_faces := []
202
+ var cache := {}
203
+ for f in faces:
204
+ var a: int = f[0]; var b: int = f[1]; var c: int = f[2]
205
+ var ab := _midpoint(verts, cache, a, b)
206
+ var bc := _midpoint(verts, cache, b, c)
207
+ var ca := _midpoint(verts, cache, c, a)
208
+ new_faces.append([a, ab, ca])
209
+ new_faces.append([b, bc, ab])
210
+ new_faces.append([c, ca, bc])
211
+ new_faces.append([ab, bc, ca])
212
+ faces = new_faces
213
+ for v in verts:
214
+ d["verts"].append(v.normalized() * radius)
215
+ d["uvs"].resize(d["verts"].size())
216
+ for f in faces:
217
+ d["indices"].append(f[0]); d["indices"].append(f[1]); d["indices"].append(f[2])
218
+ return d
219
+
220
+
221
+ func _midpoint(verts: Array, cache: Dictionary, a: int, b: int) -> int:
222
+ var key := "%d_%d" % [mini(a, b), maxi(a, b)]
223
+ if cache.has(key):
224
+ return cache[key]
225
+ var mid: Vector3 = (verts[a] + verts[b]) * 0.5
226
+ verts.append(mid)
227
+ var idx := verts.size() - 1
228
+ cache[key] = idx
229
+ return idx
230
+
231
+
232
+ ## A low-poly "Suzanne"-style monkey head silhouette (ears + cranium + snout).
233
+ ## Not bit-identical to Blender's Suzanne, but generated purely from code.
234
+ func suzanne(scale: float) -> Dictionary:
235
+ var d := _make_mesh_data()
236
+ # Cranium: squashed sphere
237
+ var cranium := uv_sphere(10, 16, 0.6)
238
+ _append(d, cranium["verts"], cranium["indices"], cranium["uvs"], Vector3(0, 0.1, 0) * scale, scale)
239
+ # Snout: a cube-ish extension forward
240
+ var snout := cube(0.4)
241
+ _append(d, snout["verts"], snout["indices"], snout["uvs"], Vector3(0, -0.1, 0.5) * scale, scale)
242
+ # Ears: two flat spheres left/right
243
+ var ear_l := uv_sphere(6, 10, 0.25)
244
+ _append(d, ear_l["verts"], ear_l["indices"], ear_l["uvs"], Vector3(-0.6, 0.35, 0) * scale, scale)
245
+ var ear_r := uv_sphere(6, 10, 0.25)
246
+ _append(d, ear_r["verts"], ear_r["indices"], ear_r["uvs"], Vector3(0.6, 0.35, 0) * scale, scale)
247
+ return d
248
+
249
+
250
+ ## Merge a sub-mesh into a target MeshData with an offset and uniform scale.
251
+ func _append(dst: Dictionary, verts: PackedVector3Array, indices: PackedInt32Array, uvs: PackedVector2Array, offset: Vector3, s: float) -> void:
252
+ var base: int = dst["verts"].size()
253
+ for v in verts:
254
+ dst["verts"].append(v * s + offset)
255
+ for u in uvs:
256
+ dst["uvs"].append(u)
257
+ for i in indices:
258
+ dst["indices"].append(base + i)
259
+
260
+
261
+ ## --- Instantiation helper ---------------------------------------------------
262
+
263
+ ## Build a MeshInstance3D from a generated MeshData and attach under Geometry.
264
+ ## Returns the instance (caller owns its lifetime; use MeshPool.release on free).
265
+ func spawn(kind: String, params: Dictionary = {}) -> MeshInstance3D:
266
+ var data := generate(kind, params)
267
+ return spawn_from_data(data, kind)
268
+
269
+
270
+ func spawn_from_data(data: Dictionary, name_hint: String = "mesh") -> MeshInstance3D:
271
+ var mesh := MeshPool.acquire_mesh()
272
+ VertexBufferManager.commit(data["verts"], data["indices"], data["uvs"], mesh)
273
+ var mi := MeshInstance3D.new()
274
+ mi.name = name_hint.capitalize()
275
+ mi.mesh = mesh
276
+ var mat := StandardMaterial3D.new()
277
+ mat.albedo_color = Color(0.7, 0.72, 0.8)
278
+ mat.shading_mode = BaseMaterial3D.SHADING_MODE_PER_PIXEL
279
+ mi.material_override = mat
280
+ if _root != null:
281
+ _root.add_child(mi)
282
+ return mi
scripts/modules/mesh_gen.gd.uid ADDED
@@ -0,0 +1 @@
 
 
1
+ uid://mh20ymrsudgf
scripts/modules/rigging.gd ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ extends Node
2
+ ##
3
+ ## RiggingModule - armature creation and automatic vertex weighting (Features 41-60).
4
+ ##
5
+ ## Creates a Skeleton3D, lets the user add bones as a transform hierarchy, and
6
+ ## binds skinning data (ARRAY_BONES + ARRAY_WEIGHTS) to a MeshInstance3D using
7
+ ## inverse-distance weighting against bone segments. The result is a normal,
8
+ ## engine-native skinned mesh - no custom shader needed.
9
+ ##
10
+
11
+ ## Bone definitions the tool builds. Each entry: { name, parent, position, tail }
12
+ var _bones: Array[Dictionary] = []
13
+ var _skeleton: Skeleton3D = null
14
+ var _host: Node = null
15
+ var _bound_mesh: MeshInstance3D = null
16
+
17
+
18
+ func _activate(host: Node) -> void:
19
+ _host = host
20
+ StudioBus.object_selected.connect(_on_object_selected)
21
+ print("[Rigging] activated")
22
+
23
+
24
+ func _deactivate() -> void:
25
+ if StudioBus.object_selected.is_connected(_on_object_selected):
26
+ StudioBus.object_selected.disconnect(_on_object_selected)
27
+ _host = null
28
+
29
+
30
+ func _on_object_selected(node: Node3D) -> void:
31
+ _bound_mesh = node as MeshInstance3D
32
+
33
+
34
+ ## --- Armature creation ------------------------------------------------------
35
+
36
+ ## Add a bone. parent="" means root. Returns the new bone index.
37
+ func add_bone(name: String, parent: String, position: Vector3, tail: Vector3 = Vector3.ZERO) -> int:
38
+ _ensure_skeleton()
39
+ var parent_idx := -1
40
+ if parent != "":
41
+ parent_idx = _find_bone(parent)
42
+ var idx := _skeleton.get_bone_count()
43
+ _skeleton.add_bone(name)
44
+ if parent_idx >= 0:
45
+ _skeleton.set_bone_parent(idx, parent_idx)
46
+ var rest := Transform3D()
47
+ # Point the bone's -Z toward its tail so rotations look natural.
48
+ var dir := (tail - position)
49
+ if dir.length_squared() > 0.000001:
50
+ rest = Transform3D(_look_at_basis(dir.normalized()), position)
51
+ else:
52
+ rest = Transform3D(Basis.IDENTITY, position)
53
+ _skeleton.set_bone_rest(idx, rest)
54
+ _skeleton.set_bone_pose_position(idx, position)
55
+ _bones.append({"name": name, "parent": parent, "index": idx})
56
+ StudioBus.scene_modified.emit()
57
+ return idx
58
+
59
+
60
+ func _ensure_skeleton() -> void:
61
+ if _skeleton != null and is_instance_valid(_skeleton):
62
+ return
63
+ _skeleton = Skeleton3D.new()
64
+ _skeleton.name = "Armature"
65
+ if _host != null:
66
+ _host.get_node_or_null("Rig").add_child(_skeleton) if _host.has_node("Rig") else _host.add_child(_skeleton)
67
+
68
+
69
+ func _find_bone(name: String) -> int:
70
+ if _skeleton == null:
71
+ return -1
72
+ for i in range(_skeleton.get_bone_count()):
73
+ if _skeleton.get_bone_name(i) == name:
74
+ return i
75
+ return -1
76
+
77
+
78
+ func _look_at_basis(dir: Vector3) -> Basis:
79
+ var up := Vector3.UP
80
+ if absf(dir.dot(up)) > 0.99:
81
+ up = Vector3.RIGHT
82
+ var right := dir.cross(up).normalized()
83
+ var real_up := right.cross(dir).normalized()
84
+ return Basis(right, real_up, -dir)
85
+
86
+
87
+ ## Build a simple starter humanoid-ish spine + limbs for a mesh's bounds.
88
+ func generate_default_armature(mesh: MeshInstance3D) -> void:
89
+ if mesh == null or mesh.mesh == null:
90
+ return
91
+ var size := mesh.mesh.get_aabb().size
92
+ var h := size.y
93
+ add_bone("root", "", Vector3.ZERO, Vector3(0, h * 0.1, 0))
94
+ add_bone("spine", "root", Vector3(0, h * 0.1, 0), Vector3(0, h * 0.35, 0))
95
+ add_bone("chest", "spine", Vector3(0, h * 0.35, 0), Vector3(0, h * 0.6, 0))
96
+ add_bone("neck", "chest", Vector3(0, h * 0.6, 0), Vector3(0, h * 0.72, 0))
97
+ add_bone("head", "neck", Vector3(0, h * 0.72, 0), Vector3(0, h * 0.95, 0))
98
+ add_bone("arm_l", "chest", Vector3(-size.x * 0.4, h * 0.6, 0), Vector3(-size.x * 0.9, h * 0.45, 0))
99
+ add_bone("arm_r", "chest", Vector3(size.x * 0.4, h * 0.6, 0), Vector3(size.x * 0.9, h * 0.45, 0))
100
+ add_bone("leg_l", "root", Vector3(-size.x * 0.2, 0, 0), Vector3(-size.x * 0.2, -h * 0.5, 0))
101
+ add_bone("leg_r", "root", Vector3(size.x * 0.2, 0, 0), Vector3(size.x * 0.2, -h * 0.5, 0))
102
+ bind_weights(mesh)
103
+
104
+
105
+ ## --- Automatic weight binding -----------------------------------------------
106
+
107
+ ## Compute skinning arrays using inverse-distance to bone segments and apply.
108
+ ## `max_influences` must be <= 4 for the standard mobile skinning path.
109
+ func bind_weights(mesh: MeshInstance3D, max_influences: int = 4) -> void:
110
+ if mesh == null or _skeleton == null or mesh.mesh == null or mesh.mesh.get_surface_count() == 0:
111
+ return
112
+ var arr := mesh.mesh.surface_get_arrays(0)
113
+ var verts: PackedVector3Array = arr[Mesh.ARRAY_VERTEX]
114
+ var n := verts.size()
115
+ var bone_count := _skeleton.get_bone_count()
116
+ var bone_segments: Array = []
117
+ for i in range(bone_count):
118
+ var pos := _skeleton.get_bone_global_rest(i).origin
119
+ bone_segments.append({"idx": i, "pos": pos})
120
+
121
+ var vbones := PackedInt32Array()
122
+ var vweights := PackedFloat32Array()
123
+ vbones.resize(n * 4)
124
+ vweights.resize(n * 4)
125
+ var to_mesh := mesh.global_transform.affine_inverse()
126
+
127
+ for vi in range(n):
128
+ var p := verts[vi]
129
+ # Rank bones by distance to their head position.
130
+ var dists: Array = []
131
+ for b in bone_segments:
132
+ var local_bone: Vector3 = to_mesh * (mesh.global_transform * b["pos"])
133
+ dists.append({"idx": b["idx"], "d": p.distance_to(local_bone)})
134
+ dists.sort_custom(func(a, b): return a["d"] < b["d"])
135
+ var influ: int = mini(max_influences, dists.size())
136
+ var wsum := 0.0
137
+ var ws: Array[float] = []
138
+ for k in range(influ):
139
+ var d: float = maxf(0.0001, dists[k]["d"])
140
+ var w := 1.0 / (d * d)
141
+ ws.append(w)
142
+ wsum += w
143
+ for slot in range(4):
144
+ var base := vi * 4 + slot
145
+ if slot < influ and wsum > 0.0:
146
+ vbones[base] = dists[slot]["idx"]
147
+ vweights[base] = ws[slot] / wsum
148
+ else:
149
+ vbones[base] = 0
150
+ vweights[base] = 0.0
151
+
152
+ # Rebuild the mesh surface with skinning channels added.
153
+ arr[Mesh.ARRAY_BONES] = vbones
154
+ arr[Mesh.ARRAY_WEIGHTS] = vweights
155
+ var new_mesh := MeshPool.acquire_mesh()
156
+ new_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arr)
157
+ mesh.mesh = new_mesh
158
+ mesh.skeleton = mesh.get_path_to(_skeleton)
159
+ # A Skin must be bound so the renderer knows which skeleton supplies the
160
+ # bone transforms for these indices. add_bind(bone_index, rest_pose).
161
+ var skin := Skin.new()
162
+ for i in range(bone_count):
163
+ skin.add_bind(i, _skeleton.get_bone_global_rest(i))
164
+ mesh.skin = skin
165
+ StudioBus.scene_modified.emit()
166
+ print("[Rigging] bound %d verts to %d bones" % [n, bone_count])
167
+
168
+
169
+ func bone_count() -> int:
170
+ return _skeleton.get_bone_count() if _skeleton != null else 0
171
+
172
+
173
+ func get_skeleton() -> Skeleton3D:
174
+ return _skeleton
scripts/modules/rigging.gd.uid ADDED
@@ -0,0 +1 @@
 
 
1
+ uid://dedkqt37ilf6u
scripts/modules/sculpt.gd ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ extends Node
2
+ ##
3
+ ## SculptModule - vertex-based sculpting brushes (Features 21-40).
4
+ ##
5
+ ## Works directly on a MeshInstance3D's surface arrays. A stroke is:
6
+ ## begin(pos) -> apply(pos, delta) x N -> end()
7
+ ## All applies accumulate into CPU PackedArrays; a single GPU upload happens in
8
+ ## end() (and optionally on a timer) so we do NOT upload per touch-move event.
9
+ ##
10
+ ## Brushes:
11
+ ## draw - push vertices along the surface normal / view dir
12
+ ## grab - drag vertices inside the radius with the pointer
13
+ ## pinch - pull vertices toward the brush center
14
+ ## smooth - laplacian relax toward neighbour average
15
+ ## flatten - move vertices toward the local average plane
16
+ ##
17
+
18
+ @export var radius: float = 0.5
19
+ @export var strength: float = 0.5
20
+ @export var invert: bool = false # hold shift / tap toggle to reverse
21
+
22
+ var _host: Node = null
23
+ var _target: MeshInstance3D = null
24
+ var _verts: PackedVector3Array = PackedVector3Array()
25
+ var _indices: PackedInt32Array = PackedInt32Array()
26
+ var _uvs: PackedVector2Array = PackedVector2Array()
27
+ var _base_verts: PackedVector3Array = PackedVector3Array()
28
+ var _dirty := false
29
+ var _stroke_active := false
30
+ var _brush_id := "draw"
31
+ var _grab_origin := Vector3.ZERO
32
+ var _grab_delta := Vector3.ZERO
33
+
34
+
35
+ func _activate(host: Node) -> void:
36
+ _host = host
37
+ StudioBus.brush_changed.connect(_on_brush_changed)
38
+ StudioBus.brush_radius_changed.connect(func(r: float): radius = r)
39
+ StudioBus.brush_strength_changed.connect(func(s: float): strength = s)
40
+ StudioBus.object_selected.connect(_on_object_selected)
41
+ print("[Sculpt] activated")
42
+
43
+
44
+ func _deactivate() -> void:
45
+ flush()
46
+ if StudioBus.brush_changed.is_connected(_on_brush_changed):
47
+ StudioBus.brush_changed.disconnect(_on_brush_changed)
48
+ if StudioBus.object_selected.is_connected(_on_object_selected):
49
+ StudioBus.object_selected.disconnect(_on_object_selected)
50
+ _host = null
51
+ _target = null
52
+
53
+
54
+ func _on_brush_changed(id: String) -> void:
55
+ _brush_id = id
56
+
57
+
58
+ func _on_object_selected(node: Node3D) -> void:
59
+ set_target(node as MeshInstance3D)
60
+
61
+
62
+ ## Bind a MeshInstance3D and cache its arrays.
63
+ func set_target(mi: MeshInstance3D) -> void:
64
+ _target = mi
65
+ if mi == null or mi.mesh == null or mi.mesh.get_surface_count() == 0:
66
+ return
67
+ var arr := mi.mesh.surface_get_arrays(0)
68
+ _verts = arr[Mesh.ARRAY_VERTEX]
69
+ _uvs = arr[Mesh.ARRAY_TEX_UV] if arr.size() > Mesh.ARRAY_TEX_UV and arr[Mesh.ARRAY_TEX_UV] != null else PackedVector2Array()
70
+ _indices = arr[Mesh.ARRAY_INDEX] if arr.size() > Mesh.ARRAY_INDEX and arr[Mesh.ARRAY_INDEX] != null else PackedInt32Array()
71
+ _base_verts = _verts.duplicate()
72
+
73
+
74
+ func begin(world_pos: Vector3) -> void:
75
+ if _verts.is_empty():
76
+ return
77
+ _stroke_active = true
78
+ _dirty = false
79
+ _grab_origin = world_pos
80
+ _grab_delta = Vector3.ZERO
81
+ StudioBus.stroke_begin.emit(world_pos)
82
+
83
+
84
+ func apply(world_pos: Vector3, move_delta: Vector3 = Vector3.ZERO) -> void:
85
+ if not _stroke_active or _verts.is_empty():
86
+ return
87
+ match _brush_id:
88
+ "draw": _brush_draw(world_pos, move_delta)
89
+ "grab": _brush_grab(world_pos, move_delta)
90
+ "pinch": _brush_pinch(world_pos)
91
+ "smooth": _brush_smooth(world_pos)
92
+ "flatten": _brush_flatten(world_pos)
93
+ _dirty = true
94
+ StudioBus.stroke_apply.emit(world_pos, move_delta)
95
+
96
+
97
+ func end() -> void:
98
+ if not _stroke_active:
99
+ return
100
+ _stroke_active = false
101
+ flush()
102
+ StudioBus.stroke_end.emit()
103
+
104
+
105
+ ## Upload the accumulated CPU edits to the GPU in one shot.
106
+ func flush() -> void:
107
+ if not _dirty or _target == null or _target.mesh == null:
108
+ return
109
+ VertexBufferManager.commit(_verts, _indices, _uvs, _target.mesh)
110
+ _base_verts = _verts.duplicate()
111
+ _dirty = false
112
+ StudioBus.scene_modified.emit()
113
+
114
+
115
+ ## --- Brush implementations --------------------------------------------------
116
+
117
+ func _falloff(dist: float) -> float:
118
+ if dist >= radius:
119
+ return 0.0
120
+ var t := 1.0 - (dist / radius)
121
+ return t * t * (3.0 - 2.0 * t) # smoothstep
122
+
123
+
124
+ func _brush_draw(center: Vector3, move_delta: Vector3) -> void:
125
+ var dir := move_delta.normalized() if move_delta.length_squared() > 0.000001 else Vector3.UP
126
+ var sign := -1.0 if invert else 1.0
127
+ var amp := strength * radius * 0.25 * sign
128
+ var r2 := radius * radius
129
+ for i in range(_verts.size()):
130
+ var d2 := _verts[i].distance_squared_to(center)
131
+ if d2 < r2:
132
+ var w := _falloff(sqrt(d2))
133
+ _verts[i] = _verts[i] + dir * amp * w
134
+
135
+
136
+ func _brush_grab(center: Vector3, move_delta: Vector3) -> void:
137
+ _grab_delta += move_delta
138
+ var r2 := radius * radius
139
+ # Move relative to where the stroke started for a stable, non-drifting feel.
140
+ for i in range(_verts.size()):
141
+ var src := _base_verts[i]
142
+ var d2 := src.distance_squared_to(_grab_origin)
143
+ if d2 < r2:
144
+ var w := _falloff(sqrt(d2))
145
+ _verts[i] = src + _grab_delta * w
146
+
147
+
148
+ func _brush_pinch(center: Vector3) -> void:
149
+ var sign := -1.0 if invert else 1.0
150
+ var r2 := radius * radius
151
+ for i in range(_verts.size()):
152
+ var v := _verts[i]
153
+ var d2 := v.distance_squared_to(center)
154
+ if d2 < r2:
155
+ var w := _falloff(sqrt(d2))
156
+ var to_center := (center - v)
157
+ _verts[i] = v + to_center * strength * 0.15 * w * sign
158
+
159
+
160
+ func _brush_smooth(center: Vector3) -> void:
161
+ # Approximate neighbour average using the vertex's triangle neighbours.
162
+ var sums := PackedVector3Array()
163
+ var counts := PackedInt32Array()
164
+ sums.resize(_verts.size())
165
+ counts.resize(_verts.size())
166
+ var tri := _indices.size() / 3
167
+ for t in range(tri):
168
+ var a := _indices[t * 3]
169
+ var b := _indices[t * 3 + 1]
170
+ var c := _indices[t * 3 + 2]
171
+ for pair in [[a, b], [b, c], [c, a]]:
172
+ var k: int = pair[1]
173
+ sums[k] = sums[k] + _verts[pair[0]]
174
+ counts[k] = counts[k] + 1
175
+ var k2: int = pair[0]
176
+ sums[k2] = sums[k2] + _verts[k]
177
+ counts[k2] = counts[k2] + 1
178
+ var r2 := radius * radius
179
+ for i in range(_verts.size()):
180
+ if _verts[i].distance_squared_to(center) < r2 and counts[i] > 0:
181
+ var avg := sums[i] / float(counts[i])
182
+ var w := _falloff(_verts[i].distance_to(center))
183
+ _verts[i] = _verts[i].lerp(avg, clampf(strength * w, 0.0, 1.0))
184
+
185
+
186
+ func _brush_flatten(center: Vector3) -> void:
187
+ var r2 := radius * radius
188
+ # Find the average normal of affected verts -> local plane.
189
+ var avg_normal := Vector3.ZERO
190
+ var affected: Array[int] = []
191
+ for i in range(_verts.size()):
192
+ if _verts[i].distance_squared_to(center) < r2:
193
+ affected.append(i)
194
+ if affected.is_empty():
195
+ return
196
+ for i in affected:
197
+ avg_normal = avg_normal + (_base_verts[i] - center)
198
+ avg_normal = avg_normal.normalized() if avg_normal.length_squared() > 0.000001 else Vector3.UP
199
+ for i in affected:
200
+ var v := _verts[i]
201
+ var dist_to_plane := v.dot(avg_normal) - center.dot(avg_normal)
202
+ var w := _falloff(v.distance_to(center))
203
+ _verts[i] = v - avg_normal * dist_to_plane * clampf(strength * w, 0.0, 1.0)
204
+
205
+
206
+ ## Ray from camera through screen point; returns closest approach to mesh AABB.
207
+ ## A physics collider is preferred; this fallback keeps sculpting usable even
208
+ ## when the mesh has no collision shape (common right after generation).
209
+ func raycast_screen(camera: Camera3D, screen_pos: Vector2) -> Dictionary:
210
+ if camera == null or _target == null:
211
+ return {"hit": false}
212
+ var origin := camera.project_ray_origin(screen_pos)
213
+ var dir := camera.project_ray_normal(screen_pos)
214
+ var space := _host.get_viewport().world_3d.direct_space_state
215
+ var q := PhysicsRayQueryParameters3D.create(origin, origin + dir * 1000.0)
216
+ q.collide_with_areas = true
217
+ var res := space.intersect_ray(q)
218
+ if res.is_empty():
219
+ # Fallback: intersect ray with the mesh AABB as an approximation.
220
+ var aabb := _target.mesh.get_aabb()
221
+ var box_pos := _target.global_transform * aabb.position
222
+ var t := _ray_box(origin, dir, _target.global_transform * (aabb.position + aabb.size * 0.5), aabb.size.length() * 0.5)
223
+ if t > 0.0:
224
+ return {"hit": true, "position": origin + dir * t, "normal": Vector3.UP}
225
+ return {"hit": false}
226
+ return {"hit": true, "position": res["position"], "normal": res["normal"]}
227
+
228
+
229
+ func _ray_box(origin: Vector3, dir: Vector3, center: Vector3, r: float) -> float:
230
+ # Sphere approximation of the mesh bounds for the fallback ray test.
231
+ var oc := origin - center
232
+ var b := oc.dot(dir)
233
+ var c := oc.dot(oc) - r * r
234
+ var disc := b * b - c
235
+ if disc < 0.0:
236
+ return -1.0
237
+ var t := -b - sqrt(disc)
238
+ return t if t > 0.0 else (-b + sqrt(disc))
scripts/modules/sculpt.gd.uid ADDED
@@ -0,0 +1 @@
 
 
1
+ uid://e464m7f2eegd
scripts/modules/simulation.gd ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ extends Node
2
+ ##
3
+ ## SimulationModule - lightweight mass-spring solver for cloth & hair
4
+ ## (Features 61-80), tuned for mobile CPUs.
5
+ ##
6
+ ## Approach: a fixed grid of particles connected by springs (structural,
7
+ ## shear, bend). Integration uses fixed-dt sub-stepping with Verlet and
8
+ ## constraint relaxation (Jakobsen) which is stable at low iteration counts -
9
+ ## far cheaper than a full PBD/XPBD solver while looking convincing.
10
+ ##
11
+ ## Memory: particle + spring arrays are PackedArrays; nothing is allocated per
12
+ ## frame. The whole sim is torn down by FeatureRegistry.deactivate() when the
13
+ ## user leaves the module, so its RAM returns to the pool.
14
+ ##
15
+
16
+ ## sim_id -> { settings, particles, springs, mesh, instance }
17
+ var _sims: Dictionary = {}
18
+ var _host: Node = null
19
+ var _accumulator: float = 0.0
20
+
21
+ const FIXED_DT := 1.0 / 30.0
22
+ const MAX_SUBSTEPS := 4
23
+
24
+
25
+ func _activate(host: Node) -> void:
26
+ _host = host
27
+ set_process(true)
28
+ StudioBus.sim_toggled.connect(_on_sim_toggled)
29
+ StudioBus.sim_settings_changed.connect(_on_settings_changed)
30
+ print("[Simulation] activated")
31
+
32
+
33
+ func _deactivate() -> void:
34
+ if StudioBus.sim_toggled.is_connected(_on_sim_toggled):
35
+ StudioBus.sim_toggled.disconnect(_on_sim_toggled)
36
+ if StudioBus.sim_settings_changed.is_connected(_on_settings_changed):
37
+ StudioBus.sim_settings_changed.disconnect(_on_settings_changed)
38
+ _sims.clear()
39
+ _host = null
40
+
41
+
42
+ func _on_sim_toggled(sim_id: String, enabled: bool) -> void:
43
+ if enabled and not _sims.has(sim_id):
44
+ _build_default(sim_id)
45
+ if _sims.has(sim_id):
46
+ _sims[sim_id]["settings"]["enabled"] = enabled
47
+
48
+
49
+ func _on_settings_changed(sim_id: String, settings: Dictionary) -> void:
50
+ if not _sims.has(sim_id):
51
+ return
52
+ for k in settings:
53
+ _sims[sim_id]["settings"][k] = settings[k]
54
+
55
+
56
+ func _build_default(sim_id: String) -> void:
57
+ if sim_id == "sim.cloth":
58
+ create_cloth("sim.cloth", 12, 12, 1.0)
59
+ elif sim_id == "sim.hair":
60
+ create_hair("sim.hair", 10, 10)
61
+
62
+
63
+ ## --- Cloth ------------------------------------------------------------------
64
+
65
+ ## Create a cloth grid of cols x rows, size meters wide (square), hung from the
66
+ ## top edge and simulated against gravity + wind.
67
+ func create_cloth(sim_id: String, cols: int, rows: int, size: float) -> void:
68
+ cols = maxi(2, cols)
69
+ rows = maxi(2, rows)
70
+ var particles := PackedVector3Array()
71
+ var pinned := PackedByteArray()
72
+ var step := size / float(cols - 1)
73
+ for r in range(rows):
74
+ for c in range(cols):
75
+ particles.append(Vector3((c - (cols - 1) * 0.5) * step, -r * step, 0.0))
76
+ # Pin the top row so the cloth hangs.
77
+ pinned.append(1 if r == 0 else 0)
78
+ var springs := PackedInt32Array()
79
+ var rest := PackedFloat32Array()
80
+ _add_structural(cols, rows, particles, springs, rest)
81
+ _add_shear(cols, rows, particles, springs, rest)
82
+ var mesh := MeshPool.acquire_mesh()
83
+ var mi := _make_instance("Cloth")
84
+ _sims[sim_id] = {
85
+ "settings": {
86
+ "enabled": true, "gravity": -9.8, "wind": Vector3(0.6, 0.0, 0.2),
87
+ "damping": 0.02, "iterations": 6, "stiffness": 1.0, "cols": cols, "rows": rows,
88
+ },
89
+ "particles": particles, "prev": particles.duplicate(), "pinned": pinned,
90
+ "springs": springs, "rest": rest, "mesh": mesh, "instance": mi,
91
+ }
92
+
93
+
94
+ func _add_structural(cols: int, rows: int, pos: PackedVector3Array,
95
+ springs: PackedInt32Array, rest: PackedFloat32Array) -> void:
96
+ for r in range(rows):
97
+ for c in range(cols):
98
+ var i := r * cols + c
99
+ if c + 1 < cols:
100
+ _push_spring(i, i + 1, pos, springs, rest)
101
+ if r + 1 < rows:
102
+ _push_spring(i, i + cols, pos, springs, rest)
103
+
104
+
105
+ func _add_shear(cols: int, rows: int, pos: PackedVector3Array,
106
+ springs: PackedInt32Array, rest: PackedFloat32Array) -> void:
107
+ for r in range(rows - 1):
108
+ for c in range(cols - 1):
109
+ var i := r * cols + c
110
+ _push_spring(i, i + cols + 1, pos, springs, rest)
111
+ _push_spring(i + 1, i + cols, pos, springs, rest)
112
+
113
+
114
+ func _push_spring(a: int, b: int, pos: PackedVector3Array,
115
+ springs: PackedInt32Array, rest: PackedFloat32Array) -> void:
116
+ springs.append(a)
117
+ springs.append(b)
118
+ rest.append(pos[a].distance_to(pos[b]))
119
+
120
+
121
+ ## --- Hair -------------------------------------------------------------------
122
+
123
+ ## Create `strands` strands, each `segments` long, rooted along a horizontal line.
124
+ func create_hair(sim_id: String, strands: int, segments: int) -> void:
125
+ var particles := PackedVector3Array()
126
+ var pinned := PackedByteArray()
127
+ var springs := PackedInt32Array()
128
+ var rest := PackedFloat32Array()
129
+ var spread := 1.0
130
+ for s in range(strands):
131
+ var x := (float(s) / float(maxi(1, strands - 1)) - 0.5) * spread
132
+ var base := particles.size()
133
+ for seg in range(segments):
134
+ particles.append(Vector3(x, 0.5 - seg * 0.08, 0.0))
135
+ pinned.append(1 if seg == 0 else 0)
136
+ for seg in range(segments - 1):
137
+ _push_spring(base + seg, base + seg + 1, particles, springs, rest)
138
+ var mesh := MeshPool.acquire_mesh()
139
+ var mi := _make_instance("Hair")
140
+ _sims[sim_id] = {
141
+ "settings": {
142
+ "enabled": true, "gravity": -9.8, "wind": Vector3(0.2, 0.0, 0.4),
143
+ "damping": 0.05, "iterations": 4, "stiffness": 0.8,
144
+ "strands": strands, "segments": segments,
145
+ },
146
+ "particles": particles, "prev": particles.duplicate(), "pinned": pinned,
147
+ "springs": springs, "rest": rest, "mesh": mesh, "instance": mi,
148
+ }
149
+
150
+
151
+ func _make_instance(name: String) -> MeshInstance3D:
152
+ var mi := MeshInstance3D.new()
153
+ mi.name = name
154
+ var mat := StandardMaterial3D.new()
155
+ mat.albedo_color = Color(0.55, 0.6, 0.75)
156
+ mat.cull_mode = BaseMaterial3D.CULL_DISABLED
157
+ mi.material_override = mat
158
+ if _host != null:
159
+ var geom := _host.get_node_or_null("Geometry")
160
+ (geom if geom else _host).add_child(mi)
161
+ # Collision is only needed for the rays; nothing to add here for now.
162
+ return mi
163
+
164
+
165
+ ## --- Solver ----------------------------------------------------------------
166
+
167
+ func _process(delta: float) -> void:
168
+ var any := false
169
+ for id in _sims:
170
+ var sim: Dictionary = _sims[id]
171
+ if sim["settings"]["enabled"]:
172
+ any = true
173
+ break
174
+ if not any:
175
+ return
176
+ _accumulator += delta
177
+ var steps := 0
178
+ while _accumulator >= FIXED_DT and steps < MAX_SUBSTEPS:
179
+ for id in _sims:
180
+ var sim: Dictionary = _sims[id]
181
+ if sim["settings"]["enabled"]:
182
+ _step(sim)
183
+ _accumulator -= FIXED_DT
184
+ steps += 1
185
+ if steps >= MAX_SUBSTEPS:
186
+ _accumulator = 0.0 # drop backlog instead of spiralling
187
+ for id in _sims:
188
+ _upload(_sims[id])
189
+
190
+
191
+ ## Verlet integration + Jacobi-style constraint relaxation.
192
+ func _step(sim: Dictionary) -> void:
193
+ var s: Dictionary = sim["settings"]
194
+ var pos: PackedVector3Array = sim["particles"]
195
+ var prev: PackedVector3Array = sim["prev"]
196
+ var pinned: PackedByteArray = sim["pinned"]
197
+ var gravity: float = s["gravity"]
198
+ var wind: Vector3 = s["wind"]
199
+ var damping: float = s["damping"]
200
+ var dt := FIXED_DT
201
+
202
+ for i in range(pos.size()):
203
+ if pinned[i] == 1:
204
+ continue
205
+ var velocity := (pos[i] - prev[i]) * (1.0 - damping)
206
+ prev[i] = pos[i]
207
+ var accel := Vector3(0, gravity, 0) + wind
208
+ pos[i] = pos[i] + velocity + accel * dt * dt
209
+
210
+ var springs: PackedInt32Array = sim["springs"]
211
+ var rest: PackedFloat32Array = sim["rest"]
212
+ var stiffness: float = s["stiffness"]
213
+ for _it in range(int(s["iterations"])):
214
+ for k in range(rest.size()):
215
+ var a := springs[k * 2]
216
+ var b := springs[k * 2 + 1]
217
+ var delta_v := pos[b] - pos[a]
218
+ var d := delta_v.length()
219
+ if d < 0.00001:
220
+ continue
221
+ var diff := (d - rest[k]) / d
222
+ var correction := delta_v * 0.5 * diff * stiffness
223
+ if pinned[a] == 0:
224
+ pos[a] = pos[a] + correction
225
+ if pinned[b] == 0:
226
+ pos[b] = pos[b] - correction
227
+
228
+
229
+ ## Upload the current particle positions as a triangle strip / line mesh.
230
+ func _upload(sim: Dictionary) -> void:
231
+ var pos: PackedVector3Array = sim["particles"]
232
+ var mesh: ArrayMesh = sim["mesh"]
233
+ var cols: int = sim["settings"].get("cols", 0)
234
+ var rows: int = sim["settings"].get("rows", 0)
235
+ if cols > 1 and rows > 1:
236
+ _upload_cloth(pos, cols, rows, mesh)
237
+ else:
238
+ _upload_hair(pos, sim["settings"].get("strands", 0), sim["settings"].get("segments", 0), mesh)
239
+
240
+
241
+ func _upload_cloth(pos: PackedVector3Array, cols: int, rows: int, mesh: ArrayMesh) -> void:
242
+ var indices := PackedInt32Array()
243
+ for r in range(rows - 1):
244
+ for c in range(cols - 1):
245
+ var i := r * cols + c
246
+ indices.append(i); indices.append(i + 1); indices.append(i + cols)
247
+ indices.append(i + 1); indices.append(i + cols + 1); indices.append(i + cols)
248
+ var uvs := PackedVector2Array()
249
+ uvs.resize(pos.size())
250
+ for i in range(pos.size()):
251
+ uvs[i] = Vector2(float(i % cols) / float(cols), float(i / cols) / float(rows))
252
+ var normals := VertexBufferManager.recompute_normals(pos, indices)
253
+ mesh.clear_surfaces()
254
+ var arrays: Array = []
255
+ arrays.resize(Mesh.ARRAY_MAX)
256
+ arrays[Mesh.ARRAY_VERTEX] = pos
257
+ arrays[Mesh.ARRAY_NORMAL] = normals
258
+ arrays[Mesh.ARRAY_TEX_UV] = uvs
259
+ arrays[Mesh.ARRAY_INDEX] = indices
260
+ mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
261
+
262
+
263
+ func _upload_hair(pos: PackedVector3Array, strands: int, segments: int, mesh: ArrayMesh) -> void:
264
+ if strands <= 1 or segments <= 1:
265
+ return
266
+ var ribbons := PackedVector3Array()
267
+ var indices := PackedInt32Array()
268
+ var uvs := PackedVector2Array()
269
+ var width := 0.02
270
+ for s in range(strands):
271
+ var base := s * segments
272
+ var line_base := ribbons.size()
273
+ for seg in range(segments):
274
+ var p := pos[base + seg]
275
+ var tangent := Vector3.RIGHT
276
+ if seg + 1 < segments:
277
+ tangent = (pos[base + seg + 1] - p).normalized()
278
+ var side := tangent.cross(Vector3.FORWARD).normalized() * width
279
+ ribbons.append(p - side)
280
+ ribbons.append(p + side)
281
+ uvs.append(Vector2(0, float(seg) / float(segments)))
282
+ uvs.append(Vector2(1, float(seg) / float(segments)))
283
+ for seg in range(segments - 1):
284
+ var a := line_base + seg * 2
285
+ indices.append(a); indices.append(a + 1); indices.append(a + 2)
286
+ indices.append(a + 1); indices.append(a + 3); indices.append(a + 2)
287
+ var normals := VertexBufferManager.recompute_normals(ribbons, indices)
288
+ mesh.clear_surfaces()
289
+ var arrays: Array = []
290
+ arrays.resize(Mesh.ARRAY_MAX)
291
+ arrays[Mesh.ARRAY_VERTEX] = ribbons
292
+ arrays[Mesh.ARRAY_NORMAL] = normals
293
+ arrays[Mesh.ARRAY_TEX_UV] = uvs
294
+ arrays[Mesh.ARRAY_INDEX] = indices
295
+ mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
296
+
297
+
298
+ func set_wind(sim_id: String, wind: Vector3) -> void:
299
+ if _sims.has(sim_id):
300
+ _sims[sim_id]["settings"]["wind"] = wind
301
+
302
+
303
+ func active_sim_count() -> int:
304
+ return _sims.size()
scripts/modules/simulation.gd.uid ADDED
@@ -0,0 +1 @@
 
 
1
+ uid://by4bu3p6btqr8
scripts/modules/timeline.gd ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ extends Node
2
+ ##
3
+ ## TimelineModule - keyframe timeline for objects and bones (Features 81-100).
4
+ ##
5
+ ## Backed by a real Godot Animation resource + AnimationPlayer, so playback,
6
+ ## interpolation and looping use engine-native code (fast, cached). The module
7
+ ## exposes a touch-friendly API: scrub(), add_keyframe(), play(), pause().
8
+ ##
9
+
10
+ @export var duration: float = 5.0
11
+ @export var fps: int = 30
12
+
13
+ var _host: Node = null
14
+ var _player: AnimationPlayer = null
15
+ var _anim: Animation = null
16
+ var _playhead: float = 0.0
17
+ var _playing := false
18
+ var _key_count := 0
19
+
20
+
21
+ func _activate(host: Node) -> void:
22
+ _host = host
23
+ _ensure_player()
24
+ print("[Timeline] activated (%.1fs @ %d fps)" % [duration, fps])
25
+
26
+
27
+ func _deactivate() -> void:
28
+ _playing = false
29
+ _player = null
30
+ _anim = null
31
+ _host = null
32
+
33
+
34
+ func _ensure_player() -> void:
35
+ if _player != null and is_instance_valid(_player):
36
+ return
37
+ _anim = Animation.new()
38
+ _anim.length = duration
39
+ _anim.step = 1.0 / float(fps)
40
+ _player = AnimationPlayer.new()
41
+ _player.name = "TimelinePlayer"
42
+ if _host != null:
43
+ _host.add_child(_player)
44
+ var lib := AnimationLibrary.new()
45
+ lib.add_animation("main", _anim)
46
+ _player.add_animation_library("", lib)
47
+ _player.animation_finished.connect(_on_finished)
48
+
49
+
50
+ func _process(delta: float) -> void:
51
+ if not _playing or _player == null:
52
+ return
53
+ _playhead = _player.current_animation_position
54
+ StudioBus.timeline_playhead.emit(_playhead)
55
+
56
+
57
+ ## --- Playback ---------------------------------------------------------------
58
+
59
+ func play(from: float = -1.0) -> void:
60
+ _ensure_player()
61
+ if from >= 0.0:
62
+ _player.seek(from, true)
63
+ _player.play("main")
64
+ _playing = true
65
+ StudioBus.playback_state_changed.emit(true)
66
+
67
+
68
+ func pause() -> void:
69
+ if _player != null:
70
+ _player.pause()
71
+ _playing = false
72
+ StudioBus.playback_state_changed.emit(false)
73
+
74
+
75
+ func stop() -> void:
76
+ if _player != null:
77
+ _player.stop()
78
+ _playhead = 0.0
79
+ _playing = false
80
+ StudioBus.playback_state_changed.emit(false)
81
+
82
+
83
+ ## Jump the playhead to `time` seconds (scrubbing).
84
+ func scrub(time: float) -> void:
85
+ _ensure_player()
86
+ time = clampf(time, 0.0, duration)
87
+ _playhead = time
88
+ _player.seek(time, true)
89
+ StudioBus.timeline_playhead.emit(time)
90
+
91
+
92
+ func is_playing() -> bool:
93
+ return _playing
94
+
95
+
96
+ func playhead() -> float:
97
+ return _playhead
98
+
99
+
100
+ ## --- Keyframes --------------------------------------------------------------
101
+
102
+ ## Add a keyframe for a `property` (NodePath-style) on an object at `time`.
103
+ ## For bones use property like "bones/Head:position" (Skeleton3D pose tracks).
104
+ func add_keyframe(target_node: Node3D, property: String, time: float, value: Variant) -> void:
105
+ _ensure_player()
106
+ time = clampf(time, 0.0, duration)
107
+ var path := _player.get_path_to(target_node)
108
+ if path == NodePath():
109
+ push_warning("Timeline: target is not under the player root")
110
+ return
111
+ var track := _find_or_create_track(String(path), property, value)
112
+ var key_idx := _anim.track_insert_key(track, time, value, 0.0)
113
+ if key_idx >= 0:
114
+ _key_count += 1
115
+ StudioBus.keyframe_added.emit(String(path), property, time)
116
+ StudioBus.scene_modified.emit()
117
+
118
+
119
+ func _find_or_create_track(node_path: String, property: String, value: Variant) -> int:
120
+ var full := NodePath(node_path + ":" + property)
121
+ for i in range(_anim.get_track_count()):
122
+ if _anim.track_get_path(i) == full:
123
+ return i
124
+ var type := Animation.TYPE_VALUE
125
+ if value is Transform3D:
126
+ type = Animation.TYPE_VALUE
127
+ _anim.add_track(type)
128
+ var idx := _anim.get_track_count() - 1
129
+ _anim.track_set_path(idx, full)
130
+ if value is Vector3:
131
+ _anim.track_set_interpolation_type(idx, Animation.INTERPOLATION_CUBIC)
132
+ return idx
133
+
134
+
135
+ ## Convenience: key every transform channel of a bone of a Skeleton3D.
136
+ ## Godot stores bone poses as 3D transforms, so we key position/rotation/scale.
137
+ func add_bone_keyframe(skeleton: Skeleton3D, bone_name: String, time: float) -> void:
138
+ if skeleton == null:
139
+ return
140
+ var bone_idx := skeleton.find_bone(bone_name)
141
+ if bone_idx < 0:
142
+ push_warning("Timeline: bone '%s' not found" % bone_name)
143
+ return
144
+ var skin_path := "%s:%s" % [_player.get_path_to(skeleton), bone_name]
145
+ var pose := skeleton.get_bone_pose(bone_idx)
146
+ add_keyframe(skeleton, "bones/" + bone_name + "/position", time, pose.origin)
147
+ # Store rotation as quaternion for clean interpolation.
148
+ add_keyframe(skeleton, "bones/" + bone_name + "/rotation", time, pose.basis.get_rotation_quaternion())
149
+ add_keyframe(skeleton, "bones/" + bone_name + "/scale", time, pose.basis.get_scale())
150
+
151
+
152
+ func delete_keyframe(track: int, key: int) -> void:
153
+ if track >= 0 and track < _anim.get_track_count():
154
+ _anim.track_remove_key(track, key)
155
+ _key_count = maxi(0, _key_count - 1)
156
+
157
+
158
+ func track_count() -> int:
159
+ return _anim.get_track_count() if _anim != null else 0
160
+
161
+
162
+ func key_count() -> int:
163
+ return _key_count
164
+
165
+
166
+ func set_duration(seconds: float) -> void:
167
+ duration = maxf(0.1, seconds)
168
+ _ensure_player()
169
+ _anim.length = duration
170
+
171
+
172
+ func _on_finished() -> void:
173
+ _playing = false
174
+ StudioBus.playback_state_changed.emit(false)
scripts/modules/timeline.gd.uid ADDED
@@ -0,0 +1 @@
 
 
1
+ uid://rfbjygv1at48
scripts/studio/studio_main.gd ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ extends Node3D
2
+ ##
3
+ ## StudioMain - root bootstrap for MonkeyStudio.
4
+ ##
5
+ ## Builds the runtime scene tree in code so the whole app is one auditable
6
+ ## script rather than a fragile binary .tscn. Scene tree created:
7
+ ##
8
+ ## StudioMain (Node3D)
9
+ ## ├── WorldEnvironment (mobile renderer settings)
10
+ ## ├── Sun (DirectionalLight3D)
11
+ ## ├── Camera3D (touch_camera.gd)
12
+ ## ├── Geometry (Node3D) <- user-created meshes live here
13
+ ## ├── Rig (Node3D) <- Skeleton3D lives here
14
+ ## ├── Features (Node) <- lazily-instantiated feature modules
15
+ ## └── UI (CanvasLayer)
16
+ ## └── (built by studio_ui.gd)
17
+ ##
18
+
19
+ const TouchCameraScript := preload("res://scripts/studio/touch_camera.gd")
20
+ const StudioUIScript := preload("res://scripts/studio/studio_ui.gd")
21
+
22
+ var camera: Camera3D
23
+ var geometry: Node3D
24
+ var rig: Node3D
25
+ var feature_host: Node
26
+ var ui: CanvasLayer
27
+
28
+
29
+ func _ready() -> void:
30
+ _build_environment()
31
+ _build_lighting()
32
+ _build_camera()
33
+ geometry = _make_node("Geometry")
34
+ rig = _make_node("Rig")
35
+ feature_host = Node.new()
36
+ feature_host.name = "Features"
37
+ add_child(feature_host)
38
+ FeatureRegistry.host = feature_host
39
+ _build_ui()
40
+ _spawn_starter_scene()
41
+ _connect_signals()
42
+ print("[StudioMain] ready. %d features available." % FeatureRegistry.feature_count())
43
+
44
+
45
+ func _build_environment() -> void:
46
+ var env := Environment.new()
47
+ env.background_mode = Environment.BG_COLOR
48
+ env.background_color = Color(0.08, 0.09, 0.11)
49
+ env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
50
+ env.ambient_light_color = Color(0.35, 0.38, 0.45)
51
+ env.ambient_light_energy = 0.6
52
+ # Mobile-friendly tonemap + no heavy SSAO/GI by default.
53
+ env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
54
+ env.ssao_enabled = false
55
+ env.glow_enabled = false
56
+ var we := WorldEnvironment.new()
57
+ we.name = "WorldEnvironment"
58
+ we.environment = env
59
+ add_child(we)
60
+
61
+
62
+ func _build_lighting() -> void:
63
+ var sun := DirectionalLight3D.new()
64
+ sun.name = "Sun"
65
+ sun.rotation_degrees = Vector3(-50, -40, 0)
66
+ sun.light_energy = 1.1
67
+ sun.shadow_enabled = true
68
+ # Lower shadow resolution on mobile to save bandwidth.
69
+ sun.directional_shadow_max_distance = 40.0
70
+ add_child(sun)
71
+
72
+
73
+ func _build_camera() -> void:
74
+ camera = TouchCameraScript.new()
75
+ camera.name = "Camera3D"
76
+ camera.fov = 60.0
77
+ camera.position = Vector3(4, 4, 8)
78
+ add_child(camera)
79
+ camera.focus = Vector3.ZERO
80
+
81
+
82
+ func _make_node(n: String) -> Node3D:
83
+ var node := Node3D.new()
84
+ node.name = n
85
+ add_child(node)
86
+ return node
87
+
88
+
89
+ func _build_ui() -> void:
90
+ ui = StudioUIScript.new()
91
+ ui.name = "UI"
92
+ add_child(ui)
93
+
94
+
95
+ ## Spawn a cube so the app is immediately interactive on first launch.
96
+ func _spawn_starter_scene() -> void:
97
+ FeatureRegistry.activate("mesh.cube")
98
+ var gen: Node = feature_host.get_node_or_null("Feature_mesh.cube")
99
+ if gen != null and gen.has_method("spawn"):
100
+ var mesh: MeshInstance3D = gen.call("spawn", "cube", {"size": 1.5})
101
+ _ensure_collision(mesh)
102
+ StudioBus.object_selected.emit(mesh)
103
+ if camera != null:
104
+ camera.frame_aabb(mesh.mesh.get_aabb())
105
+
106
+
107
+ ## Give a mesh a static trimesh collider so sculpting raycasts hit it.
108
+ func _ensure_collision(mi: MeshInstance3D) -> void:
109
+ if mi == null or mi.mesh == null:
110
+ return
111
+ var body := StaticBody3D.new()
112
+ body.name = mi.name + "_Body"
113
+ var shape := CollisionShape3D.new()
114
+ var trimesh := mi.mesh.create_trimesh_shape()
115
+ if trimesh != null:
116
+ shape.shape = trimesh
117
+ else:
118
+ # Fallback to a box for exotic primitives without trimesh support.
119
+ var box := BoxShape3D.new()
120
+ box.size = mi.mesh.get_aabb().size
121
+ shape.shape = box
122
+ body.add_child(shape)
123
+ mi.add_child(body)
124
+
125
+
126
+ func _connect_signals() -> void:
127
+ StudioBus.fps_throttled.connect(_on_fps_throttled)
128
+ MemoryManager.memory_warning.connect(_on_memory_warning)
129
+
130
+
131
+ func _on_fps_throttled(fps: int) -> void:
132
+ # Shed load: disable shadows first, then reduce the sim substep budget.
133
+ var sun := get_node_or_null("Sun") as DirectionalLight3D
134
+ if sun != null and sun.shadow_enabled:
135
+ sun.shadow_enabled = false
136
+ print("[Perf] shadows off at %d fps" % fps)
137
+
138
+
139
+ func _on_memory_warning(used_mb: float, budget_mb: float) -> void:
140
+ print("[Mem] warning: %.0f/%.0f MB" % [used_mb, budget_mb])
scripts/studio/studio_main.gd.uid ADDED
@@ -0,0 +1 @@
 
 
1
+ uid://ds0chccxfkthr
scripts/studio/studio_ui.gd ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ extends CanvasLayer
2
+ ##
3
+ ## StudioUI - dark material theme + floating, collapsible panels (Step 4).
4
+ ##
5
+ ## Layout (all built in code, resolution independent, scaled by a UI_SCALE
6
+ ## constant so it fits phones in portrait/landscape and tablets):
7
+ ##
8
+ ## TopBar : project name, memory HUD, undo/redo
9
+ ## LeftDock : tool rail (mesh / sculpt / rig / sim / anim)
10
+ ## RightPanels : collapsible panels -> Tools, Layers, Timeline
11
+ ## BottomBar : play/pause + timeline scrubber
12
+ ##
13
+ ## Touch targets are >= 48px. The theme is Material-ish dark to match the
14
+ ## viewport and reduce OLED power draw on phones.
15
+ ##
16
+
17
+ const UI_SCALE := 1.0
18
+ const TOUCH_MIN := 48.0
19
+
20
+ var _touch_camera: Camera3D = null
21
+ var _ui_root: Control
22
+ var _panels: Dictionary = {} # panel_id -> PanelContainer
23
+ var _tool_buttons: Dictionary = {}
24
+ var _memory_label: Label
25
+ var _fps_label: Label
26
+ var _timeline: HSlider
27
+ var _play_button: Button
28
+ var _status: Label
29
+
30
+ var _active_brush := "sculpt.draw"
31
+
32
+
33
+ func _ready() -> void:
34
+ layer = 10
35
+ _touch_camera = get_parent().get_node_or_null("Camera3D") as Camera3D
36
+ _build_theme()
37
+ _build_root()
38
+ _build_top_bar()
39
+ _build_tool_rail()
40
+ _build_panels()
41
+ _build_bottom_bar()
42
+ _connect_bus()
43
+
44
+
45
+ ## --- Theme ------------------------------------------------------------------
46
+
47
+ func _build_theme() -> void:
48
+ var theme := Theme.new()
49
+ theme.default_font_size = int(16 * UI_SCALE)
50
+
51
+ var panel := StyleBoxFlat.new()
52
+ panel.bg_color = Color(0.11, 0.12, 0.15, 0.94)
53
+ panel.corner_radius_top_left = 10
54
+ panel.corner_radius_top_right = 10
55
+ panel.corner_radius_bottom_left = 10
56
+ panel.corner_radius_bottom_right = 10
57
+ panel.content_margin_left = 10
58
+ panel.content_margin_right = 10
59
+ panel.content_margin_top = 8
60
+ panel.content_margin_bottom = 8
61
+ theme.set_stylebox("panel", "PanelContainer", panel)
62
+
63
+ var btn := StyleBoxFlat.new()
64
+ btn.bg_color = Color(0.18, 0.2, 0.25)
65
+ btn.corner_radius_top_left = 8
66
+ btn.corner_radius_top_right = 8
67
+ btn.corner_radius_bottom_left = 8
68
+ btn.corner_radius_bottom_right = 8
69
+ btn.content_margin_left = 12
70
+ btn.content_margin_right = 12
71
+ btn.content_margin_top = 8
72
+ btn.content_margin_bottom = 8
73
+ theme.set_stylebox("normal", "Button", btn)
74
+
75
+ var btn_hover := btn.duplicate() as StyleBoxFlat
76
+ btn_hover.bg_color = Color(0.24, 0.27, 0.33)
77
+ theme.set_stylebox("hover", "Button", btn_hover)
78
+
79
+ var btn_pressed := btn.duplicate() as StyleBoxFlat
80
+ btn_pressed.bg_color = Color(0.31, 0.55, 0.86)
81
+ theme.set_stylebox("pressed", "Button", btn_pressed)
82
+
83
+ theme.set_color("font_color", "Label", Color(0.88, 0.9, 0.94))
84
+ theme.set_color("font_color", "Button", Color(0.88, 0.9, 0.94))
85
+
86
+ _ui_root_theme = theme
87
+
88
+
89
+ var _ui_root_theme: Theme
90
+
91
+
92
+ ## --- Layout -----------------------------------------------------------------
93
+
94
+ func _build_root() -> void:
95
+ var root := Control.new()
96
+ root.name = "Root"
97
+ root.theme = _ui_root_theme
98
+ root.set_anchors_preset(Control.PRESET_FULL_RECT)
99
+ root.mouse_filter = Control.MOUSE_FILTER_IGNORE
100
+ add_child(root)
101
+ _ui_root = root
102
+
103
+
104
+ func _build_top_bar() -> void:
105
+ var bar := PanelContainer.new()
106
+ bar.name = "TopBar"
107
+ bar.set_anchors_preset(Control.PRESET_TOP_WIDE)
108
+ bar.custom_minimum_size = Vector2(0, 52 * UI_SCALE)
109
+ bar.mouse_filter = Control.MOUSE_FILTER_STOP
110
+ _ui_root.add_child(bar)
111
+
112
+ var hbox := HBoxContainer.new()
113
+ hbox.add_theme_constant_override("separation", 10)
114
+ bar.add_child(hbox)
115
+
116
+ var title := Label.new()
117
+ title.text = " MonkeyStudio"
118
+ title.add_theme_font_size_override("font_size", int(20 * UI_SCALE))
119
+ hbox.add_child(title)
120
+
121
+ var spacer := Control.new()
122
+ spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
123
+ hbox.add_child(spacer)
124
+
125
+ _fps_label = Label.new()
126
+ _fps_label.text = "FPS --"
127
+ hbox.add_child(_fps_label)
128
+
129
+ _memory_label = Label.new()
130
+ _memory_label.text = "MEM --"
131
+ hbox.add_child(_memory_label)
132
+
133
+
134
+ func _build_tool_rail() -> void:
135
+ var rail := PanelContainer.new()
136
+ rail.name = "LeftDock"
137
+ rail.set_anchors_preset(Control.PRESET_CENTER_LEFT)
138
+ rail.position = Vector2(8, 0)
139
+ rail.mouse_filter = Control.MOUSE_FILTER_STOP
140
+ _ui_root.add_child(rail)
141
+
142
+ var vbox := VBoxContainer.new()
143
+ vbox.add_theme_constant_override("separation", 8)
144
+ rail.add_child(vbox)
145
+
146
+ var tools := [
147
+ ["mesh", "Mesh", "mesh.cube"],
148
+ ["sculpt", "Sculpt", "sculpt.draw"],
149
+ ["rig", "Rig", "rig.skeleton"],
150
+ ["sim", "Sim", "sim.cloth"],
151
+ ["anim", "Anim", "anim.timeline"],
152
+ ]
153
+ for t in tools:
154
+ var b := Button.new()
155
+ b.text = t[1]
156
+ b.custom_minimum_size = Vector2(96 * UI_SCALE, TOUCH_MIN * UI_SCALE)
157
+ b.pressed.connect(_on_tool_pressed.bind(t[2]))
158
+ vbox.add_child(b)
159
+ _tool_buttons[t[0]] = b
160
+
161
+
162
+ func _build_panels() -> void:
163
+ var stack := VBoxContainer.new()
164
+ stack.name = "RightPanels"
165
+ stack.set_anchors_preset(Control.PRESET_TOP_RIGHT)
166
+ stack.position = Vector2(-8, 60)
167
+ stack.add_theme_constant_override("separation", 8)
168
+ _ui_root.add_child(stack)
169
+
170
+ _add_panel(stack, "tools", "Tools", _build_tools_panel())
171
+ _add_panel(stack, "layers", "Layers", _build_layers_panel())
172
+ _add_panel(stack, "timeline", "Timeline", _build_timeline_panel())
173
+
174
+
175
+ func _add_panel(parent: Control, id: String, title: String, content: Control) -> void:
176
+ var panel := PanelContainer.new()
177
+ panel.name = "Panel_" + id
178
+ panel.custom_minimum_size = Vector2(220 * UI_SCALE, 0)
179
+ parent.add_child(panel)
180
+
181
+ var vbox := VBoxContainer.new()
182
+ panel.add_child(vbox)
183
+
184
+ var header := Button.new()
185
+ header.text = "v " + title
186
+ header.alignment = HORIZONTAL_ALIGNMENT_LEFT
187
+ header.custom_minimum_size = Vector2(0, TOUCH_MIN * UI_SCALE * 0.8)
188
+ header.pressed.connect(_toggle_panel.bind(id))
189
+ vbox.add_child(header)
190
+
191
+ var body := MarginContainer.new()
192
+ body.name = "Body"
193
+ body.add_child(content)
194
+ vbox.add_child(body)
195
+ _panels[id] = panel
196
+
197
+
198
+ func _build_tools_panel() -> Control:
199
+ var box := VBoxContainer.new()
200
+ box.add_theme_constant_override("separation", 6)
201
+ var label := Label.new()
202
+ label.text = "Brush"
203
+ box.add_child(label)
204
+ for b in ["draw", "grab", "pinch", "smooth", "flatten"]:
205
+ var btn := Button.new()
206
+ btn.text = b.capitalize()
207
+ btn.custom_minimum_size = Vector2(0, TOUCH_MIN * UI_SCALE)
208
+ btn.pressed.connect(_on_brush_selected.bind("sculpt." + b))
209
+ box.add_child(btn)
210
+ box.add_child(_make_slider("Radius", 0.05, 2.0, 0.5, func(v): StudioBus.brush_radius_changed.emit(v)))
211
+ box.add_child(_make_slider("Strength", 0.05, 1.0, 0.5, func(v): StudioBus.brush_strength_changed.emit(v)))
212
+ return box
213
+
214
+
215
+ func _build_layers_panel() -> Control:
216
+ var box := VBoxContainer.new()
217
+ var label := Label.new()
218
+ label.text = "Scene objects appear here."
219
+ box.add_child(label)
220
+ return box
221
+
222
+
223
+ func _build_timeline_panel() -> Control:
224
+ var box := VBoxContainer.new()
225
+ var label := Label.new()
226
+ label.text = "Keyframe the selected object at the playhead."
227
+ box.add_child(label)
228
+ var key_btn := Button.new()
229
+ key_btn.text = "Add Keyframe"
230
+ key_btn.custom_minimum_size = Vector2(0, TOUCH_MIN * UI_SCALE)
231
+ key_btn.pressed.connect(_on_add_keyframe)
232
+ box.add_child(key_btn)
233
+ return box
234
+
235
+
236
+ func _make_slider(label_text: String, mn: float, mx: float, val: float, on_change: Callable) -> Control:
237
+ var wrap := VBoxContainer.new()
238
+ var l := Label.new()
239
+ l.text = label_text
240
+ wrap.add_child(l)
241
+ var s := HSlider.new()
242
+ s.min_value = mn
243
+ s.max_value = mx
244
+ s.step = 0.01
245
+ s.value = val
246
+ s.custom_minimum_size = Vector2(180 * UI_SCALE, TOUCH_MIN * UI_SCALE)
247
+ s.value_changed.connect(on_change)
248
+ wrap.add_child(s)
249
+ return wrap
250
+
251
+
252
+ func _build_bottom_bar() -> void:
253
+ var bar := PanelContainer.new()
254
+ bar.name = "BottomBar"
255
+ bar.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
256
+ bar.custom_minimum_size = Vector2(0, 56 * UI_SCALE)
257
+ bar.mouse_filter = Control.MOUSE_FILTER_STOP
258
+ _ui_root.add_child(bar)
259
+
260
+ var hbox := HBoxContainer.new()
261
+ hbox.add_theme_constant_override("separation", 10)
262
+ bar.add_child(hbox)
263
+
264
+ _play_button = Button.new()
265
+ _play_button.text = "Play"
266
+ _play_button.custom_minimum_size = Vector2(84 * UI_SCALE, TOUCH_MIN * UI_SCALE)
267
+ _play_button.pressed.connect(_on_play_pressed)
268
+ hbox.add_child(_play_button)
269
+
270
+ _timeline = HSlider.new()
271
+ _timeline.min_value = 0.0
272
+ _timeline.max_value = 5.0
273
+ _timeline.step = 0.01
274
+ _timeline.size_flags_horizontal = Control.SIZE_EXPAND_FILL
275
+ _timeline.custom_minimum_size = Vector2(0, TOUCH_MIN * UI_SCALE)
276
+ _timeline.value_changed.connect(_on_timeline_scrub)
277
+ hbox.add_child(_timeline)
278
+
279
+ _status = Label.new()
280
+ _status.text = "0.00s"
281
+ hbox.add_child(_status)
282
+
283
+
284
+ ## --- Behaviour --------------------------------------------------------------
285
+
286
+ func _connect_bus() -> void:
287
+ StudioBus.timeline_playhead.connect(_on_playhead)
288
+ StudioBus.playback_state_changed.connect(_on_playback_state)
289
+ StudioBus.brush_changed.connect(_on_brush_changed)
290
+ MemoryManager.memory_warning.connect(func(_u, _b): _refresh_hud())
291
+
292
+
293
+ func _process(_delta: float) -> void:
294
+ _fps_label.text = "FPS %d" % Engine.get_frames_per_second()
295
+ _refresh_hud()
296
+
297
+
298
+ func _refresh_hud() -> void:
299
+ var used: float = MemoryManager.get_loaded_mb()
300
+ _memory_label.text = "MEM %.0f/%.0f MB" % [used, MemoryManager.asset_budget_mb]
301
+
302
+
303
+ func _toggle_panel(id: String) -> void:
304
+ var panel: PanelContainer = _panels.get(id)
305
+ if panel == null:
306
+ return
307
+ var body := panel.get_node_or_null("VBoxContainer/Body")
308
+ # PanelContainer -> VBox -> [Button, MarginContainer]
309
+ var vbox := panel.get_child(0)
310
+ var body_node := vbox.get_node_or_null("Body") as Control
311
+ if body_node != null:
312
+ body_node.visible = not body_node.visible
313
+ StudioBus.panel_toggled.emit(id, body_node != null and body_node.visible)
314
+
315
+
316
+ func _on_tool_pressed(feature_id: String) -> void:
317
+ # Turning on a sculpt brush means the finger paints; navigation uses 2 fingers.
318
+ var is_sculpt := feature_id.begins_with("sculpt.")
319
+ if _touch_camera != null:
320
+ _touch_camera.set("one_finger_orbit", not is_sculpt)
321
+ if is_sculpt:
322
+ var ok := FeatureRegistry.activate("sculpt.draw")
323
+ if ok:
324
+ # Ensure the sculpt module is wired to the selected object.
325
+ var sculpt: Node = find_sculpt_module()
326
+ if sculpt != null and StudioBus.has_signal("brush_changed"):
327
+ StudioBus.brush_changed.emit(feature_id)
328
+ _activate_feature_family(feature_id)
329
+ _set_status("Tool: " + feature_id)
330
+
331
+
332
+ func find_sculpt_module() -> Node:
333
+ var host := get_parent().get_node_or_null("Features")
334
+ if host == null:
335
+ return null
336
+ return host.get_node_or_null("Feature_sculpt.draw")
337
+
338
+
339
+ func _activate_feature_family(feature_id: String) -> void:
340
+ match feature_id:
341
+ "mesh.cube": FeatureRegistry.activate("mesh.cube")
342
+ "rig.skeleton": FeatureRegistry.activate("rig.skeleton")
343
+ "sim.cloth":
344
+ FeatureRegistry.activate("sim.cloth")
345
+ StudioBus.sim_toggled.emit("sim.cloth", true)
346
+ "anim.timeline": FeatureRegistry.activate("anim.timeline")
347
+ _: pass
348
+
349
+
350
+ func _on_brush_selected(id: String) -> void:
351
+ _active_brush = id
352
+ StudioBus.brush_changed.emit(id)
353
+ _set_status("Brush: " + id)
354
+
355
+
356
+ func _on_brush_changed(id: String) -> void:
357
+ _set_status("Brush: " + id)
358
+
359
+
360
+ func _on_play_pressed() -> void:
361
+ var tl := _get_timeline_module()
362
+ if tl == null:
363
+ return
364
+ if tl.call("is_playing"):
365
+ tl.call("pause")
366
+ else:
367
+ tl.call("play")
368
+
369
+
370
+ func _on_timeline_scrub(value: float) -> void:
371
+ var tl := _get_timeline_module()
372
+ if tl != null:
373
+ tl.call("scrub", value)
374
+ _status.text = "%.2fs" % value
375
+
376
+
377
+ func _on_playhead(t: float) -> void:
378
+ _timeline.set_value_no_signal(t)
379
+ _status.text = "%.2fs" % t
380
+
381
+
382
+ func _on_playback_state(playing: bool) -> void:
383
+ _play_button.text = "Pause" if playing else "Play"
384
+
385
+
386
+ func _on_add_keyframe() -> void:
387
+ var tl := _get_timeline_module()
388
+ if tl == null:
389
+ FeatureRegistry.activate("anim.timeline")
390
+ tl = _get_timeline_module()
391
+ if tl == null:
392
+ return
393
+ var selected := _selected_object()
394
+ if selected != null:
395
+ tl.call("add_keyframe", selected, "position", _timeline.value, selected.position)
396
+ _set_status("Keyframe added")
397
+
398
+
399
+ func _get_timeline_module() -> Node:
400
+ var host := get_parent().get_node_or_null("Features")
401
+ if host == null:
402
+ return null
403
+ return host.get_node_or_null("Feature_anim.timeline")
404
+
405
+
406
+ func _selected_object() -> Node3D:
407
+ var geom := get_parent().get_node_or_null("Geometry")
408
+ if geom == null:
409
+ return null
410
+ for child in geom.get_children():
411
+ if child is MeshInstance3D:
412
+ return child
413
+ return null
414
+
415
+
416
+ func _set_status(text: String) -> void:
417
+ if _status != null:
418
+ _status.text = text
scripts/studio/studio_ui.gd.uid ADDED
@@ -0,0 +1 @@
 
 
1
+ uid://brs4dwcg1h1qd
scripts/studio/touch_camera.gd ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ extends Camera3D
2
+ ##
3
+ ## TouchCamera - lag-free mobile navigation for the 3D viewport (Step 3.1).
4
+ ##
5
+ ## Gesture map (configurable):
6
+ ## one finger drag -> orbit (rotate around the focus point)
7
+ ## two finger pinch -> dolly zoom
8
+ ## two finger drag -> pan (move the focus point in screen space)
9
+ ## three finger drag -> pan (alternative, larger screens)
10
+ ##
11
+ ## Design: raw InputEvent handling (not _input echo), one gesture re-evaluated
12
+ ## per frame, no allocations in the hot path. Values are smoothed with an
13
+ ## exponential filter so motion feels fluid even when touch sampling is jittery.
14
+ ##
15
+
16
+ @export var orbit_speed: float = 0.4
17
+ @export var pan_speed: float = 0.6
18
+ @export var zoom_speed: float = 1.2
19
+ @export var min_distance: float = 0.5
20
+ @export var max_distance: float = 200.0
21
+ @export var smoothing: float = 18.0 # higher = snappier, lower = floatier
22
+
23
+ var focus: Vector3 = Vector3.ZERO
24
+ var _yaw: float = 0.6
25
+ var _pitch: float = -0.5
26
+ var _distance: float = 8.0
27
+
28
+ ## When false, a single finger is reserved for sculpting/painting and only
29
+ ## multi-finger gestures navigate. The UI flips this when a sculpt tool is on.
30
+ var one_finger_orbit: bool = true
31
+ var _painting := false
32
+ var _last_paint: Vector2 = Vector2.ZERO
33
+
34
+ # Smoothed targets
35
+ var _t_yaw: float = 0.6
36
+ var _t_pitch: float = -0.5
37
+ var _t_distance: float = 8.0
38
+ var _t_focus: Vector3 = Vector3.ZERO
39
+
40
+ # Touch bookkeeping
41
+ var _touches: Dictionary = {} # index -> position
42
+ var _prev_centroid: Vector2 = Vector2.ZERO
43
+ var _prev_spread: float = 0.0
44
+ var _gesture_active := false
45
+
46
+
47
+ func _ready() -> void:
48
+ _t_yaw = _yaw
49
+ _t_pitch = _pitch
50
+ _t_distance = _distance
51
+ _t_focus = focus
52
+ _apply_transform()
53
+
54
+
55
+ func _input(event: InputEvent) -> void:
56
+ if event is InputEventScreenTouch:
57
+ _handle_touch(event)
58
+ elif event is InputEventScreenDrag:
59
+ _handle_drag(event)
60
+
61
+
62
+ func _handle_touch(e: InputEventScreenTouch) -> void:
63
+ if e.pressed:
64
+ _touches[e.index] = e.position
65
+ if e.index == 0 and not one_finger_orbit and _touches.size() == 1:
66
+ _painting = true
67
+ _last_paint = e.position
68
+ StudioBus.stroke_begin.emit(_ray_center(e.position))
69
+ else:
70
+ _touches.erase(e.index)
71
+ if e.index == 0 and _painting:
72
+ _painting = false
73
+ StudioBus.stroke_end.emit()
74
+ if _touches.size() >= 2:
75
+ _recompute_gesture_baseline()
76
+
77
+
78
+ func _handle_drag(e: InputEventScreenDrag) -> void:
79
+ _touches[e.index] = e.position
80
+ if _painting and e.index == 0 and not one_finger_orbit:
81
+ var world := _ray_center(e.position)
82
+ var prev_world := _ray_center(_last_paint)
83
+ StudioBus.stroke_apply.emit(world, world - prev_world)
84
+ _last_paint = e.position
85
+ return
86
+ var n := _touches.size()
87
+ if n == 1:
88
+ # Orbit
89
+ var d := e.relative
90
+ _t_yaw -= d.x * orbit_speed * 0.01
91
+ _t_pitch = clampf(_t_pitch - d.y * orbit_speed * 0.01, -1.5, 1.5)
92
+ elif n == 2:
93
+ var pts := _touches.values()
94
+ var centroid: Vector2 = (pts[0] + pts[1]) * 0.5
95
+ var spread: float = (pts[0] as Vector2).distance_to(pts[1] as Vector2)
96
+ if _prev_spread > 0.0:
97
+ var dzoom := (spread - _prev_spread) * zoom_speed * 0.01
98
+ _t_distance = clampf(_t_distance - dzoom, min_distance, max_distance)
99
+ var dpan := centroid - _prev_centroid
100
+ _apply_pan(dpan)
101
+ _prev_centroid = centroid
102
+ _prev_spread = spread
103
+ elif n >= 3:
104
+ var pts := _touches.values()
105
+ var centroid := Vector2.ZERO
106
+ for p in pts:
107
+ centroid += p
108
+ centroid /= float(pts.size())
109
+ var dpan := centroid - _prev_centroid
110
+ _apply_pan(dpan)
111
+ _prev_centroid = centroid
112
+
113
+
114
+ func _recompute_gesture_baseline() -> void:
115
+ var pts := _touches.values()
116
+ if pts.size() >= 2:
117
+ _prev_centroid = (pts[0] + pts[1]) * 0.5
118
+ _prev_spread = (pts[0] as Vector2).distance_to(pts[1] as Vector2)
119
+
120
+
121
+ func _apply_pan(screen_delta: Vector2) -> void:
122
+ # Pan along camera right/up, scaled by distance so it feels consistent at
123
+ # any zoom level.
124
+ var scale := _distance * pan_speed * 0.002
125
+ var right := global_transform.basis.x
126
+ var up := global_transform.basis.y
127
+ _t_focus -= right * screen_delta.x * scale
128
+ _t_focus += up * screen_delta.y * scale
129
+ focus = _t_focus
130
+
131
+
132
+ func _process(delta: float) -> void:
133
+ var t := 1.0 - exp(-smoothing * delta)
134
+ _yaw = lerpf(_yaw, _t_yaw, t)
135
+ _pitch = lerpf(_pitch, _t_pitch, t)
136
+ _distance = lerpf(_distance, _t_distance, t)
137
+ focus = focus.lerp(_t_focus, t)
138
+ _apply_transform()
139
+
140
+
141
+ func _apply_transform() -> void:
142
+ var offset := Vector3(
143
+ cos(_pitch) * sin(_yaw),
144
+ sin(_pitch),
145
+ cos(_pitch) * cos(_yaw)
146
+ ) * _distance
147
+ global_position = focus + offset
148
+ look_at(focus, Vector3.UP)
149
+
150
+
151
+ ## --- Public helpers ---------------------------------------------------------
152
+
153
+ func frame_aabb(box: AABB, margin: float = 1.6) -> void:
154
+ _t_focus = box.get_center()
155
+ focus = _t_focus
156
+ var radius := maxf(0.5, box.size.length() * 0.5)
157
+ _t_distance = clampf(radius * margin * 2.0, min_distance, max_distance)
158
+ _distance = _t_distance
159
+
160
+
161
+ func set_focus(p: Vector3) -> void:
162
+ _t_focus = p
163
+ focus = p
164
+
165
+
166
+ func screen_to_ray(screen_pos: Vector2) -> Dictionary:
167
+ return {
168
+ "origin": project_ray_origin(screen_pos),
169
+ "dir": project_ray_normal(screen_pos),
170
+ }
171
+
172
+
173
+ ## Ray-cast the viewport at a screen point against physics; returns the world
174
+ ## hit (or a point in front of the camera if nothing is hit).
175
+ func _ray_center(screen_pos: Vector2) -> Vector3:
176
+ var origin := project_ray_origin(screen_pos)
177
+ var dir := project_ray_normal(screen_pos)
178
+ var space := get_world_3d().direct_space_state
179
+ var q := PhysicsRayQueryParameters3D.create(origin, origin + dir * 1000.0)
180
+ var res := space.intersect_ray(q)
181
+ if not res.is_empty():
182
+ return res["position"]
183
+ return origin + dir * _distance
scripts/studio/touch_camera.gd.uid ADDED
@@ -0,0 +1 @@
 
 
1
+ uid://diopfaymyxcso
tests/self_test.gd ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ extends Node
2
+ ##
3
+ ## SelfTest - headless verification of every core subsystem.
4
+ ## Run: godot --headless res://tests/self_test.tscn
5
+ ##
6
+ ## Exits with code 0 if all checks pass, 1 otherwise. This is the regression
7
+ ## gate for the modules; it does not need a display or GPU.
8
+ ##
9
+
10
+ var _failures := 0
11
+ var _host: Node3D
12
+
13
+
14
+ func _ready() -> void:
15
+ _host = Node3D.new()
16
+ _host.name = "TestHost"
17
+ var geom := Node3D.new()
18
+ geom.name = "Geometry"
19
+ _host.add_child(geom)
20
+ var rig := Node3D.new()
21
+ rig.name = "Rig"
22
+ _host.add_child(rig)
23
+ add_child(_host)
24
+
25
+ _test_memory_manager()
26
+ _test_mesh_generators()
27
+ _test_sculpt()
28
+ _test_rigging()
29
+ _test_simulation()
30
+ _test_timeline()
31
+
32
+ print("")
33
+ if _failures == 0:
34
+ print("SELFTEST: ALL PASS")
35
+ get_tree().quit(0)
36
+ else:
37
+ print("SELFTEST: %d FAILURE(S)" % _failures)
38
+ get_tree().quit(1)
39
+
40
+
41
+ func _check(name: String, cond: bool) -> void:
42
+ if cond:
43
+ print(" [PASS] ", name)
44
+ else:
45
+ _failures += 1
46
+ print(" [FAIL] ", name)
47
+
48
+
49
+ func _test_memory_manager() -> void:
50
+ print("[Test] MemoryManager")
51
+ _check("budget set", MemoryManager.asset_budget_mb > 0.0)
52
+ _check("feature count >= 20", FeatureRegistry.feature_count() >= 20)
53
+ var snap := MemoryManager.debug_snapshot()
54
+ _check("snapshot has budget", snap.has("budget_mb"))
55
+
56
+
57
+ func _load_module(path: String) -> Node:
58
+ var script: GDScript = load(path)
59
+ var n: Node = script.new()
60
+ _host.add_child(n)
61
+ if n.has_method("_activate"):
62
+ n.call("_activate", _host)
63
+ return n
64
+
65
+
66
+ func _test_mesh_generators() -> void:
67
+ print("[Test] Mesh generators")
68
+ var mg := _load_module("res://scripts/modules/mesh_gen.gd")
69
+ for kind in ["cube", "sphere", "cylinder", "plane", "torus", "ico", "suzanne"]:
70
+ var data: Dictionary = mg.call("generate", kind, {})
71
+ var vcount: int = (data["verts"] as PackedVector3Array).size()
72
+ var icount: int = (data["indices"] as PackedInt32Array).size()
73
+ _check("%s has verts & tris (v=%d,i=%d)" % [kind, vcount, icount], vcount > 0 and icount > 0)
74
+ var mi: MeshInstance3D = mg.call("spawn", "sphere", {"radius": 1.0})
75
+ _check("spawn returns MeshInstance3D", mi != null and mi.mesh != null and mi.mesh.get_surface_count() == 1)
76
+ var cost := VertexBufferManager.estimate_cost_mb(1000)
77
+ _check("cost estimate > 0", cost > 0.0)
78
+ mg.queue_free()
79
+
80
+
81
+ func _test_sculpt() -> void:
82
+ print("[Test] Sculpting")
83
+ var mg := _load_module("res://scripts/modules/mesh_gen.gd")
84
+ var sc := _load_module("res://scripts/modules/sculpt.gd")
85
+ var mi: MeshInstance3D = mg.call("spawn", "sphere", {"radius": 1.0})
86
+ sc.call("set_target", mi)
87
+ var before: PackedVector3Array = (mi.mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX] as PackedVector3Array).duplicate()
88
+ for brush in ["draw", "grab", "pinch", "smooth", "flatten"]:
89
+ sc.set("_brush_id", brush)
90
+ sc.call("begin", Vector3(0, 1, 0))
91
+ sc.call("apply", Vector3(0, 1, 0), Vector3(0, 0.1, 0))
92
+ sc.call("apply", Vector3(0, 1, 0), Vector3(0.05, 0.1, 0))
93
+ sc.call("end")
94
+ var after: PackedVector3Array = mi.mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX]
95
+ var changed := false
96
+ for i in range(mini(before.size(), after.size())):
97
+ if before[i].distance_to(after[i]) > 0.0001:
98
+ changed = true
99
+ break
100
+ _check("sculpt modified vertices", changed)
101
+ _check("mesh still valid after sculpt", mi.mesh.get_surface_count() == 1)
102
+ mg.queue_free()
103
+ sc.queue_free()
104
+
105
+
106
+ func _test_rigging() -> void:
107
+ print("[Test] Rigging")
108
+ var mg := _load_module("res://scripts/modules/mesh_gen.gd")
109
+ var rg := _load_module("res://scripts/modules/rigging.gd")
110
+ var mi: MeshInstance3D = mg.call("spawn", "cube", {"size": 1.0})
111
+ rg.call("generate_default_armature", mi)
112
+ _check("bones created", int(rg.call("bone_count")) >= 9)
113
+ _check("mesh has skin", mi.skin != null)
114
+ _check("mesh has skeleton path", mi.skeleton != NodePath())
115
+ if mi.mesh.get_surface_count() > 0:
116
+ var arr: Array = mi.mesh.surface_get_arrays(0)
117
+ _check("bones channel present", arr[Mesh.ARRAY_BONES] != null)
118
+ _check("weights channel present", arr[Mesh.ARRAY_WEIGHTS] != null)
119
+ mg.queue_free()
120
+ rg.queue_free()
121
+
122
+
123
+ func _test_simulation() -> void:
124
+ print("[Test] Simulation")
125
+ var sim := _load_module("res://scripts/modules/simulation.gd")
126
+ sim.call("create_cloth", "sim.cloth", 6, 6, 1.0)
127
+ var before: PackedVector3Array = (sim.get("_sims")["sim.cloth"]["particles"] as PackedVector3Array).duplicate()
128
+ for i in range(30):
129
+ sim.call("_step", sim.get("_sims")["sim.cloth"])
130
+ sim.call("_upload", sim.get("_sims")["sim.cloth"])
131
+ var after: PackedVector3Array = sim.get("_sims")["sim.cloth"]["particles"]
132
+ var moved := false
133
+ for i in range(after.size()):
134
+ if before[i].distance_to(after[i]) > 0.0001:
135
+ moved = true
136
+ break
137
+ _check("cloth fell under gravity", moved)
138
+ var mesh: ArrayMesh = sim.get("_sims")["sim.cloth"]["mesh"]
139
+ _check("cloth mesh uploaded", mesh.get_surface_count() == 1)
140
+ sim.call("create_hair", "sim.hair", 4, 5)
141
+ _check("hair sim created", int(sim.call("active_sim_count")) >= 2)
142
+ sim.queue_free()
143
+
144
+
145
+ func _test_timeline() -> void:
146
+ print("[Test] Timeline")
147
+ var mg := _load_module("res://scripts/modules/mesh_gen.gd")
148
+ var tl := _load_module("res://scripts/modules/timeline.gd")
149
+ var mi: MeshInstance3D = mg.call("spawn", "cube", {"size": 1.0})
150
+ tl.call("add_keyframe", mi, "position", 0.0, Vector3.ZERO)
151
+ tl.call("add_keyframe", mi, "position", 1.0, Vector3(0, 2, 0))
152
+ _check("track created", int(tl.call("track_count")) >= 1)
153
+ _check("key count == 2", int(tl.call("key_count")) == 2)
154
+ tl.call("scrub", 0.5)
155
+ _check("scrub clamps/works", absf(float(tl.call("playhead")) - 0.5) < 0.001)
156
+ mg.queue_free()
157
+ tl.queue_free()
tests/self_test.gd.uid ADDED
@@ -0,0 +1 @@
 
 
1
+ uid://b0chtaj3gt8qr
tests/self_test.tscn ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ [gd_scene load_steps=2 format=3]
2
+
3
+ [ext_resource type="Script" path="res://tests/self_test.gd" id="1_test"]
4
+
5
+ [node name="SelfTest" type="Node"]
6
+ script = ExtResource("1_test")