Files
Papay-Shooter/debug/rig_lab.gd
T
Nicholas ButzkeandClaude Opus 5 b1c8bab714 feat(rig lab): drag the anchors themselves, not just the gun under them
The lab could move the GUN and not the anchor points the hands are solved onto.
`grip_offset` slides the weapon around inside the fist; `gun_fore` and
`gun_stock` are distances ALONG the barrel, so the trigger and support hands
could travel up and down the weapon's own axis and nowhere else. Nothing could
take a hand off that axis, which is what a handguard below the bore, an angled
foregrip, or a pistol whose grip is nowhere near its barrel line all need.

Two things fix that.

`grip_shift` and `fore_shift` give the two hand anchors real three-dimensional
freedom, expressed in the GUN's own across/up/along frame so a sideways nudge
stays sideways as the weapon pitches between low ready and ADS. Zero is exactly
the old behaviour. Their z overlaps the along-axis distances, which is redundant
and deliberate: keeping those separate is what lets the reach solver slide the
support hand back down the handguard without undoing a considered sideways
offset.

And the markers are now draggable. They already showed the anchors; now they
are handles. The one under the mouse swells and draws through the body — depth
testing is right for judging whether a hand reached its target and wrong for a
handle, because at any useful framing the hands occlude all three.

Verified three ways, and each one had to be rebuilt once:

  anchor_shift_check first compared absolute positions and reported a 3.5 mm
  error that was the character BREATHING — there is a sin() on the muzzle pitch,
  so no anchor is ever in the same place twice. Measuring each anchor relative
  to the one it hangs off, rotated into the current gun basis, cancels the
  breathing, the ADS blend and the recoil exactly. 48 checks, six characters,
  both poses.

  anchor_drag_check asserts the drag writes the knob the MOUSE asked for,
  derived independently from the camera: 0.00-0.01 mm on all three. It does not
  assert the marker lands under the cursor, because it does not — the anchors
  hang off the shoulder and the arm chasing them moves the shoulder, so a drag
  settles at 0.77x-1.13x. Small enough to ignore interactively.

  That feedback first read as 1.5x-1.8x, because the cases were compounding on
  each other, and waiting LONGER for the pose to settle made it worse rather
  than better — which is the opposite of how a settling error behaves and is
  what gave it away.

The buttstock case also failed for a while on a bug entirely in the test: it
read an absent knob as zero when `pocket_hip` defaults to (30, -70, 60) mm. The
lab has a note about that trap in `_reset`. It is just as easy to walk into from
a test, and now has one there too.

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

795 lines
29 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}
var _sliders: Dictionary = {"hold": {}, "anchors": {}}
## "" 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])))
_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_ads" if POSES[_pose][3] > 0.5 else "pocket_hip"
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)))
func _spec_for(group: String, key: String) -> Array:
for spec in _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.
func _specs(group: String) -> Array:
return RigAnchors.KNOBS if group == "anchors" else WeaponHoldTuning.KNOBS
func _default_for(group: String, key: String):
return RigAnchors.default_for(key) if group == "anchors" \
else WeaponHoldTuning.default_for(key)
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)
for k in _sliders[group]:
var entry = _sliders[group][k]
var spec: Array = entry.spec
if spec[4]:
var v: Vector3 = _knobs[group].get(k, _default_for(group, k))
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(_knobs[group].get(k,
_default_for(group, k))))
_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))
# 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))
box.add_child(UITheme.heading(GROUPS[group]["title"], 18))
for spec in _specs(group):
box.add_child(_knob_row(group, spec))
box.add_child(_group_buttons(group))
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]
func _reset(group: String) -> void:
_knobs[group] = {}
for k in _sliders[group]:
var e = _sliders[group][k]
for a in ["x", "y", "z"]:
if e.has(a):
# Back to the DEFAULT, not to 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.
var d = _default_for(group, k)
var v: float = (d[a] if d is Vector3 else float(d)) \
if e.spec[4] else float(d)
e[a].set_value_no_signal(v)
_refresh_label(group, k)
_push(group)
_status.text = "Reset %s 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()