extends Node3D ## Editor scale lab: tune ModelProp fit_* vs measuring wall + 1.74 m player. ## Open scenes/dev/scale_lab.tscn — Save writes data/model_scales.json (runtime SSOT). const MODELS_ROOT := "res://assets/3DModelle" const MODEL_PROP_SCENE := "res://scenes/prefabs/model_prop.tscn" const WALL_MAX_M := 4.0 const PREVIEW_POS := Vector3(0.0, 0.0, 0.0) const PLAYER_REF_POS := Vector3(-1.6, 0.0, 0.6) var _models: Array[Dictionary] = [] ## {key, path, label} var _selected: int = -1 var _preview: ModelProp var _working: Dictionary = {} ## key -> {fit_height, fit_max_width, notes?} var _status_label: Label var _height_spin: SpinBox var _width_spin: SpinBox var _list: ItemList var _updating_ui: bool = false func _ready() -> void: Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _build_environment() _build_measuring_wall() _build_player_reference() _build_ui() _load_working_from_catalog() _scan_models() _populate_list() if not _models.is_empty(): _list.select(0) _select_index(0) _update_camera() func _build_environment() -> void: var env := Environment.new() env.background_mode = Environment.BG_COLOR env.background_color = Color(0.55, 0.72, 0.88) env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR env.ambient_light_color = Color(0.85, 0.88, 0.92) env.ambient_light_energy = 0.65 $WorldEnvironment.environment = env func _build_measuring_wall() -> void: var wall_root := Node3D.new() wall_root.name = "MeasuringWall" wall_root.position = Vector3(0.0, 0.0, -2.2) add_child(wall_root) ## Back panel var panel := MeshInstance3D.new() var box := BoxMesh.new() box.size = Vector3(6.0, WALL_MAX_M + 0.4, 0.08) panel.mesh = box panel.position = Vector3(0.0, (WALL_MAX_M + 0.4) * 0.5, 0.0) var panel_mat := StandardMaterial3D.new() panel_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED panel_mat.albedo_color = Color(0.92, 0.92, 0.88) panel.material_override = panel_mat wall_root.add_child(panel) ## Tick marks every 0.1 m; stronger at 0.5 / 1.0 for i in range(int(WALL_MAX_M * 10.0) + 1): var y := float(i) * 0.1 var major := (i % 10) == 0 var mid := (i % 5) == 0 var tick := MeshInstance3D.new() var tbox := BoxMesh.new() var len := 0.35 if major else (0.22 if mid else 0.12) var thick := 0.025 if major else (0.018 if mid else 0.01) tbox.size = Vector3(len, thick, 0.04) tick.mesh = tbox tick.position = Vector3(-2.6 + len * 0.5, y, 0.06) var tmat := StandardMaterial3D.new() tmat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED if absf(y - SpriteFit.WORLD_PLAYER_HEIGHT) < 0.001: tmat.albedo_color = Color(0.85, 0.15, 0.1) tbox.size = Vector3(0.55, 0.035, 0.05) tick.position.x = -2.6 + 0.275 elif major: tmat.albedo_color = Color(0.12, 0.12, 0.14) else: tmat.albedo_color = Color(0.35, 0.35, 0.38) tick.material_override = tmat wall_root.add_child(tick) if major or absf(y - SpriteFit.WORLD_PLAYER_HEIGHT) < 0.001: var label := Label3D.new() if absf(y - SpriteFit.WORLD_PLAYER_HEIGHT) < 0.001: label.text = "1.74 m player" label.modulate = Color(0.9, 0.2, 0.15) else: label.text = "%.0f m" % y label.modulate = Color(0.1, 0.1, 0.12) label.font_size = 48 label.pixel_size = 0.0045 label.position = Vector3(-2.95, y, 0.08) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT label.billboard = BaseMaterial3D.BILLBOARD_DISABLED wall_root.add_child(label) ## Floor grid strip in front of wall var floor_grid := MeshInstance3D.new() var fbox := BoxMesh.new() fbox.size = Vector3(6.0, 0.02, 4.0) floor_grid.mesh = fbox floor_grid.position = Vector3(0.0, 0.01, 0.0) var fmat := StandardMaterial3D.new() fmat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED fmat.albedo_color = Color(0.45, 0.5, 0.48) floor_grid.material_override = fmat add_child(floor_grid) func _build_player_reference() -> void: var root := Node3D.new() root.name = "PlayerReference" root.position = PLAYER_REF_POS add_child(root) var capsule := MeshInstance3D.new() var mesh := CapsuleMesh.new() mesh.radius = 0.22 mesh.height = SpriteFit.WORLD_PLAYER_HEIGHT capsule.mesh = mesh capsule.position = Vector3(0.0, SpriteFit.WORLD_PLAYER_HEIGHT * 0.5, 0.0) var mat := StandardMaterial3D.new() mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.albedo_color = Color(0.2, 0.45, 0.95, 0.55) mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA capsule.material_override = mat root.add_child(capsule) var tag := Label3D.new() tag.text = "Player 1.74 m" tag.font_size = 42 tag.pixel_size = 0.004 tag.position = Vector3(0.0, SpriteFit.WORLD_PLAYER_HEIGHT + 0.15, 0.0) tag.modulate = Color(0.15, 0.35, 0.9) root.add_child(tag) func _build_ui() -> void: var layer := CanvasLayer.new() layer.name = "LabUI" layer.layer = 80 add_child(layer) var panel := PanelContainer.new() panel.set_anchors_preset(Control.PRESET_TOP_LEFT) panel.offset_left = 12 panel.offset_top = 12 panel.offset_right = 360 panel.offset_bottom = 520 layer.add_child(panel) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 10) margin.add_theme_constant_override("margin_right", 10) margin.add_theme_constant_override("margin_top", 8) margin.add_theme_constant_override("margin_bottom", 8) panel.add_child(margin) var vbox := VBoxContainer.new() vbox.add_theme_constant_override("separation", 6) margin.add_child(vbox) var title := Label.new() title.text = "Scale Lab" title.add_theme_font_size_override("font_size", 20) vbox.add_child(title) var hint := Label.new() hint.text = "Wall ticks = meters. Red = player 1.74 m.\nSave → data/model_scales.json" hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART vbox.add_child(hint) _list = ItemList.new() _list.custom_minimum_size = Vector2(0, 220) _list.size_flags_vertical = Control.SIZE_EXPAND_FILL _list.item_selected.connect(_on_list_selected) vbox.add_child(_list) vbox.add_child(_make_spin_row("fit_height (m)", 0.01, 20.0, 0.01, true)) vbox.add_child(_make_spin_row("fit_max_width (m)", 0.0, 30.0, 0.05, false)) var buttons := HBoxContainer.new() buttons.add_theme_constant_override("separation", 6) vbox.add_child(buttons) var save_btn := Button.new() save_btn.text = "Save" save_btn.pressed.connect(_on_save) buttons.add_child(save_btn) var reset_btn := Button.new() reset_btn.text = "Reset defaults" reset_btn.pressed.connect(_on_reset) buttons.add_child(reset_btn) var reload_btn := Button.new() reload_btn.text = "Reload JSON" reload_btn.pressed.connect(_on_reload) buttons.add_child(reload_btn) _status_label = Label.new() _status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _status_label.text = "Ready." vbox.add_child(_status_label) var cam_hint := Label.new() cam_hint.text = "Orbit: RMB drag · Zoom: wheel · WASD move cam" cam_hint.modulate = Color(0.7, 0.7, 0.75) vbox.add_child(cam_hint) func _make_spin_row(label_text: String, min_v: float, max_v: float, step: float, is_height: bool) -> Control: var row := VBoxContainer.new() var lab := Label.new() lab.text = label_text row.add_child(lab) var spin := SpinBox.new() spin.min_value = min_v spin.max_value = max_v spin.step = step spin.allow_greater = true spin.allow_lesser = false if is_height: spin.value_changed.connect(_on_height_changed) _height_spin = spin else: spin.value_changed.connect(_on_width_changed) _width_spin = spin row.add_child(spin) return row func _load_working_from_catalog() -> void: SpriteFit.reload_scale_catalog() _working.clear() ## Seed from defaults, then overlay file. var defaults: Dictionary = SpriteFit.default_scale_catalog().get("scales", {}) for key in defaults.keys(): _working[str(key)] = (defaults[key] as Dictionary).duplicate(true) if FileAccess.file_exists(SpriteFit.SCALE_CATALOG_PATH): var file := FileAccess.open(SpriteFit.SCALE_CATALOG_PATH, FileAccess.READ) if file: var parsed: Variant = JSON.parse_string(file.get_as_text()) if typeof(parsed) == TYPE_DICTIONARY: var scales: Variant = (parsed as Dictionary).get("scales", {}) if typeof(scales) == TYPE_DICTIONARY: for key in (scales as Dictionary).keys(): var entry: Variant = scales[key] if typeof(entry) == TYPE_DICTIONARY: _working[str(key)] = (entry as Dictionary).duplicate(true) func _scan_models() -> void: _models.clear() for category in ["animals", "houses", "props"]: var cat_dir := "%s/%s" % [MODELS_ROOT, category] var dir := DirAccess.open(cat_dir) if dir == null: continue dir.list_dir_begin() var folder := dir.get_next() while folder != "": if dir.current_is_dir() and not folder.begins_with("."): var sub := "%s/%s" % [cat_dir, folder] var subdir := DirAccess.open(sub) if subdir: subdir.list_dir_begin() var fname := subdir.get_next() while fname != "": if not subdir.current_is_dir() and fname.to_lower().ends_with(".glb"): var path := "%s/%s" % [sub, fname] var key := "%s/%s" % [category, folder] _models.append({ "key": key, "path": path, "label": "%s (%s)" % [key, fname], }) ## Ensure working entry exists. if not _working.has(key): _working[key] = { "fit_height": SpriteFit.fit_height_for(path), "fit_max_width": SpriteFit.fit_max_width_for(path), } break fname = subdir.get_next() folder = dir.get_next() _models.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return str(a["key"]) < str(b["key"])) func _populate_list() -> void: _list.clear() for m in _models: _list.add_item(str(m["label"])) func _on_list_selected(index: int) -> void: _select_index(index) func _select_index(index: int) -> void: if index < 0 or index >= _models.size(): return _selected = index var m: Dictionary = _models[index] var key: String = m["key"] var path: String = m["path"] var entry: Dictionary = _working.get(key, { "fit_height": SpriteFit.fit_height_for(path), "fit_max_width": SpriteFit.fit_max_width_for(path), }) _updating_ui = true _height_spin.value = float(entry.get("fit_height", 1.0)) _width_spin.value = float(entry.get("fit_max_width", 1.0)) _updating_ui = false _spawn_preview(path, float(_height_spin.value), float(_width_spin.value)) _status_label.text = "Selected %s" % key func _spawn_preview(path: String, fit_h: float, fit_w: float) -> void: if is_instance_valid(_preview): _preview.queue_free() _preview = null var packed: PackedScene = load(MODEL_PROP_SCENE) if packed == null: return _preview = packed.instantiate() as ModelProp if _preview == null: return _preview.name = "PreviewProp" _preview.use_scale_catalog = false _preview.add_collision = false _preview.model_path = path _preview.fit_height = fit_h _preview.fit_max_width = fit_w _preview.ground_align = true _preview.can_interact = false add_child(_preview) _preview.global_position = PREVIEW_POS func _on_height_changed(v: float) -> void: if _updating_ui or _selected < 0: return _commit_working(v, float(_width_spin.value)) func _on_width_changed(v: float) -> void: if _updating_ui or _selected < 0: return _commit_working(float(_height_spin.value), v) func _commit_working(fit_h: float, fit_w: float) -> void: var key: String = _models[_selected]["key"] var entry: Dictionary = _working.get(key, {}).duplicate(true) entry["fit_height"] = fit_h entry["fit_max_width"] = fit_w _working[key] = entry if is_instance_valid(_preview): _preview.set_fit_params(fit_h, fit_w, true) func _on_save() -> void: var doc := { "_meta": { "player_height_m": SpriteFit.WORLD_PLAYER_HEIGHT, "notes": "Edited by scenes/dev/scale_lab.tscn. Keys = category/folder under assets/3DModelle/.", }, "scales": _working.duplicate(true), } var err := SpriteFit.save_scale_catalog(doc) if err == OK: _status_label.text = "Saved %s (%d entries)." % [SpriteFit.SCALE_CATALOG_PATH, _working.size()] else: _status_label.text = "Save FAILED (err %s). Run from editor so res:// is writable." % err func _on_reset() -> void: var defaults: Dictionary = SpriteFit.default_scale_catalog().get("scales", {}) _working.clear() for key in defaults.keys(): _working[str(key)] = (defaults[key] as Dictionary).duplicate(true) ## Keep any extra scanned models with current SpriteFit fallbacks. for m in _models: var key: String = m["key"] if not _working.has(key): _working[key] = { "fit_height": SpriteFit.fit_height_for(str(m["path"])), "fit_max_width": SpriteFit.fit_max_width_for(str(m["path"])), } if _selected >= 0: _select_index(_selected) _status_label.text = "Reset to built-in defaults (not saved yet)." func _on_reload() -> void: _load_working_from_catalog() if _selected >= 0: _select_index(_selected) _status_label.text = "Reloaded from JSON." ## Simple free camera (WASD + RMB look + wheel). var _yaw: float = 0.35 var _pitch: float = -0.25 var _cam_dist: float = 5.5 var _cam_focus := Vector3(0.0, 1.0, 0.0) var _rmb_down: bool = false func _unhandled_input(event: InputEvent) -> void: if event is InputEventMouseButton: var mb := event as InputEventMouseButton if mb.button_index == MOUSE_BUTTON_RIGHT: _rmb_down = mb.pressed elif mb.pressed and mb.button_index == MOUSE_BUTTON_WHEEL_UP: _cam_dist = maxf(1.5, _cam_dist * 0.9) _update_camera() elif mb.pressed and mb.button_index == MOUSE_BUTTON_WHEEL_DOWN: _cam_dist = minf(18.0, _cam_dist * 1.1) _update_camera() elif event is InputEventMouseMotion and _rmb_down: var mm := event as InputEventMouseMotion _yaw -= mm.relative.x * 0.005 _pitch = clampf(_pitch - mm.relative.y * 0.005, -1.2, 0.6) _update_camera() func _process(delta: float) -> void: var move := Vector3.ZERO if Input.is_key_pressed(KEY_W): move.z -= 1.0 if Input.is_key_pressed(KEY_S): move.z += 1.0 if Input.is_key_pressed(KEY_A): move.x -= 1.0 if Input.is_key_pressed(KEY_D): move.x += 1.0 if Input.is_key_pressed(KEY_Q): move.y -= 1.0 if Input.is_key_pressed(KEY_E): move.y += 1.0 if move != Vector3.ZERO: var basis := Basis.from_euler(Vector3(_pitch, _yaw, 0.0)) _cam_focus += basis * move.normalized() * 3.0 * delta _update_camera() func _update_camera() -> void: var cam: Camera3D = $Camera3D if cam == null: return var offset := Vector3( sin(_yaw) * cos(_pitch), sin(_pitch), cos(_yaw) * cos(_pitch) ) * _cam_dist ## Orbit behind focus (wall at -Z). cam.global_position = _cam_focus + Vector3(offset.x, -offset.y + 1.2, offset.z) cam.look_at(_cam_focus, Vector3.UP) func _enter_tree() -> void: call_deferred("_update_camera")