extends Node3D ## Rig lab — the room where a character's rig is adjusted by eye, in 3D, live. ## ## godot --path . res://debug/rig_lab.tscn ## ## Everything about a rig that CAN be derived from the skeleton is derived: the ## bone roles, the mount rotation, the wrist roll, the weapon size, which ## surfaces are hair. What is left over is not a missing derivation, it is ## judgement — how high a stock rides, how far an elbow flares, how far down the ## palm a grip sits on THIS character's hand. Judgement wants an eye and a ## slider, not another constant tuned against one rig and wrong on the next. ## ## This is the environment for that. Three things can be adjusted here: ## ## HOLD how this character holds this weapon. Per character AND weapon, ## because a rifle and a pistol are not held alike. ## -> assets/characters/weapon_holds.json ## ANCHORS where the grip sits in the palm and how the gun rolls in the ## fingers. Per character only — that is a fact about the hand. ## -> assets/characters/rig_anchors.json ## SURFACES what the importer decided each surface of the model IS. Read-only ## here, but isolating a class is how you check the decision: click ## `hair` and only the hair should remain. ## ## Both tuning files are layered (defaults -> this character -> this weapon) and ## an absent file means "use what the code derives", so nothing here is required ## for the game to run correctly. ## ## CONTROLS ## drag a MARKER move that anchor left drag orbit ## middle drag pan wheel zoom ## F frame the hands ## R reset knobs S save C copy JSON to clipboard ## ## The three coloured markers ARE the anchors — red is the trigger grip, green ## the support hand on the handguard, blue the buttstock — and they can be ## dragged. The marker under the mouse swells; drag it and the hand follows. ## ## Dragging an anchor is not the same as any slider above it. The sliders move ## the GUN: `grip_offset` slides the weapon around inside the fist, and ## `gun_fore` / `gun_stock` slide the hands along the weapon's own axis. Neither ## can take a hand OFF that axis, which is what a handguard below the bore, an ## angled foregrip or a pistol grip all need. A drag writes `grip_shift` or ## `fore_shift`, in the gun's own across/up/along frame, so a nudge sideways ## stays sideways as the weapon pitches; the buttstock marker writes the shoulder ## pocket for whichever pose is showing. ## ## If a hand is not ON its marker, the IK could not reach — a different problem ## from the marker being in the wrong place, and dragging the marker further will ## not fix it. const POSES := [ ["Low ready", "ground", 0.0, 0.0], ["Aiming", "ground", 0.0, 1.0], ["Running", "ground", 9.0, 0.0], ["Crouched", "ground", 0.0, 0.0], ] ## The two editable knob groups, and everything the UI needs to build, resolve ## and save each one. Adding a third group is adding a row here. ## ## `subject` is what the group is keyed on inside its file: a weapon id for the ## hold, a fixed word for anchors, because an anchor is per character and ## re-tuning it per gun would be re-answering the same question. const GROUPS := { "hold": { "title": "HOLD · this character, this weapon", "per_weapon": true, }, "anchors": { "title": "ANCHORS · this character", "per_weapon": false, }, } var _model: SkinnedPlayerModel var _skins: Array = [] var _weapons: Array = [] var _skin := 0 var _weapon := 0 var _pose := 0 ## group -> resolved knob table, and group -> the whole file it came from. var _knobs: Dictionary = {"hold": {}, "anchors": {}} var _all: Dictionary = {"hold": {}, "anchors": {}} ## group -> key -> {spec, label, x, y, z}. Only ever holds the knobs currently ## ON SCREEN, which for the hold means only the selected pose's. var _sliders: Dictionary = {"hold": {}, "anchors": {}} ## group -> the container its slider rows live in, and its heading, so the hold ## can be rebuilt when the pose changes without rebuilding the whole panel. var _knob_boxes: Dictionary = {} var _headings: Dictionary = {} ## "" means the pose buttons drive the model; anything else is a canonical clip ## being played on its own so a single animation can be watched end to end. var _clip := "" var _clip_picker: OptionButton var _surface_box: VBoxContainer var _isolated := "" ## MeshInstance3D -> surface index -> the override material displaced by ## isolation, so it can be put back without rebuilding the character. var _hidden_surfaces: Dictionary = {} var _status: Label var _cam: Camera3D var _yaw := 0.6 var _pitch := -0.1 var _dist := 2.2 var _pivot := Vector3(0, 1.25, 0) var _markers: Array = [] var _pickers: Dictionary = {} ## Frames to wait before the self-shot below. The model loads asynchronously and ## the cloth solver needs a moment to settle, so an immediate capture shows a ## half-built character. const SHOT_WARMUP := 40 var _shot_path := "" var _frames := 0 func _ready() -> void: _build_world() _collect_sources() # `-- shot [skin] [weapon]` renders one frame and quits, so the lab # can be checked without a human at the controls. var args := OS.get_cmdline_user_args() if args.size() >= 2 and String(args[0]) == "shot": _shot_path = String(args[1]) if args.size() > 2: _skin = maxi(0, _index_of(_skins, String(args[2]))) if args.size() > 3: _weapon = maxi(0, _index_of(_weapons, String(args[3]))) if args.size() > 4: # Which pose to photograph — the panel differs per pose now, so a # shot of only the default one says nothing about the others. _pose = clampi(int(args[4]), 0, POSES.size() - 1) _build_ui() _reload_model() func _index_of(list: Array, id: String) -> int: for i in list.size(): if String(list[i].id) == id: return i return -1 # ── scene ───────────────────────────────────────────────────────────────────── func _build_world() -> void: var env := WorldEnvironment.new() var e := Environment.new() e.background_mode = Environment.BG_COLOR e.background_color = Color(0.17, 0.18, 0.22) e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR e.ambient_light_color = Color(0.55, 0.57, 0.65) e.ambient_light_energy = 1.0 env.environment = e add_child(env) var key := DirectionalLight3D.new() key.rotation_degrees = Vector3(-42, 132, 0) key.light_energy = 1.5 add_child(key) var fill := DirectionalLight3D.new() fill.rotation_degrees = Vector3(-18, -40, 0) fill.light_energy = 0.5 add_child(fill) _cam = Camera3D.new() _cam.fov = 45.0 add_child(_cam) _update_camera() # Grip / support / stock, so the points under the sliders are visible. for c in [Color(1, 0.3, 0.3), Color(0.3, 1, 0.4), Color(0.4, 0.6, 1)]: var m := MeshInstance3D.new() var sphere := SphereMesh.new() sphere.radius = 0.012 sphere.height = 0.024 m.mesh = sphere var mat := StandardMaterial3D.new() mat.albedo_color = c mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED # Depth-tested on purpose. Drawn through the body they look like they are # floating in front of the chest when they are in fact behind an arm, # which is exactly the wrong impression for judging whether a hand is on # its target. `_process` lifts this for the marker under the mouse, so # the one you are about to grab is the one you can see. mat.no_depth_test = false m.material_override = mat add_child(m) _markers.append(m) func _collect_sources() -> void: for id in SkinManager.skins: var s = SkinManager.skins[id] # GLB-backed skins only; the colour tints have no skeleton to pose. if "model_path" in s and String(s.model_path) != "": _skins.append({"id": id, "name": s.skin_name, "path": s.model_path}) _skins.sort_custom(func(a, b): return a.id < b.id) for id in LoadoutManager.weapon_db: var w: Dictionary = LoadoutManager.weapon_db[id] if String(w.get("script", "")) != "": _weapons.append({"id": id, "name": w.get("name", id), "script": w["script"]}) _weapons.sort_custom(func(a, b): return a.id < b.id) _all["hold"] = WeaponHoldTuning.load_all() _all["anchors"] = RigAnchors.load_all() func _reload_model() -> void: if _model: _model.queue_free() _model = SkinnedPlayerModel.new() _model.model_path = _skins[_skin].path _model.skin_id = _skins[_skin].id add_child(_model) await get_tree().process_frame _load_knobs() _model.set_weapon(_weapons[_weapon].script) _push("hold") _push("anchors") _clip = "" _refresh_clip_picker() _rebuild_surface_list() _apply_pose() ## Fill the clip list from what this character actually HAS. ## ## Canonical names, from the model's own resolution table, rather than the raw ## clip names in the GLB — those differ per character (one rig's "CrouchIdle" is ## another's "Crouch_Idle_Loop") and the canonical name is the one the game asks ## for, so it is the one worth being able to audition. func _refresh_clip_picker() -> void: if _clip_picker == null: return _clip_picker.clear() _clip_picker.add_item("(pose driven)") if _model: var names: Array = _model.clip_names_debug() names.sort() for n in names: _clip_picker.add_item(n) _clip_picker.select(0) func _select_clip(index: int) -> void: _clip = "" if index <= 0 else _clip_picker.get_item_text(index) if _clip != "" and _model: _model.play_clip_debug(_clip) _status.text = "Pose buttons driving" if _clip == "" \ else "Playing '%s' on its own" % _clip func _apply_pose() -> void: if not _model or not _model.loaded: return # A clip chosen in the scrubber owns playback: driving update_state as well # would blend straight back to whatever the pose implies, and the clip would # never be seen. if _clip != "": return var p: Array = POSES[_pose] _model.update_state(p[1], p[2], _pose == 3) _model.set_locomotion(0.0, 1.0 if p[2] > 0.1 else 0.0, p[3]) func _process(_delta: float) -> void: _apply_pose() if _shot_path != "": _frames += 1 if _frames == SHOT_WARMUP: # Frame the hands, which is the only part anyone is judging. if _model and _model._pose_mod: _pivot = _model.skeleton.global_transform * _model._pose_mod.dbg_grip _dist = 0.7 _yaw = 1.15 _pitch = -0.15 _update_camera() elif _frames > SHOT_WARMUP + 2: var img := get_viewport().get_texture().get_image() img.save_png(_shot_path) print("rig_lab: saved ", _shot_path) get_tree().quit() return if _model and _model._pose_mod and _model.skeleton: var to_world: Transform3D = _model.skeleton.global_transform var pm = _model._pose_mod var pts := [pm.dbg_grip, pm.dbg_fore, pm.dbg_stock] for i in _markers.size(): _markers[i].global_position = to_world * pts[i] _markers[i].visible = pts[i] != Vector3.ZERO # The one being dragged, or the one the mouse is over, swells AND # draws through the body. # # Depth testing is right for the other two: drawn through the mesh # they look like they are floating in front of the chest when they # are in fact behind an arm, which is the wrong impression for # judging whether a hand reached its target. But it is wrong for the # one being grabbed — at any useful framing the hands occlude all # three markers, and a handle you cannot see is a handle you cannot # find. `_marker_under` never cared about occlusion; this makes that # visible rather than a secret. var hot: bool = (i == _drag_marker) or (_drag_marker < 0 and i == _hover_marker) _markers[i].scale = Vector3.ONE * (1.9 if hot else 1.0) var mat := _markers[i].material_override as StandardMaterial3D if mat: mat.no_depth_test = hot # ── dragging the anchors ────────────────────────────────────────────────────── # # The markers are the anchor POINTS the hands are solved onto. Until now they # could only be moved through the sliders, and two of the three could only move # along the barrel — `gun_stock` and `gun_fore` are distances along the gun's own # axis, so the trigger and support hands slid up and down the weapon and nowhere # else. What the sliders DID move freely was the gun itself, under anchors that # stayed put. # # So: grab a marker and drag it. Each one writes to the knob that expresses that # motion, in the frame that knob is written in — which is why the pose layer now # publishes the gun's basis. A drag left has to mean "left across the weapon" # whether the muzzle is down at low ready or level at ADS. ## marker index -> [knob key, frame]. "gun" is the weapon's own across/up/along ## basis; "skeleton" is the model's, which is where the shoulder pocket lives. const MARKER_KNOB := [ ["grip_shift", "gun"], ["fore_shift", "gun"], ["pocket", "skeleton"], # resolved to pocket_hip / pocket_ads by pose ] ## How close, in pixels, the mouse has to be to a marker to take hold of it. const GRAB_RADIUS := 26.0 var _drag_marker := -1 var _hover_marker := -1 ## The plane a drag is measured on: fixed at grab time so it does not drift ## toward the camera as the marker follows the mouse. var _drag_plane_at := Vector3.ZERO var _drag_last := Vector3.ZERO ## The stock anchor is a blend of two knobs; a drag edits whichever one this ## pose is actually showing. Dragging at low ready must not silently rewrite the ## aiming pocket. func _stock_knob() -> String: return "pocket_%s" % _hold_pose() func _knob_for(marker: int) -> String: var key: String = MARKER_KNOB[marker][0] return _stock_knob() if key == "pocket" else key func _marker_under(mouse: Vector2) -> int: var best := -1 var best_d := GRAB_RADIUS for i in _markers.size(): if not _markers[i].visible: continue var p: Vector3 = _markers[i].global_position # Behind the camera projects to a nonsense point that can still land # within the grab radius. if _cam.is_position_behind(p): continue var d := _cam.unproject_position(p).distance_to(mouse) if d < best_d: best_d = d best = i return best ## Where the mouse ray meets the drag plane — through the grab point, facing the ## camera. func _plane_hit(mouse: Vector2) -> Vector3: var origin := _cam.project_ray_origin(mouse) var dir := _cam.project_ray_normal(mouse) var n := -_cam.global_transform.basis.z var denom := dir.dot(n) if absf(denom) < 1e-5: return _drag_last return origin + dir * (((_drag_plane_at - origin).dot(n)) / denom) func _begin_drag(marker: int, mouse: Vector2) -> void: _drag_marker = marker _drag_plane_at = _markers[marker].global_position _drag_last = _plane_hit(mouse) _status.text = "Dragging the %s anchor (%s)" % [ ["trigger hand", "support hand", "buttstock"][marker], _knob_for(marker)] func _drag_to(mouse: Vector2) -> void: if _model == null or _model._pose_mod == null or _model.skeleton == null: return var now := _plane_hit(mouse) var world_delta := now - _drag_last _drag_last = now # World -> skeleton, because that is the space the pose layer works in. var delta: Vector3 = _model.skeleton.global_transform.basis.inverse() * world_delta if MARKER_KNOB[_drag_marker][1] == "gun": # ...and on into the gun's frame for the two hand anchors. Orthonormal, # so the inverse is the transpose, but say what is meant. delta = _model._pose_mod.dbg_gun_basis.inverse() * delta var key := _knob_for(_drag_marker) var spec := _spec_for("hold", key) var cur: Vector3 = _knobs["hold"].get(key, WeaponHoldTuning.default_for(key)) if not (cur is Vector3): cur = Vector3.ZERO var lo: float = spec[2] var hi: float = spec[3] _set_knob("hold", key, Vector3( clampf(cur.x + delta.x, lo, hi), clampf(cur.y + delta.y, lo, hi), clampf(cur.z + delta.z, lo, hi))) ## The spec for a key, searched across every pose rather than the screenful on ## show. A drag writes the selected pose's key, which is on screen; a reset ## touches both. Looking only at `_specs` would silently hand back the ## catch-all below for anything belonging to the other pose. func _spec_for(group: String, key: String) -> Array: for spec in _all_specs(group): if spec[0] == key: return spec return ["", "", -1.0, 1.0, true, Vector3.ZERO] ## Write a knob from somewhere other than its own slider, and keep the slider in ## step. Without the write-back the sliders would silently disagree with the ## model the moment anything was dragged, and Save would store the sliders. func _set_knob(group: String, key: String, value) -> void: _knobs[group][key] = value var entry = _sliders[group].get(key) if entry != null: if entry.spec[4]: entry.x.set_value_no_signal(value.x) entry.y.set_value_no_signal(value.y) entry.z.set_value_no_signal(value.z) else: entry.x.set_value_no_signal(float(value)) _refresh_label(group, key) _push(group) # ── knobs ───────────────────────────────────────────────────────────────────── # # The two groups differ only in which spec table describes them, which file they # live in and what they are keyed on. Everything below is written once against # those three facts rather than twice against the two groups, so the anchors got # a full editor — sliders, live preview, reset, save, clipboard — for the cost of # a spec table. ## The spec table for a group, for the pose CURRENTLY being adjusted. ## ## The hold's table is a function of the pose: half its knobs mean something ## different at low ready than they do down the sights, and one of them — ## muzzle pitch — does not exist down the sights at all, because there the ## muzzle follows the camera. Showing all of them at once meant every slider on ## screen was for one of two poses and nothing said which. func _specs(group: String) -> Array: if group == "anchors": return RigAnchors.KNOBS return WeaponHoldTuning.knobs_for(_hold_pose()) ## Which hold the selected pose is tuning. ## ## The runtime blends between exactly TWO holds, on `ads`. "Running" and ## "Crouched" are locomotion states that still use the low-ready hold, so they ## edit the same numbers as "Low ready" — and the heading says so, rather than ## letting someone tune "Running" and wonder why standing still changed. func _hold_pose() -> String: return WeaponHoldTuning.pose_for_ads(POSES[_pose][3]) func _default_for(group: String, key: String): return RigAnchors.default_for(key) if group == "anchors" \ else WeaponHoldTuning.default_for(key) ## Every knob of a group across BOTH poses. Reset works on the whole table, not ## just the screenful on show — resetting while low ready is selected must not ## leave the aiming pocket half-tuned and invisible. func _all_specs(group: String) -> Array: return RigAnchors.KNOBS if group == "anchors" else WeaponHoldTuning.all_knobs() func _load_knobs() -> void: for group in GROUPS: if group == "anchors": _knobs[group] = RigAnchors.resolve(_all[group], _skins[_skin].id) else: _knobs[group] = WeaponHoldTuning.resolve(_all[group], _skins[_skin].id, _weapons[_weapon].id) _sync_sliders() ## Build the slider rows for each group, for the pose being adjusted. ## ## Called on startup and whenever the pose changes. Nothing about the underlying ## VALUES changes here — `_knobs` holds every pose's numbers at once, and Save ## writes all of them. This only decides which are on screen, so that every ## slider you can see belongs to the pose in the picker above it. func _rebuild_knobs() -> void: for group in GROUPS: var rows: VBoxContainer = _knob_boxes.get(group) if rows == null: continue for c in rows.get_children(): rows.remove_child(c) c.queue_free() _sliders[group].clear() for spec in _specs(group): rows.add_child(_knob_row(group, spec)) var head: Label = _headings.get(group) if head: head.text = _heading_for(group) _sync_sliders() func _heading_for(group: String) -> String: if group != "hold": return GROUPS[group]["title"] var pose := _hold_pose() var shown: String = WeaponHoldTuning.POSE_NAMES[pose] # Say outright when the selected pose is not its own hold. Running and # Crouched use the low-ready numbers, and someone tuning "Running" and # finding standing still had changed too would rightly call that a bug. var via := "" if POSES[_pose][0].to_lower() != shown: via = " (%s uses the %s hold)" % [POSES[_pose][0], shown] return "HOLD · %s · this character, this weapon%s" % [shown.to_upper(), via] ## Push the stored values into whatever sliders are currently on screen. func _sync_sliders() -> void: for group in GROUPS: for k in _sliders[group]: var entry = _sliders[group][k] var v = _knobs[group].get(k, _default_for(group, k)) if entry.spec[4]: if not (v is Vector3): v = Vector3.ZERO entry.x.set_value_no_signal(v.x) entry.y.set_value_no_signal(v.y) entry.z.set_value_no_signal(v.z) else: entry.x.set_value_no_signal(float(v)) _refresh_label(group, k) func _knob_changed(group: String, key: String) -> void: var entry = _sliders[group][key] var spec: Array = entry.spec if spec[4]: _knobs[group][key] = Vector3(entry.x.value, entry.y.value, entry.z.value) else: _knobs[group][key] = entry.x.value _refresh_label(group, key) _push(group) func _push(group: String) -> void: if _model == null: return if group == "anchors": _model.set_anchors(_knobs[group]) else: _model.set_hold_tuning(_knobs[group]) func _refresh_label(group: String, key: String) -> void: var entry = _sliders[group][key] var spec: Array = entry.spec if spec[4]: entry.label.text = "%s %.3f, %.3f, %.3f" % [spec[1], entry.x.value, entry.y.value, entry.z.value] else: var v: float = entry.x.value entry.label.text = "%s %s" % [spec[1], "auto" if (key == "weapon_scale" and v < 0.01) else "%.3f" % v] # ── ui ──────────────────────────────────────────────────────────────────────── func _build_ui() -> void: var layer := CanvasLayer.new() add_child(layer) # The lab is a dev tool, but it is still a screen in this game, and the # theme is meant to be the ONE theme. It also has to be applied to this # Control rather than only to the Window: a CanvasLayer is not a Control, so # theme inheritance stops at it. UITheme.apply_global(get_tree()) var panel := PanelContainer.new() panel.theme = UITheme.build() panel.set_anchors_preset(Control.PRESET_LEFT_WIDE) panel.custom_minimum_size = Vector2(440, 0) layer.add_child(panel) var scroll := ScrollContainer.new() panel.add_child(scroll) var box := VBoxContainer.new() box.custom_minimum_size = Vector2(410, 0) box.add_theme_constant_override("separation", 2) scroll.add_child(box) box.add_child(UITheme.title("RIG LAB", 34, 0.0)) box.add_child(UITheme.divider(0.3)) box.add_child(_picker("Character", _skins, func(i): _skin = i _reload_model())) box.add_child(_picker("Weapon", _weapons, func(i): _weapon = i _load_knobs() if _model: _model.set_weapon(_weapons[_weapon].script) _push("hold") _push("anchors"))) var poses: Array = [] for p in POSES: poses.append({"name": p[0]}) box.add_child(_picker("Pose", poses, func(i): _pose = i # The hold's knobs ARE the pose's knobs, so changing pose rebuilds them. _rebuild_knobs())) # Clip scrubber. The four poses above are the states the game drives; this # plays one clip on its own, which is the only way to watch a whole # animation end to end and see where a retarget went wrong. var clip_row := _picker("Clip", [{"name": "(pose driven)"}], func(i): _select_clip(i)) _clip_picker = clip_row.get_child(1) box.add_child(clip_row) for group in GROUPS: box.add_child(UITheme.divider(0.5)) var head := UITheme.heading("", 18) _headings[group] = head box.add_child(head) # The hold's rows are rebuilt whenever the pose changes, so they live in # their own container rather than loose in the panel. var rows := VBoxContainer.new() rows.add_theme_constant_override("separation", 2) _knob_boxes[group] = rows box.add_child(rows) box.add_child(_group_buttons(group)) _rebuild_knobs() box.add_child(UITheme.divider(0.7)) box.add_child(UITheme.heading("SURFACES · click to isolate", 18)) _surface_box = VBoxContainer.new() _surface_box.add_theme_constant_override("separation", 1) box.add_child(_surface_box) _status = Label.new() _status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _status.custom_minimum_size = Vector2(400, 40) _status.text = "DRAG A MARKER to move that anchor · left drag orbit · wheel zoom · middle drag pan · F frame hands" box.add_child(_status) # Reflect whatever the shot arguments or the defaults selected, or the # dropdown says one thing while the scene shows another. _pickers["Character"].select(_skin) _pickers["Weapon"].select(_weapon) _pickers["Pose"].select(_pose) func _picker(label: String, items: Array, on_pick: Callable) -> Control: var row := HBoxContainer.new() var l := Label.new() l.text = label l.custom_minimum_size = Vector2(90, 0) row.add_child(l) var opt := OptionButton.new() opt.size_flags_horizontal = Control.SIZE_EXPAND_FILL for it in items: opt.add_item(String(it.name)) opt.item_selected.connect(on_pick) row.add_child(opt) _pickers[label] = opt return row func _knob_row(group: String, spec: Array) -> Control: var key: String = spec[0] var box := VBoxContainer.new() box.add_theme_constant_override("separation", 0) var label := Label.new() label.add_theme_font_size_override("font_size", 16) box.add_child(label) var entry := {"spec": spec, "label": label} var axes := ["x", "y", "z"] if spec[4] else ["x"] for a in axes: var sl := HSlider.new() sl.min_value = spec[2] sl.max_value = spec[3] sl.step = 0.001 sl.custom_minimum_size = Vector2(400, 14) sl.value_changed.connect(func(_v): _knob_changed(group, key)) box.add_child(sl) entry[a] = sl _sliders[group][key] = entry return box func _group_buttons(group: String) -> Control: var row := HBoxContainer.new() for b in [["Save", func(): _save(group)], ["Reset", func(): _reset(group)], ["Copy", func(): _copy(group)]]: var btn := Button.new() btn.text = b[0] btn.pressed.connect(b[1]) row.add_child(btn) return row func _save(group: String) -> void: var where := "" if group == "anchors": where = RigAnchors.save(_all[group], _skins[_skin].id, _knobs[group]) _status.text = "Saved %s anchors to %s" % [_skins[_skin].id, where] else: where = WeaponHoldTuning.save(_all[group], _skins[_skin].id, _weapons[_weapon].id, _knobs[group]) _status.text = "Saved %s + %s hold to %s" % [_skins[_skin].id, _weapons[_weapon].id, where] ## Clear a whole group — every pose, not just the one on screen. ## ## The sliders then take their DEFAULTS, not zero. Those are the same thing for ## every anchor and for most of the hold, but not for all of it: a slider parked ## at 0 next to a code default of 1.0 means reset quietly switched that behaviour ## off rather than restoring it. func _reset(group: String) -> void: _knobs[group] = {} _sync_sliders() _push(group) _status.text = "Reset every %s knob, both poses, to what the code derives" % group func _copy(group: String) -> void: DisplayServer.clipboard_set(JSON.stringify( TuningStore.flatten(_knobs[group]), " ")) _status.text = "Copied %s to the clipboard" % group # ── surfaces ────────────────────────────────────────────────────────────────── # # What the importer decided each surface of this model IS. Read-only, because # the decision belongs in the sidecar where the whole game reads it — but # ISOLATING a class is how the decision gets checked. Click `hair` and only the # hair should be left standing. Anything else still visible was misclassified. func _rebuild_surface_list() -> void: for c in _surface_box.get_children(): c.queue_free() _isolated = "" _hidden_surfaces.clear() if _model == null: return var counts := {} for cls in [SkinSurfaces.BODY, SkinSurfaces.CLOTH, SkinSurfaces.HAIR, SkinSurfaces.ACCESSORY, SkinSurfaces.LINEWORK]: var n: int = _model.surfaces_of(cls).size() if n > 0: counts[cls] = n for cls in counts: var btn := Button.new() btn.text = "%s ×%d" % [cls, counts[cls]] btn.toggle_mode = true btn.pressed.connect(func(): _isolate(cls if _isolated != cls else "")) _surface_box.add_child(btn) if counts.is_empty(): _surface_box.add_child(UITheme.heading("no surface table", 16)) ## Show only one surface class, or "" for all of them. ## ## Hidden by swapping in a fully transparent material rather than by hiding the ## MeshInstance, because a mesh is not one class: Miku's body, face and hair are ## three surfaces of a single mesh, and hiding the node would take all three. ## The displaced materials are kept so this is reversible without rebuilding the ## character — which would also throw away whatever is being tuned. func _isolate(cls: String) -> void: for mi in _hidden_surfaces: if is_instance_valid(mi): for s in _hidden_surfaces[mi]: mi.set_surface_override_material(s, _hidden_surfaces[mi][s]) _hidden_surfaces.clear() _isolated = cls for b in _surface_box.get_children(): if b is Button: b.set_pressed_no_signal(b.text.begins_with(cls) and cls != "") if cls == "" or _model == null: _status.text = "Showing every surface" return var keep := {} for pair in _model.surfaces_of(cls): keep["%s|%d" % [pair[0].get_instance_id(), pair[1]]] = true var blank := StandardMaterial3D.new() blank.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED blank.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA blank.albedo_color = Color(0, 0, 0, 0) for mi in _model.find_children("*", "MeshInstance3D", true, false): if mi.mesh == null: continue for s in mi.mesh.get_surface_count(): if keep.has("%s|%d" % [mi.get_instance_id(), s]): continue if not _hidden_surfaces.has(mi): _hidden_surfaces[mi] = {} _hidden_surfaces[mi][s] = mi.get_surface_override_material(s) mi.set_surface_override_material(s, blank) _status.text = "Isolated '%s' — anything else still visible is misclassified" % cls # ── camera ──────────────────────────────────────────────────────────────────── func _update_camera() -> void: var b := Basis.from_euler(Vector3(_pitch, _yaw, 0)) _cam.global_transform = Transform3D(b, _pivot + b * Vector3(0, 0, _dist)) func _unhandled_input(e: InputEvent) -> void: if e is InputEventMouseMotion: # An anchor drag OWNS the left button — otherwise grabbing a marker # would also orbit the camera, and the anchor would appear to move # because the view did. if _drag_marker >= 0: _drag_to(e.position) return _hover_marker = _marker_under(e.position) \ if e.button_mask == 0 else _hover_marker if e.button_mask & MOUSE_BUTTON_MASK_LEFT: _yaw -= e.relative.x * 0.006 _pitch = clampf(_pitch - e.relative.y * 0.006, -1.4, 1.4) _update_camera() elif e.button_mask & MOUSE_BUTTON_MASK_MIDDLE: var b := _cam.global_transform.basis _pivot += (b.x * -e.relative.x + b.y * e.relative.y) * _dist * 0.0015 _update_camera() elif e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT: if e.pressed: var hit := _marker_under(e.position) if hit >= 0: _begin_drag(hit, e.position) elif _drag_marker >= 0: _status.text = "%s is now %s — Save to keep it" % [ _knob_for(_drag_marker), _knobs["hold"].get(_knob_for(_drag_marker), Vector3.ZERO)] _drag_marker = -1 elif e is InputEventMouseButton and e.pressed: if e.button_index == MOUSE_BUTTON_WHEEL_UP: _dist = maxf(_dist * 0.9, 0.15) _update_camera() elif e.button_index == MOUSE_BUTTON_WHEEL_DOWN: _dist = minf(_dist * 1.1, 8.0) _update_camera() elif e is InputEventKey and e.pressed and not e.echo: match e.keycode: KEY_F: # Frame the hands, which is what is actually being judged. if _model and _model._pose_mod: _pivot = _model.skeleton.global_transform \ * _model._pose_mod.dbg_grip _dist = 0.55 _update_camera() # The shortcuts act on the HOLD group, which is what they meant # when it was the only group. Anchors have their own buttons. KEY_S: _save("hold") KEY_R: _reset("hold") KEY_C: _copy("hold") KEY_ESCAPE: get_tree().quit()