Houses ~8.5 m; chimps capped ~0.85 m with safer axis fitting. Large GLB/PBR packs stay local until Git LFS (Forgejo HTTP 413). Co-authored-by: Cursor <cursoragent@cursor.com>
472 lines
14 KiB
GDScript
472 lines
14 KiB
GDScript
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")
|