Files
Papay-Shooter/debug/rig_lab.gd
T
Nicholas ButzkeandClaude Opus 5 a97ccca13c feat(rig lab): wrists in three axes, and sliders that belong to the pose on screen
Three things the lab could not do.

WRISTS. Each hand had one scalar, a twist about the barrel. That is the only
axis a hand wrapping a cylinder is free in ONCE the arc onto the barrel is
solved — which is true of the support hand, was never true of the trigger hand,
and in neither case left a way to cock a wrist forward or break it inward. Both
now take pitch, yaw and roll, applied in the GUN's frame so the three sliders
mean the same thing whether the muzzle is down at low ready or level down the
sights. Zero is exactly the old behaviour, since the roll term defaulted to zero
too.

HAND POINTS. `gun_stock` and `gun_fore` are the distances along the weapon at
which each hand sits, and they were labelled by what they measure rather than by
whose hand it is. They now say TRIGGER and SUPPORT, next to the off-barrel
shifts for the same two hands, so the four controls that place a hand read as
four controls that place a hand.

POSES. The hold's knobs are now per pose, and the lab shows one pose's at a
time. Half of them mean something different at low ready than down the sights;
showing both sets at once meant every slider on screen was for one of two poses
with nothing saying which. Selecting a pose rebuilds the panel.

Two poses, not four, and deliberately: 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 — and the heading says so, rather
than letting someone tune "Running" and wonder why standing still changed.
Offering four independent tunings would be inventing a capability the code does
not have, and the fourth would silently do nothing.

`pitch` is the case that forced the design: down the sights the muzzle follows
the CAMERA, so there is nothing there to tune. It exists at low ready and
nowhere else, and a spec table where a knob names the poses it applies to is
what lets that be said instead of shipping a control that does nothing.

hold_pose_check asserts both halves — that no pose shows another's knobs, that
aiming offers no muzzle pitch, that the heading names the hold being edited, and
that all twelve wrist axes turn the hand they name.

Its first version reported every wrist axis as moving the hand by 0.0 degrees,
which is precisely the answer it would have given if the wrists had never been
implemented: it read `get_bone_pose_rotation` from a SceneTree script, and Godot
restores every bone's local pose after the modifier pass. The repo has a
reference section about exactly this and it still cost a cycle. Measured through
a PoseProbe, every axis turns its hand ~20 degrees.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 01:31:08 -04:00

881 lines
32 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <path> [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()