Files
Papay-Shooter/debug/rig_lab.gd
T
Nicholas ButzkeandClaude Opus 5 a13ae50f95 feat(rig): every weapon gets its own hold, so the silhouette names the gun
`_apply_rifle_hold` did exactly what its name said, to everything. A knife, an
AWP and a rocket launcher were all solved as a rifle — stock in the shoulder
pocket, support hand out along the barrel, muzzle on the aim line — so in third
person every character stood the same way whatever they carried, and the only
thing distinguishing a sniper from a shotgun was the ~30 cm of gun mesh in
their hands. At the distance an enemy is usually seen that is nothing.

It costs more than looking wrong. A character's pose is the fastest available
answer to "what is about to happen to me": a tube on a shoulder means take
cover, a blade held low means they have to close, a rifle at low ready means
they have not seen you. One hold throws all of that away. It is also the thing
HoYoverse's team say they chase in Zenless Zone Zero — characters read by
silhouette first, and they refuse to settle on one construction method because
one method limits how distinguishable the results can be.

weapons/weapon_hold_profiles.gd gives each of the twelve weapons a style, and a
style is not a bundle of slider values. Three of its differences cannot be
expressed on the rifle solve at all, and those are the ones that carry:

  support   WHERE the off hand goes and HOW IT IS TURNED there — wrapped round
            a handguard, cupped against the firing fist, hooked under a tube
            palm-up, or released entirely. Sending a hand somewhere new without
            re-orienting it gives a hand teleported to the new spot still shaped
            for the old one.
  mount     whether the weapon's rear sits IN the shoulder pocket, ON TOP of the
            shoulder, or nowhere near it.
  head      whether the head comes down to the stock, or leans away to clear a
            tube. The cheek weld is the sniper silhouette, and its inverse is
            what says a launcher is resting on that shoulder.

A blade RELEASES the off arm back to the animation, so it swings with the run
cycle instead of gripping a handguard that is not there — most of what makes a
one-handed weapon read as one-handed — and closes a full fist, because an index
left straight along a knife handle reads as a mistake, not as discipline.

Layered strictly UNDER the existing tuning, so aria's hand-tuned AK-47 hold is
byte-for-byte what it was. WeaponHoldTuning.default_for is weapon-aware now,
which it had to be: the rig lab SAVES every knob it shows, so without it,
opening the lab on the knife and pressing save would silently overwrite the
blade profile with the rifle spec and put the character back to holding a knife
like an AK with nothing to indicate it had happened.

debug/weapon_hold_check.gd asserts the consequence, not the plumbing — storing
an enum and reading it back proves nothing. It measures where the hands and head
ACTUALLY end up, from inside the modifier pass (outside it, Godot restores the
local poses and every weapon reports an identical rifle) and in the shoulder's
own frame, because the hold breathes and two samples of the SAME weapon
otherwise differ by more than two different weapons do. 21 weapon pairs, all
distinguishable; the knife's off-hand weight measured at 0.01.

Two findings only measuring produced. Pushing `gun_fore` further out for the
sniper does NOTHING — the reach solver slides the support hand back down the
handguard until the arm can get there, so it landed at 0.388 m against the
rifle's 0.387. Raising the whole weapon is what makes a scoped rifle read.

And one only LOOKING produced, via debug/hold_capture.gd: the shotgun's barrel
passed through the character's chest. Every assertion passed — the hands were
exactly where they had been asked to go — but +x is toward the centreline, so
dropping the pocket and pushing it across at once swings the muzzle into the
torso.

rig_anchor_check still reports aria: her hand-tuned wrist rotates the anchor
offset. Pre-existing, and this halves it — it was 2 failures on main, now 1.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 12:05:43 -04:00

900 lines
33 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.
##
## HAND versus GUN. These rotate different things, and the split is the point:
##
## TRIGGER/SUPPORT WRIST (hold) turns the HAND. The gun stays exactly where
## the solver put it, on the aim line.
## Grip roll/pitch/yaw (anchors) turns the GUN inside the hand.
##
## The weapon is a child of a BoneAttachment3D on the trigger hand, so the two
## used to be welded — every degree the wrist turned swung the barrel the same
## degree, and took with it every control that could have corrected for it. The
## pose layer now publishes the counter-rotation and the mount applies it, so
## the wrist sliders align the hand TO the gun rather than dragging it around.
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, _weapons[_weapon].id))
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])
## A knob's resting value when nothing has been saved for this character.
##
## The weapon is passed through, because the hold's defaults depend on what KIND
## of weapon it is — see WeaponHoldTuning.default_for. Without it the lab would
## show a knife the rifle spec's numbers and then save them over the blade
## profile the moment anyone pressed save.
func _default_for(group: String, key: String):
return RigAnchors.default_for(key) if group == "anchors" \
else WeaponHoldTuning.default_for(key, _weapons[_weapon].id)
## 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()