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]>
795 lines
29 KiB
GDScript
795 lines
29 KiB
GDScript
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()
|