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 [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()