Add regression tests for prop attachment, centering, and editor UX
- Implemented test_phase9a_prop_attach.gd to validate anchor schema, alignment math, and preview isolation. - Implemented test_phase9b_prop_centering.gd to check canonical origin presentation, bounds, centering, and save threshold. - Implemented test_phase9c_prop_editor_ux.gd to assess the user experience in the prop editor, including anchor renaming and UI responsiveness. - Added corresponding .uid files for each test. - Created sync_sources.py script to manage markdown file synchronization with the notebook. - Added .gitignore for virtual environment files.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
class_name PropAnchorData
|
||||
extends RefCounted
|
||||
## Normalized, JSON-safe prop-anchor model shared by authoring tools.
|
||||
|
||||
const ORIGIN_ID: String = "origin"
|
||||
const ROLE_STRINGS: PackedStringArray = ["pivot", "grip", "emitter", "impact", "mount", "custom"]
|
||||
const ALLOWED_HAND_STRINGS: PackedStringArray = ["both", "left_only", "right_only"]
|
||||
enum Hand { LEFT, RIGHT }
|
||||
|
||||
static func default_anchors() -> Dictionary:
|
||||
return {ORIGIN_ID: _origin()}
|
||||
|
||||
static func normalize_anchors(raw: Variant, warn: bool = true) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
if raw is Dictionary:
|
||||
for raw_id: Variant in (raw as Dictionary):
|
||||
if not (raw as Dictionary)[raw_id] is Dictionary:
|
||||
if warn: push_warning("PropAnchorData: skipped malformed anchor.")
|
||||
continue
|
||||
var id: String = normalize_id(str(raw_id), result)
|
||||
if id == ORIGIN_ID:
|
||||
continue
|
||||
var source: Dictionary = (raw as Dictionary)[raw_id] as Dictionary
|
||||
var role: String = str(source.get("role", "custom")).to_lower()
|
||||
if not ROLE_STRINGS.has(role) or role == "pivot":
|
||||
if role == "pivot" and warn: push_warning("PropAnchorData: non-origin pivot changed to custom.")
|
||||
role = "custom"
|
||||
var hand: String = str(source.get("allowed_hands", "both")).to_lower()
|
||||
if not ALLOWED_HAND_STRINGS.has(hand): hand = "both"
|
||||
result[id] = {"role": role, "position": Vector2(_finite(source.get("x", source.get("position", Vector2.ZERO)), true), _finite(source.get("y", source.get("position", Vector2.ZERO)), false)), "rotation_deg": _wrap_deg(_finite(source.get("rotation_deg", 0.0))), "z_index_offset": clampi(int(source.get("z_index_offset", 0)), -5, 5), "allowed_hands": hand}
|
||||
result[ORIGIN_ID] = _origin()
|
||||
return result.duplicate(true)
|
||||
|
||||
static func serialize_anchors(anchors: Dictionary) -> Dictionary:
|
||||
var normalized: Dictionary = normalize_anchors(anchors, false)
|
||||
var result: Dictionary = {}
|
||||
for id: String in normalized:
|
||||
var anchor: Dictionary = normalized[id]
|
||||
var pos: Vector2 = anchor.get("position", Vector2.ZERO)
|
||||
result[id] = {"role": str(anchor.get("role", "custom")), "x": pos.x, "y": pos.y, "rotation_deg": float(anchor.get("rotation_deg", 0.0)), "z_index_offset": int(anchor.get("z_index_offset", 0)), "allowed_hands": str(anchor.get("allowed_hands", "both"))}
|
||||
return result
|
||||
|
||||
static func editable_ids(anchors: Dictionary) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for id: String in normalize_anchors(anchors, false):
|
||||
if id != ORIGIN_ID:
|
||||
result.append(id)
|
||||
return result
|
||||
|
||||
static func preferred_editable_id(anchors: Dictionary, preferred_id: String = "") -> String:
|
||||
var normalized: Dictionary = normalize_anchors(anchors, false)
|
||||
if preferred_id != ORIGIN_ID and normalized.has(preferred_id):
|
||||
return preferred_id
|
||||
if normalized.has("primary_grip") and str(normalized["primary_grip"].get("role", "")) == "grip":
|
||||
return "primary_grip"
|
||||
for id: String in editable_ids(normalized):
|
||||
if str(normalized[id].get("role", "")) == "grip":
|
||||
return id
|
||||
var ids: Array[String] = editable_ids(normalized)
|
||||
return ids[0] if not ids.is_empty() else ""
|
||||
|
||||
static func translated_non_origin(anchors: Dictionary, delta: Vector2) -> Dictionary:
|
||||
var normalized: Dictionary = normalize_anchors(anchors, false)
|
||||
if not is_finite(delta.x) or not is_finite(delta.y):
|
||||
push_warning("PropAnchorData: ignored non-finite anchor translation.")
|
||||
return normalized
|
||||
for id: String in editable_ids(normalized):
|
||||
var anchor: Dictionary = normalized[id].duplicate(true)
|
||||
anchor["position"] = anchor.get("position", Vector2.ZERO) + delta
|
||||
normalized[id] = anchor
|
||||
normalized[ORIGIN_ID] = _origin()
|
||||
return normalized
|
||||
|
||||
static func normalize_id(candidate: String, anchors: Dictionary) -> String:
|
||||
var id: String = candidate.strip_edges().to_lower()
|
||||
var regex := RegEx.new(); regex.compile("[^a-z0-9]+")
|
||||
id = regex.sub(id, "_", true).strip_edges().trim_prefix("_").trim_suffix("_")
|
||||
if id.is_empty() or not id[0].is_valid_identifier(): id = "anchor"
|
||||
id = id.substr(0, 32)
|
||||
if id == ORIGIN_ID and not anchors.has(id): return id
|
||||
var base: String = id; var suffix: int = 2
|
||||
while anchors.has(id) or id == ORIGIN_ID:
|
||||
id = base.substr(0, 32 - str(suffix).length()) + "_" + str(suffix); suffix += 1
|
||||
return id
|
||||
|
||||
static func ids_for_role(anchors: Dictionary, role: String) -> Array[String]:
|
||||
var ids: Array[String] = []
|
||||
for id: String in normalize_anchors(anchors, false):
|
||||
if str(normalize_anchors(anchors, false)[id].get("role", "")) == role: ids.append(id)
|
||||
return ids
|
||||
|
||||
static func compatible_grip_ids(anchors: Dictionary, hand: int) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for id: String in ids_for_role(anchors, "grip"):
|
||||
var allowed: String = str(normalize_anchors(anchors, false)[id].get("allowed_hands", "both"))
|
||||
if allowed == "both" or (hand == Hand.LEFT and allowed == "left_only") or (hand == Hand.RIGHT and allowed == "right_only"): result.append(id)
|
||||
return result
|
||||
|
||||
static func local_transform(anchor: Dictionary, mirror_x: bool = false) -> Transform2D:
|
||||
var position: Vector2 = anchor.get("position", Vector2.ZERO)
|
||||
var angle: float = deg_to_rad(float(anchor.get("rotation_deg", 0.0)))
|
||||
if mirror_x: position.x = -position.x; angle = PI - angle
|
||||
return Transform2D(angle, position)
|
||||
|
||||
static func solve_attachment_transform(target_transform: Transform2D, anchor: Dictionary, positive_scale: Vector2, mirror_x: bool) -> Transform2D:
|
||||
var effective: Transform2D = local_transform(anchor, mirror_x)
|
||||
var scale: Vector2 = Vector2(maxf(absf(positive_scale.x) if is_finite(positive_scale.x) else 1.0, 0.0001), maxf(absf(positive_scale.y) if is_finite(positive_scale.y) else 1.0, 0.0001))
|
||||
var basis := Transform2D(target_transform.get_rotation() - effective.get_rotation(), Vector2.ZERO).scaled(scale)
|
||||
basis.origin = target_transform.origin - basis.basis_xform(effective.origin)
|
||||
return basis
|
||||
|
||||
static func preset_layout(preset: String, bounds: Rect2) -> Dictionary:
|
||||
var center: Vector2 = bounds.get_center() if bounds.size != Vector2.ZERO else Vector2.ZERO
|
||||
var horizontal: bool = bounds.size.x >= bounds.size.y
|
||||
var length: float = bounds.size.x if horizontal else bounds.size.y
|
||||
var angle: float = 0.0 if horizontal else 90.0
|
||||
var endpoint: Vector2 = Vector2(bounds.end.x, center.y) if horizontal else Vector2(center.x, bounds.end.y)
|
||||
var offset: Vector2 = (Vector2(minf(length * 0.2, 32.0), 0.0) if horizontal else Vector2(0.0, minf(length * 0.2, 32.0)))
|
||||
var make := func(role: String, pos: Vector2) -> Dictionary: return {"role": role, "position": pos, "rotation_deg": angle, "z_index_offset": 0, "allowed_hands": "both"}
|
||||
var out: Dictionary = default_anchors()
|
||||
match preset:
|
||||
"one_handed": out["primary_grip"] = make.call("grip", center)
|
||||
"two_handed": out["primary_grip"] = make.call("grip", center - offset); out["secondary_grip"] = make.call("grip", center + offset)
|
||||
"tool_nozzle": out["primary_grip"] = make.call("grip", center); out["emitter"] = make.call("emitter", endpoint)
|
||||
"melee_edge": out["primary_grip"] = make.call("grip", center); out["impact"] = make.call("impact", endpoint)
|
||||
return out
|
||||
|
||||
static func _origin() -> Dictionary: return {"role": "pivot", "position": Vector2.ZERO, "rotation_deg": 0.0, "z_index_offset": 0, "allowed_hands": "both"}
|
||||
static func _finite(value: Variant, x: bool = true) -> float:
|
||||
var n: float = value.x if value is Vector2 and x else (value.y if value is Vector2 else float(value))
|
||||
return n if is_finite(n) else 0.0
|
||||
static func _wrap_deg(value: float) -> float: return fposmod(value + 180.0, 360.0) - 180.0
|
||||
@@ -0,0 +1 @@
|
||||
uid://bhlmhegchc1cu
|
||||
@@ -0,0 +1,101 @@
|
||||
class_name PropAnchorOverlay
|
||||
extends RefCounted
|
||||
|
||||
signal anchors_changed(anchors: Dictionary)
|
||||
signal anchor_selected(anchor_id: String)
|
||||
|
||||
enum DragMode { NONE, TRANSLATE, ROTATE }
|
||||
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
|
||||
const MARKER_RADIUS: float = 9.0
|
||||
const HIT_RADIUS: float = 15.0
|
||||
const ROTATE_OFFSET: float = 28.0
|
||||
const ROTATION_DOT_RADIUS: float = 5.0
|
||||
const ROTATION_DOT_HIT_RADIUS: float = 12.0
|
||||
var _anchors: Dictionary = PropAnchorDataScript.default_anchors()
|
||||
var _selected_id: String = ""
|
||||
var _show_inactive: bool = true
|
||||
var _drag_mode: DragMode = DragMode.NONE
|
||||
var _drag_offset: Vector2 = Vector2.ZERO
|
||||
|
||||
func set_anchor_data(data: Variant) -> void:
|
||||
_anchors = PropAnchorDataScript.normalize_anchors(data)
|
||||
if _selected_id == PropAnchorDataScript.ORIGIN_ID or not _anchors.has(_selected_id): _selected_id = ""
|
||||
|
||||
func get_anchor_data() -> Dictionary: return _anchors.duplicate(true)
|
||||
func select_anchor(anchor_id: String) -> void:
|
||||
var next_id: String = anchor_id if anchor_id != PropAnchorDataScript.ORIGIN_ID and _anchors.has(anchor_id) else ""
|
||||
if next_id != _selected_id:
|
||||
_selected_id = next_id
|
||||
anchor_selected.emit(_selected_id)
|
||||
func get_selected_anchor_id() -> String: return _selected_id
|
||||
func set_show_inactive(enabled: bool) -> void: _show_inactive = enabled
|
||||
|
||||
func hit_test_anchor(world_pos: Vector2, zoom: float) -> String:
|
||||
for id: String in _anchors:
|
||||
if id == PropAnchorDataScript.ORIGIN_ID: continue
|
||||
if not _visible(id): continue
|
||||
var anchor: Dictionary = _anchors[id]
|
||||
if world_pos.distance_to(anchor.get("position", Vector2.ZERO)) <= HIT_RADIUS / maxf(zoom, 0.001): return id
|
||||
return ""
|
||||
|
||||
func begin_pointer(world_pos: Vector2, zoom: float) -> bool:
|
||||
# The dot is intentionally outside the marker hit target, so test it before
|
||||
# ordinary anchor selection. This matches the shape transform gizmo model.
|
||||
if not _selected_id.is_empty() and _anchors.has(_selected_id):
|
||||
var selected: Dictionary = _anchors[_selected_id]
|
||||
var role: String = str(selected.get("role", "custom"))
|
||||
var selected_pos: Vector2 = selected.get("position", Vector2.ZERO)
|
||||
var dot: Vector2 = selected_pos + Vector2(0.0, ROTATE_OFFSET / maxf(zoom, 0.001))
|
||||
if (role == "grip" or role == "emitter") and world_pos.distance_to(dot) <= ROTATION_DOT_HIT_RADIUS / maxf(zoom, 0.001):
|
||||
_drag_mode = DragMode.ROTATE
|
||||
return true
|
||||
var id: String = hit_test_anchor(world_pos, zoom)
|
||||
if id.is_empty(): return false
|
||||
select_anchor(id)
|
||||
var anchor: Dictionary = _anchors[id]
|
||||
var pos: Vector2 = anchor.get("position", Vector2.ZERO)
|
||||
var role: String = str(anchor.get("role", "custom"))
|
||||
_drag_mode = DragMode.TRANSLATE; _drag_offset = pos - world_pos
|
||||
return true
|
||||
|
||||
func update_pointer(world_pos: Vector2, zoom: float, snap: bool, grid_size: int, ctrl: bool) -> void:
|
||||
if _drag_mode == DragMode.NONE or _selected_id.is_empty(): return
|
||||
var anchor: Dictionary = _anchors[_selected_id].duplicate(true)
|
||||
if _drag_mode == DragMode.TRANSLATE:
|
||||
var pos: Vector2 = world_pos + _drag_offset
|
||||
if snap: pos = Vector2(snappedf(pos.x, grid_size), snappedf(pos.y, grid_size))
|
||||
anchor["position"] = pos
|
||||
else:
|
||||
var angle: float = rad_to_deg((world_pos - anchor.get("position", Vector2.ZERO)).angle())
|
||||
if ctrl: angle = snappedf(angle, 15.0)
|
||||
anchor["rotation_deg"] = fposmod(angle + 180.0, 360.0) - 180.0
|
||||
_anchors[_selected_id] = anchor
|
||||
anchors_changed.emit(get_anchor_data())
|
||||
|
||||
func end_pointer() -> void:
|
||||
if _drag_mode != DragMode.NONE: anchors_changed.emit(get_anchor_data())
|
||||
_drag_mode = DragMode.NONE
|
||||
|
||||
func draw_gizmos(canvas: CanvasItem, zoom: float) -> void:
|
||||
for id: String in _anchors:
|
||||
if id == PropAnchorDataScript.ORIGIN_ID: continue
|
||||
if _visible(id): _draw_marker(canvas, id, _anchors[id], zoom, id == _selected_id)
|
||||
|
||||
func _visible(id: String) -> bool: return id != PropAnchorDataScript.ORIGIN_ID and (_show_inactive or id == _selected_id)
|
||||
func _draw_marker(canvas: CanvasItem, id: String, anchor: Dictionary, zoom: float, selected: bool) -> void:
|
||||
var pos: Vector2 = anchor.get("position", Vector2.ZERO); var radius: float = MARKER_RADIUS / maxf(zoom, 0.001)
|
||||
var role: String = str(anchor.get("role", "custom")); var color: Color = {"pivot": Color.YELLOW, "grip": Color.CYAN, "emitter": Color.RED, "impact": Color.HOT_PINK, "mount": Color.MEDIUM_PURPLE}.get(role, Color.LIGHT_GRAY)
|
||||
canvas.draw_circle(pos, radius + 2.0 / zoom, Color(0, 0, 0, 0.85))
|
||||
if role == "pivot": canvas.draw_line(pos - Vector2(radius, 0), pos + Vector2(radius, 0), color, 2.0 / zoom); canvas.draw_line(pos - Vector2(0, radius), pos + Vector2(0, radius), color, 2.0 / zoom)
|
||||
elif role == "impact": canvas.draw_colored_polygon(PackedVector2Array([pos + Vector2(0,-radius), pos + Vector2(radius,0), pos + Vector2(0,radius), pos + Vector2(-radius,0)]), color)
|
||||
else: canvas.draw_circle(pos, radius, color, false, 2.0 / zoom)
|
||||
if role == "grip" or role == "emitter":
|
||||
var direction: Vector2 = Vector2.RIGHT.rotated(deg_to_rad(float(anchor.get("rotation_deg", 0.0)))) * radius * 2.0
|
||||
canvas.draw_line(pos, pos + direction, color, 2.0 / zoom)
|
||||
if selected:
|
||||
var dot: Vector2 = pos + Vector2(0.0, ROTATE_OFFSET / zoom)
|
||||
canvas.draw_line(pos, dot, Color(0, 0, 0, 0.85), 4.0 / zoom)
|
||||
canvas.draw_line(pos, dot, color, 2.0 / zoom)
|
||||
canvas.draw_circle(dot, (ROTATION_DOT_RADIUS + 2.0) / zoom, Color(0, 0, 0, 0.85))
|
||||
canvas.draw_circle(dot, ROTATION_DOT_RADIUS / zoom, color)
|
||||
if selected: canvas.draw_arc(pos, radius + 5.0 / zoom, 0.0, TAU, 20, Color.YELLOW, 2.0 / zoom)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cj2xh11d1wk7j
|
||||
@@ -0,0 +1,548 @@
|
||||
class_name PropAttachPreview
|
||||
extends PanelContainer
|
||||
## Private editor visualization. It never creates a PropBlock or touches Sandbox state.
|
||||
|
||||
enum PreviewHand { LEFT, RIGHT }
|
||||
enum PreviewMotion { FRONT_POSE, WALK_LEFT, WALK_RIGHT }
|
||||
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
|
||||
const StickmanFactoryScript := preload("res://scripts/stickman_factory.gd")
|
||||
const StkRigAdapterScript := preload("res://scripts/stk_rig_adapter.gd")
|
||||
const RigScene := preload("res://scenes/stickman_rig.tscn")
|
||||
const RIG_CENTER := Vector2(340.0, 575.0)
|
||||
const TARGET_RADIUS := 14.0
|
||||
|
||||
var _viewport: SubViewport
|
||||
var _viewport_container: SubViewportContainer
|
||||
var _world: Node2D
|
||||
var _rig_root: Node2D
|
||||
var _rig: StickmanRig
|
||||
var _prop: Node2D
|
||||
var _target_marker: Polygon2D
|
||||
var _camera: Camera2D
|
||||
var _hint: Label
|
||||
var _walk_playback_button: Button
|
||||
var _reset_arm_button: Button
|
||||
var _reset_view_button: Button
|
||||
var _hand_option: OptionButton
|
||||
var _motion_option: OptionButton
|
||||
var _shapes: Array[Dictionary] = []
|
||||
var _anchors: Dictionary = {}
|
||||
var _selected: String = ""
|
||||
var _hand: int = PreviewHand.RIGHT
|
||||
var _motion: PreviewMotion = PreviewMotion.FRONT_POSE
|
||||
var _walk_paused: bool = true
|
||||
var _manual_arm_override: bool = false
|
||||
# Retained only for old test harnesses; Phase 9c UI uses _walk_paused.
|
||||
var _animate: bool = false
|
||||
var _dragging_target: bool = false
|
||||
var _camera_dragging: bool = false
|
||||
var _last_pointer := Vector2.ZERO
|
||||
var _hand_target_rests_local: Dictionary = {}
|
||||
var _camera_zoom := 1.0
|
||||
var _fitted_zoom := 1.0
|
||||
|
||||
func _ready() -> void:
|
||||
custom_minimum_size.x = 360.0
|
||||
var surface := StyleBoxFlat.new()
|
||||
surface.bg_color = Color(0.075, 0.09, 0.12, 1.0)
|
||||
surface.set_border_width_all(0)
|
||||
surface.set_corner_radius_all(5)
|
||||
add_theme_stylebox_override("panel", surface)
|
||||
_build_ui()
|
||||
_build_private_world()
|
||||
_reframe_camera()
|
||||
|
||||
func _build_ui() -> void:
|
||||
var layout := VBoxContainer.new()
|
||||
layout.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
add_child(layout)
|
||||
var header := FlowContainer.new()
|
||||
layout.add_child(header)
|
||||
var title := Label.new()
|
||||
title.text = "Holding Preview"
|
||||
title.custom_minimum_size.x = 132.0
|
||||
header.add_child(title)
|
||||
_hand_option = OptionButton.new()
|
||||
_hand_option.name = "HandOption"
|
||||
_hand_option.add_item("Left Hand", PreviewHand.LEFT)
|
||||
_hand_option.add_item("Right Hand", PreviewHand.RIGHT)
|
||||
_hand_option.select(PreviewHand.RIGHT)
|
||||
_hand_option.item_selected.connect(set_preview_hand)
|
||||
header.add_child(_hand_option)
|
||||
_motion_option = OptionButton.new()
|
||||
_motion_option.name = "MotionOption"
|
||||
_motion_option.add_item("Front Pose", PreviewMotion.FRONT_POSE)
|
||||
_motion_option.add_item("Walk Left", PreviewMotion.WALK_LEFT)
|
||||
_motion_option.add_item("Walk Right", PreviewMotion.WALK_RIGHT)
|
||||
_motion_option.item_selected.connect(set_preview_motion)
|
||||
header.add_child(_motion_option)
|
||||
_hint = Label.new()
|
||||
_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
layout.add_child(_hint)
|
||||
_viewport_container = SubViewportContainer.new()
|
||||
# The pane keeps its 360 px width, but its height must yield to the editor's
|
||||
# fixed name/status rows when Attach mode makes the preview visible.
|
||||
_viewport_container.custom_minimum_size = Vector2(360.0, 0.0)
|
||||
_viewport_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
_viewport_container.stretch = true
|
||||
_viewport_container.gui_input.connect(_on_viewport_gui_input)
|
||||
layout.add_child(_viewport_container)
|
||||
var footer := FlowContainer.new()
|
||||
layout.add_child(footer)
|
||||
_walk_playback_button = Button.new()
|
||||
_walk_playback_button.name = "WalkPlaybackButton"
|
||||
_walk_playback_button.pressed.connect(toggle_walk_playback)
|
||||
footer.add_child(_walk_playback_button)
|
||||
_reset_arm_button = Button.new()
|
||||
_reset_arm_button.text = "Reset Arm"
|
||||
_reset_arm_button.pressed.connect(reset_arm_pose)
|
||||
footer.add_child(_reset_arm_button)
|
||||
_reset_view_button = Button.new()
|
||||
_reset_view_button.text = "Reset View"
|
||||
_reset_view_button.pressed.connect(_reframe_camera)
|
||||
footer.add_child(_reset_view_button)
|
||||
_apply_hit_target_metrics(36.0)
|
||||
|
||||
func set_ui_hit_target_height(height: float) -> void:
|
||||
_apply_hit_target_metrics(maxf(height, 36.0))
|
||||
|
||||
func _apply_hit_target_metrics(height: float) -> void:
|
||||
for option: OptionButton in [_hand_option, _motion_option]:
|
||||
if option != null:
|
||||
option.custom_minimum_size = Vector2(88.0, height)
|
||||
option.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
option.focus_mode = Control.FOCUS_NONE
|
||||
for button: Button in [_walk_playback_button, _reset_arm_button, _reset_view_button]:
|
||||
if button != null:
|
||||
button.custom_minimum_size = Vector2(112.0, height)
|
||||
button.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
button.focus_mode = Control.FOCUS_NONE
|
||||
|
||||
func _build_private_world() -> void:
|
||||
_viewport = SubViewport.new()
|
||||
_viewport.name = "PreviewViewport"
|
||||
_viewport.transparent_bg = false
|
||||
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
_viewport_container.add_child(_viewport)
|
||||
_viewport_container.resized.connect(_resize_viewport)
|
||||
_world = Node2D.new()
|
||||
_world.name = "PreviewWorld"
|
||||
_viewport.add_child(_world)
|
||||
_rig_root = Node2D.new()
|
||||
_rig_root.name = "PreviewRigRoot"
|
||||
_rig_root.position = RIG_CENTER
|
||||
_world.add_child(_rig_root)
|
||||
_rig = RigScene.instantiate() as StickmanRig
|
||||
_rig.set_physics_process(false)
|
||||
_rig_root.add_child(_rig)
|
||||
var data: Dictionary = StickmanFactoryScript.load_stk("res://stickmen/test.stk")
|
||||
if not data.is_empty():
|
||||
StkRigAdapterScript.apply(data, _rig)
|
||||
_prop = Node2D.new()
|
||||
_prop.name = "PreviewProp"
|
||||
_world.add_child(_prop)
|
||||
_target_marker = Polygon2D.new()
|
||||
_target_marker.polygon = PackedVector2Array([Vector2(0, -10), Vector2(10, 0), Vector2(0, 10), Vector2(-10, 0)])
|
||||
_target_marker.color = Color(0.25, 0.9, 1.0, 0.85)
|
||||
_world.add_child(_target_marker)
|
||||
_camera = Camera2D.new()
|
||||
_camera.name = "PreviewCamera"
|
||||
_world.add_child(_camera)
|
||||
_camera.make_current()
|
||||
_disable_collisions(_rig)
|
||||
call_deferred("_finish_rig_setup")
|
||||
|
||||
func _finish_rig_setup() -> void:
|
||||
var player := _rig.get_node_or_null("AnimationPlayer") as AnimationPlayer
|
||||
if player != null:
|
||||
player.stop()
|
||||
_rig.stop_queue()
|
||||
_rig.clear_queue()
|
||||
_rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD)
|
||||
for hand: int in [PreviewHand.LEFT, PreviewHand.RIGHT]:
|
||||
var target := _target_for(hand)
|
||||
if target != null:
|
||||
_hand_target_rests_local[hand] = target.position
|
||||
_apply_motion_pose()
|
||||
_update_target_marker()
|
||||
_rebuild_prop()
|
||||
_reframe_camera()
|
||||
|
||||
func set_document(shapes: Array[Dictionary], anchors: Dictionary, selected_anchor_id: String) -> void:
|
||||
_shapes = shapes.duplicate(true)
|
||||
_anchors = anchors.duplicate(true)
|
||||
_selected = selected_anchor_id
|
||||
_rebuild_prop()
|
||||
_reframe_camera()
|
||||
|
||||
func set_selected_anchor(anchor_id: String) -> void:
|
||||
_selected = anchor_id
|
||||
_rebuild_prop()
|
||||
|
||||
func set_preview_hand(hand: int) -> void:
|
||||
_hand = clampi(hand, PreviewHand.LEFT, PreviewHand.RIGHT)
|
||||
_manual_arm_override = false
|
||||
_apply_motion_pose()
|
||||
_rebuild_prop()
|
||||
|
||||
func set_preview_motion(motion: int) -> void:
|
||||
_motion = clampi(motion, PreviewMotion.FRONT_POSE, PreviewMotion.WALK_RIGHT)
|
||||
_manual_arm_override = false
|
||||
_apply_motion_pose(true)
|
||||
_rebuild_prop()
|
||||
_reframe_camera()
|
||||
|
||||
# Compatibility facade for Phase 9a callers. The visible Facing control was
|
||||
# removed in 9c; callers now map to the equivalent preview motion.
|
||||
func set_facing_profile(profile: int) -> void:
|
||||
if profile == StickmanRig.FacingProfile.LEFT:
|
||||
set_preview_motion(PreviewMotion.WALK_LEFT)
|
||||
elif profile == StickmanRig.FacingProfile.RIGHT:
|
||||
set_preview_motion(PreviewMotion.WALK_RIGHT)
|
||||
else:
|
||||
set_preview_motion(PreviewMotion.FRONT_POSE)
|
||||
|
||||
func set_walk_paused(paused: bool) -> void:
|
||||
if _motion == PreviewMotion.FRONT_POSE:
|
||||
_walk_paused = true
|
||||
_update_walk_button()
|
||||
return
|
||||
var player := _animation_player()
|
||||
_walk_paused = paused
|
||||
if player != null:
|
||||
if paused:
|
||||
player.pause()
|
||||
else:
|
||||
player.play()
|
||||
_manual_arm_override = false if not paused else _manual_arm_override
|
||||
_update_walk_button()
|
||||
|
||||
func toggle_walk_playback() -> void:
|
||||
set_walk_paused(not _walk_paused)
|
||||
|
||||
func set_arm_animation_enabled(enabled: bool) -> void:
|
||||
_animate = enabled
|
||||
if enabled and _motion == PreviewMotion.FRONT_POSE:
|
||||
set_preview_motion(PreviewMotion.WALK_RIGHT)
|
||||
elif not enabled:
|
||||
set_walk_paused(true)
|
||||
|
||||
func reset_arm_pose() -> void:
|
||||
if _motion != PreviewMotion.FRONT_POSE:
|
||||
set_walk_paused(true)
|
||||
var player := _animation_player()
|
||||
if player != null:
|
||||
player.seek(player.current_animation_position, true)
|
||||
else:
|
||||
_restore_standing_targets()
|
||||
_manual_arm_override = false
|
||||
_animate = false
|
||||
_update_target_marker()
|
||||
_update_prop_transform()
|
||||
|
||||
func is_preview_available() -> bool:
|
||||
return _rig != null and is_instance_valid(_rig) and _viewport != null
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not is_preview_available():
|
||||
return
|
||||
_update_target_marker()
|
||||
_update_prop_transform()
|
||||
|
||||
func _animation_player() -> AnimationPlayer:
|
||||
return _rig.get_node_or_null("AnimationPlayer") as AnimationPlayer if _rig != null else null
|
||||
|
||||
func _restore_standing_targets() -> void:
|
||||
for hand: int in [PreviewHand.LEFT, PreviewHand.RIGHT]:
|
||||
var target := _target_for(hand)
|
||||
if target != null and _hand_target_rests_local.has(hand):
|
||||
target.position = _hand_target_rests_local[hand]
|
||||
|
||||
func _apply_motion_pose(restart: bool = false) -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
var player := _animation_player()
|
||||
if _motion == PreviewMotion.FRONT_POSE:
|
||||
_rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD)
|
||||
_rig_root.scale = Vector2.ONE
|
||||
if player != null: player.stop(true)
|
||||
_restore_standing_targets()
|
||||
_walk_paused = true
|
||||
else:
|
||||
var profile: int = StickmanRig.FacingProfile.LEFT if _motion == PreviewMotion.WALK_LEFT else StickmanRig.FacingProfile.RIGHT
|
||||
_rig.set_facing_profile(profile)
|
||||
_rig.scale = Vector2.ONE
|
||||
_rig_root.scale = Vector2(-1.0, 1.0) if _motion == PreviewMotion.WALK_LEFT else Vector2.ONE
|
||||
if player != null:
|
||||
if restart or player.current_animation != "walk_right":
|
||||
player.play("walk_right")
|
||||
player.seek(0.0, true)
|
||||
else:
|
||||
player.play()
|
||||
_walk_paused = false
|
||||
_enable_arm_ik()
|
||||
_update_walk_button()
|
||||
|
||||
func _update_walk_button() -> void:
|
||||
if _walk_playback_button == null:
|
||||
return
|
||||
var front: bool = _motion == PreviewMotion.FRONT_POSE
|
||||
_walk_playback_button.disabled = front
|
||||
_walk_playback_button.text = "Resume Walk" if _walk_paused else "Pause Walk"
|
||||
_walk_playback_button.tooltip_text = "Choose Walk Left or Walk Right to preview walking." if front else ""
|
||||
|
||||
func _hand_target() -> Marker2D:
|
||||
return _target_for(_hand)
|
||||
|
||||
func _target_for(hand: int) -> Marker2D:
|
||||
if _rig == null:
|
||||
return null
|
||||
return _rig.get_node_or_null("IK_Targets/Left_Hand" if hand == PreviewHand.LEFT else "IK_Targets/Right_Hand") as Marker2D
|
||||
|
||||
func _lower_arm() -> Bone2D:
|
||||
if _rig == null:
|
||||
return null
|
||||
return _rig.get_node_or_null("Skeleton2D/Torso/LeftUpperArm/LeftLowerArm" if _hand == PreviewHand.LEFT else "Skeleton2D/Torso/RightUpperArm/RightLowerArm") as Bone2D
|
||||
|
||||
func _solved_wrist_transform() -> Transform2D:
|
||||
var lower_visual := _rig.get_node_or_null("Body/LeftLowerArm" if _hand == PreviewHand.LEFT else "Body/RightLowerArm") as Node2D
|
||||
var lower := _lower_arm()
|
||||
if lower == null or lower_visual == null:
|
||||
return Transform2D.IDENTITY
|
||||
var elbow := lower_visual.to_global(Vector2.ZERO)
|
||||
var wrist := lower_visual.to_global(Vector2(0.0, lower.length))
|
||||
var direction := (wrist - elbow).normalized()
|
||||
if direction.length_squared() < 0.0001:
|
||||
return Transform2D.IDENTITY
|
||||
return Transform2D(direction.angle(), wrist)
|
||||
|
||||
func _rebuild_prop() -> void:
|
||||
if _prop == null:
|
||||
return
|
||||
for child: Node in _prop.get_children():
|
||||
child.queue_free()
|
||||
var anchor: Dictionary = _anchors.get(_selected, {})
|
||||
var grip_ok := str(anchor.get("role", "")) == "grip" and _grip_allows_hand(anchor)
|
||||
_update_hand_choices(anchor)
|
||||
_prop.visible = grip_ok
|
||||
if _hint != null:
|
||||
_hint.text = "Drag the cyan target to pose the arm." if grip_ok else "Select a grip anchor compatible with this hand to preview holding."
|
||||
if not grip_ok:
|
||||
return
|
||||
for shape: Dictionary in _shapes:
|
||||
_add_shape_visual(shape)
|
||||
_update_prop_transform()
|
||||
|
||||
func _add_shape_visual(shape: Dictionary) -> void:
|
||||
# PropCanvas exports JSON-safe {x,y} dictionaries. Assigning that Array
|
||||
# directly to PackedVector2Array silently turns every point into zero.
|
||||
var points := _coerce_points(shape.get("points", PackedVector2Array()))
|
||||
if points.is_empty() and str(shape.get("shape_type", "polygon")) == "circle":
|
||||
var radius := maxf(float(shape.get("radius", 24.0)), 0.001)
|
||||
var center := Vector2.ZERO
|
||||
for index: int in 24:
|
||||
points.append(center + Vector2.from_angle(TAU * float(index) / 24.0) * radius)
|
||||
if points.is_empty():
|
||||
return
|
||||
var color := Color.from_string(str(shape.get("fill_color", "#999999")), Color.GRAY)
|
||||
if int(shape.get("shape_type", 0)) == 2 or not bool(shape.get("closed", true)):
|
||||
var line := Line2D.new()
|
||||
line.points = points
|
||||
line.width = maxf(float(shape.get("outline_width", 2.0)), 1.0)
|
||||
line.default_color = Color.from_string(str(shape.get("outline_color", "#eeeeee")), Color.WHITE)
|
||||
_prop.add_child(line)
|
||||
else:
|
||||
var polygon := Polygon2D.new()
|
||||
polygon.polygon = points
|
||||
polygon.color = color
|
||||
_prop.add_child(polygon)
|
||||
|
||||
func _coerce_points(raw: Variant) -> PackedVector2Array:
|
||||
if raw is PackedVector2Array:
|
||||
return raw as PackedVector2Array
|
||||
var result := PackedVector2Array()
|
||||
if raw is Array:
|
||||
for item: Variant in raw:
|
||||
if item is Vector2:
|
||||
result.append(item as Vector2)
|
||||
elif item is Dictionary:
|
||||
var point: Dictionary = item as Dictionary
|
||||
result.append(Vector2(float(point.get("x", 0.0)), float(point.get("y", 0.0))))
|
||||
return result
|
||||
|
||||
func _update_prop_transform() -> void:
|
||||
if _prop == null or not _prop.visible:
|
||||
return
|
||||
var anchor: Dictionary = _anchors.get(_selected, {})
|
||||
var mirror := _motion == PreviewMotion.WALK_LEFT
|
||||
_prop.global_transform = PropAnchorDataScript.solve_attachment_transform(_solved_wrist_transform(), anchor, Vector2.ONE, mirror)
|
||||
# PreviewProp follows PreviewRigRoot in tree order, so zero remains visible
|
||||
# above the stickman; negative puts it behind and positive remains in front.
|
||||
_prop.z_index = int(anchor.get("z_index_offset", 0))
|
||||
|
||||
func _grip_allows_hand(anchor: Dictionary) -> bool:
|
||||
var allowed := str(anchor.get("allowed_hands", "both"))
|
||||
return allowed == "both" or (allowed == "left_only" and _hand == PreviewHand.LEFT) or (allowed == "right_only" and _hand == PreviewHand.RIGHT)
|
||||
|
||||
func _update_target_marker() -> void:
|
||||
var target := _hand_target()
|
||||
if target != null and _target_marker != null:
|
||||
_target_marker.global_position = target.global_position
|
||||
_target_marker.visible = _prop == null or _prop.visible
|
||||
|
||||
func _resize_viewport() -> void:
|
||||
if _viewport != null:
|
||||
# SubViewportContainer.stretch owns the viewport size. Resizing it here
|
||||
# fights that ownership and produces an engine warning.
|
||||
_reframe_camera()
|
||||
|
||||
func _on_viewport_gui_input(event: InputEvent) -> void:
|
||||
if _camera == null:
|
||||
return
|
||||
if event is InputEventMouseButton:
|
||||
var mouse := event as InputEventMouseButton
|
||||
if mouse.button_index == MOUSE_BUTTON_WHEEL_UP or mouse.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||
var world_before := _screen_to_world(mouse.position)
|
||||
_camera_zoom = clampf(_camera_zoom * (1.1 if mouse.button_index == MOUSE_BUTTON_WHEEL_UP else 1.0 / 1.1), _fitted_zoom * 0.3, _fitted_zoom * 3.0)
|
||||
_camera.zoom = Vector2(_camera_zoom, _camera_zoom)
|
||||
_camera.position += world_before - _screen_to_world(mouse.position)
|
||||
return
|
||||
var world_pos := _screen_to_world(mouse.position)
|
||||
if mouse.button_index == MOUSE_BUTTON_LEFT:
|
||||
if mouse.pressed and world_pos.distance_to(_target_marker.global_position) <= TARGET_RADIUS / _camera_zoom:
|
||||
_dragging_target = true
|
||||
if _motion != PreviewMotion.FRONT_POSE:
|
||||
set_walk_paused(true)
|
||||
_manual_arm_override = true
|
||||
elif not mouse.pressed:
|
||||
_dragging_target = false
|
||||
if mouse.button_index == MOUSE_BUTTON_MIDDLE:
|
||||
_camera_dragging = mouse.pressed
|
||||
_last_pointer = mouse.position
|
||||
elif event is InputEventMouseMotion:
|
||||
var motion := event as InputEventMouseMotion
|
||||
if _dragging_target:
|
||||
_set_target_clamped(_screen_to_world(motion.position))
|
||||
elif _camera_dragging:
|
||||
_camera.position -= (motion.position - _last_pointer) / _camera_zoom
|
||||
_last_pointer = motion.position
|
||||
|
||||
func _screen_to_world(screen: Vector2) -> Vector2:
|
||||
return _viewport.get_canvas_transform().affine_inverse() * screen
|
||||
|
||||
func _set_target_clamped(requested: Vector2) -> void:
|
||||
var target := _hand_target()
|
||||
var lower := _lower_arm()
|
||||
if target == null or lower == null:
|
||||
return
|
||||
target.global_position = _clamp_target(requested)
|
||||
|
||||
func _clamp_target(requested: Vector2) -> Vector2:
|
||||
var target := _hand_target()
|
||||
var lower := _lower_arm()
|
||||
var upper := lower.get_parent() as Bone2D if lower != null else null
|
||||
if target == null or lower == null or upper == null:
|
||||
return requested
|
||||
var origin := upper.global_position
|
||||
var offset := requested - origin
|
||||
var rest: Vector2 = target.to_global(_hand_target_rests_local.get(_hand, Vector2.RIGHT)) - origin
|
||||
if offset.length_squared() < 0.0001:
|
||||
offset = rest.normalized() if rest.length_squared() > 0.0001 else Vector2.RIGHT
|
||||
var min_reach := absf(upper.length - lower.length) + 8.0
|
||||
var max_reach := maxf(min_reach, upper.length + lower.length - 8.0)
|
||||
return origin + offset.normalized() * clampf(offset.length(), min_reach, max_reach)
|
||||
|
||||
func _update_hand_choices(anchor: Dictionary) -> void:
|
||||
var allowed := str(anchor.get("allowed_hands", "both"))
|
||||
if allowed == "left_only" and _hand != PreviewHand.LEFT:
|
||||
_hand = PreviewHand.LEFT
|
||||
reset_arm_pose()
|
||||
if allowed == "right_only" and _hand != PreviewHand.RIGHT:
|
||||
_hand = PreviewHand.RIGHT
|
||||
reset_arm_pose()
|
||||
if _hand_option == null:
|
||||
return
|
||||
_hand_option.set_item_disabled(PreviewHand.LEFT, allowed == "right_only")
|
||||
_hand_option.set_item_disabled(PreviewHand.RIGHT, allowed == "left_only")
|
||||
_hand_option.select(_hand)
|
||||
|
||||
func _enable_arm_ik() -> void:
|
||||
var skeleton := _rig.get_node_or_null("Skeleton2D") as Skeleton2D
|
||||
if skeleton == null:
|
||||
return
|
||||
var stack := skeleton.get_modification_stack()
|
||||
if stack == null:
|
||||
return
|
||||
for index: int in 2:
|
||||
var modification := stack.get_modification(index)
|
||||
if modification is SkeletonModification2DTwoBoneIK:
|
||||
(modification as SkeletonModification2DTwoBoneIK).enabled = true
|
||||
|
||||
func _reframe_camera() -> void:
|
||||
if _camera == null or _viewport == null or _viewport.size.x < 1:
|
||||
return
|
||||
var bounds := _visual_bounds(true)
|
||||
if bounds.size.length_squared() < 0.001:
|
||||
return
|
||||
bounds = bounds.grow(32.0)
|
||||
_camera.position = bounds.get_center()
|
||||
_fitted_zoom = minf(float(_viewport.size.x) / bounds.size.x, float(_viewport.size.y) / bounds.size.y)
|
||||
_fitted_zoom = maxf(_fitted_zoom, 0.001)
|
||||
_camera_zoom = _fitted_zoom
|
||||
_camera.zoom = Vector2(_camera_zoom, _camera_zoom)
|
||||
|
||||
func _visual_bounds(full_figure: bool) -> Rect2:
|
||||
var points := PackedVector2Array()
|
||||
_collect_visual_points(_rig.get_node_or_null("Body"), points, full_figure)
|
||||
_collect_visual_points(_prop, points, true)
|
||||
if points.is_empty():
|
||||
return Rect2(RIG_CENTER - Vector2(200.0, 200.0), Vector2(400.0, 400.0))
|
||||
var rect := Rect2(points[0], Vector2.ZERO)
|
||||
for point: Vector2 in points:
|
||||
rect = rect.expand(point)
|
||||
return rect
|
||||
|
||||
func get_prop_global_bounds() -> Rect2:
|
||||
return _visual_bounds_for_node(_prop)
|
||||
|
||||
func get_preview_world_rect() -> Rect2:
|
||||
if _viewport == null:
|
||||
return Rect2()
|
||||
var transform := _viewport.get_canvas_transform().affine_inverse()
|
||||
var top_left := transform * Vector2.ZERO
|
||||
var bottom_right := transform * Vector2(_viewport.size)
|
||||
return Rect2(top_left, bottom_right - top_left).abs()
|
||||
|
||||
func get_solved_wrist_transform() -> Transform2D:
|
||||
return _solved_wrist_transform()
|
||||
|
||||
func _visual_bounds_for_node(node: Node) -> Rect2:
|
||||
var points := PackedVector2Array()
|
||||
_collect_visual_points(node, points, true)
|
||||
if points.is_empty():
|
||||
return Rect2()
|
||||
var rect := Rect2(points[0], Vector2.ZERO)
|
||||
for point: Vector2 in points:
|
||||
rect = rect.expand(point)
|
||||
return rect
|
||||
|
||||
func _collect_visual_points(node: Node, out: PackedVector2Array, full_figure: bool) -> void:
|
||||
if node == null or not (node as CanvasItem).visible:
|
||||
return
|
||||
var include := full_figure or not (node.name.contains("Leg"))
|
||||
if include and node is Polygon2D:
|
||||
for point: Vector2 in (node as Polygon2D).polygon:
|
||||
out.append((node as Polygon2D).to_global(point))
|
||||
if include and node is Line2D:
|
||||
for point: Vector2 in (node as Line2D).points:
|
||||
out.append((node as Line2D).to_global(point))
|
||||
for child: Node in node.get_children():
|
||||
_collect_visual_points(child, out, full_figure)
|
||||
|
||||
func _disable_collisions(node: Node) -> void:
|
||||
if node is CollisionObject2D:
|
||||
var body := node as CollisionObject2D
|
||||
body.collision_layer = 0
|
||||
body.collision_mask = 0
|
||||
for child: Node in node.get_children():
|
||||
_disable_collisions(child)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bthsk33yw5rr8
|
||||
+104
-11
@@ -28,6 +28,8 @@ signal vertex_created(shape_idx: int, vertex_idx: int)
|
||||
|
||||
## Emitted only when the edit mode actually changes.
|
||||
signal mode_changed(mode: int)
|
||||
signal anchors_changed(anchors: Dictionary)
|
||||
signal anchor_selected(anchor_id: String)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums / constants
|
||||
@@ -41,7 +43,7 @@ enum DragMode { NONE, VERTEX, TRANSLATE, ROTATE, SCALE }
|
||||
## TRANSFORM = "Move & Resize" (gizmos + shape drag); POINTS = "Edit Points"
|
||||
## (vertex handles + Create/Remove Point). Gates input and drawing only — the
|
||||
## public math APIs stay mode-agnostic.
|
||||
enum EditMode { TRANSFORM, POINTS }
|
||||
enum EditMode { TRANSFORM, POINTS, ATTACH }
|
||||
|
||||
const HANDLE_RADIUS: float = 3.0
|
||||
const HANDLE_RADIUS_SELECTION: float = 8.0
|
||||
@@ -62,9 +64,11 @@ const MIN_ZOOM: float = 0.3
|
||||
const MAX_ZOOM: float = 3.0
|
||||
const ZOOM_STEP: float = 1.10
|
||||
const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.15)
|
||||
const ORIGIN_MARKER_DEFAULT_COLOR := Color(0.6, 0.6, 0.6, 0.7)
|
||||
|
||||
const TerrainUtilsScript := preload("res://scripts/terrain_utils.gd")
|
||||
const PropFileScript := preload("res://scripts/prop_file.gd")
|
||||
const PropAnchorOverlayScript := preload("res://scripts/prop_anchor_overlay.gd")
|
||||
|
||||
const SELECTION_CORE_COLOR := Color.WHITE
|
||||
const SELECTION_HALO_COLOR := Color(0.0, 0.0, 0.0, 0.85)
|
||||
@@ -112,6 +116,9 @@ var _snap_enabled: bool = false
|
||||
var _has_clipboard: bool = false
|
||||
var _context_menu: PopupMenu
|
||||
var _edit_mode: EditMode = EditMode.TRANSFORM
|
||||
var _anchor_overlay: RefCounted
|
||||
var _origin_marker_color: Color = ORIGIN_MARKER_DEFAULT_COLOR
|
||||
var _show_origin_marker: bool = true
|
||||
|
||||
# Drag state
|
||||
var _drag_mode: DragMode = DragMode.NONE
|
||||
@@ -142,6 +149,9 @@ func _ready() -> void:
|
||||
draw.connect(_on_canvas_draw)
|
||||
if not gui_input.is_connected(_on_canvas_gui_input):
|
||||
gui_input.connect(_on_canvas_gui_input)
|
||||
_anchor_overlay = PropAnchorOverlayScript.new()
|
||||
_anchor_overlay.anchors_changed.connect(func(data: Dictionary) -> void: anchors_changed.emit(data); queue_redraw())
|
||||
_anchor_overlay.anchor_selected.connect(func(anchor_id: String) -> void: anchor_selected.emit(anchor_id); queue_redraw())
|
||||
_context_menu = PopupMenu.new()
|
||||
_context_menu.name = "ContextMenu"
|
||||
add_child(_context_menu)
|
||||
@@ -162,7 +172,7 @@ func _process(delta: float) -> void:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func set_edit_mode(mode: int) -> void:
|
||||
var m: int = EditMode.TRANSFORM if mode != EditMode.POINTS else EditMode.POINTS
|
||||
var m: int = mode if mode >= EditMode.TRANSFORM and mode <= EditMode.ATTACH else EditMode.TRANSFORM
|
||||
if m == _edit_mode:
|
||||
return
|
||||
_edit_mode = m
|
||||
@@ -175,6 +185,62 @@ func set_edit_mode(mode: int) -> void:
|
||||
func get_edit_mode() -> int:
|
||||
return _edit_mode
|
||||
|
||||
func set_anchor_data(data: Variant) -> void:
|
||||
if _anchor_overlay == null: _anchor_overlay = PropAnchorOverlayScript.new()
|
||||
_anchor_overlay.set_anchor_data(data)
|
||||
queue_redraw()
|
||||
|
||||
func get_anchor_data() -> Dictionary:
|
||||
return _anchor_overlay.get_anchor_data() if _anchor_overlay != null else {}
|
||||
|
||||
func select_anchor(anchor_id: String) -> void:
|
||||
if _anchor_overlay != null: _anchor_overlay.select_anchor(anchor_id); queue_redraw()
|
||||
|
||||
func get_selected_anchor_id() -> String:
|
||||
return _anchor_overlay.get_selected_anchor_id() if _anchor_overlay != null else ""
|
||||
|
||||
func set_show_inactive_anchors(enabled: bool) -> void:
|
||||
if _anchor_overlay != null: _anchor_overlay.set_show_inactive(enabled); queue_redraw()
|
||||
|
||||
func set_show_origin_marker(enabled: bool) -> void:
|
||||
_show_origin_marker = enabled
|
||||
queue_redraw()
|
||||
|
||||
func get_shapes_bounds() -> Rect2:
|
||||
var found: bool = false; var result: Rect2 = Rect2()
|
||||
for shape: Dictionary in shapes:
|
||||
if not _shape_has_geometry(shape): continue
|
||||
var rect: Rect2 = _shape_bbox(shape)
|
||||
if not found: result = rect; found = true
|
||||
else: result = result.merge(rect)
|
||||
return result
|
||||
|
||||
func has_shape_geometry() -> bool:
|
||||
for shape: Dictionary in shapes:
|
||||
if _shape_has_geometry(shape): return true
|
||||
return false
|
||||
|
||||
func translate_all_shapes(delta: Vector2) -> bool:
|
||||
if not has_shape_geometry() or not is_finite(delta.x) or not is_finite(delta.y) or delta.is_zero_approx(): return false
|
||||
for i: int in shapes.size():
|
||||
var shape: Dictionary = shapes[i].duplicate(true)
|
||||
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
|
||||
for j: int in points.size(): points[j] += delta
|
||||
shape["points"] = points
|
||||
shapes[i] = shape
|
||||
queue_redraw()
|
||||
shape_changed.emit(get_shape_data())
|
||||
return true
|
||||
|
||||
func focus_world_point(world_point: Vector2) -> void:
|
||||
if not is_finite(world_point.x) or not is_finite(world_point.y): return
|
||||
_pan_offset = size * 0.5 - world_point * _zoom
|
||||
queue_redraw()
|
||||
|
||||
func set_origin_marker_color(color: Color) -> void:
|
||||
_origin_marker_color = color
|
||||
queue_redraw()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — shape data
|
||||
@@ -193,8 +259,7 @@ func set_shape_data(data: Variant) -> void:
|
||||
_selected_shape_idx = -1
|
||||
_drag_mode = DragMode.NONE
|
||||
_dragging_vertex = -1
|
||||
_zoom = 1.0
|
||||
_pan_offset = Vector2.ZERO
|
||||
reset_view()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
@@ -212,9 +277,7 @@ func clear() -> void:
|
||||
_selected_shape_idx = -1
|
||||
_drag_mode = DragMode.NONE
|
||||
_dragging_vertex = -1
|
||||
_zoom = 1.0
|
||||
_pan_offset = Vector2.ZERO
|
||||
queue_redraw()
|
||||
reset_view()
|
||||
|
||||
|
||||
func select(idx: int) -> void:
|
||||
@@ -566,8 +629,7 @@ func get_snap_enabled() -> bool:
|
||||
|
||||
func reset_view() -> void:
|
||||
_zoom = 1.0
|
||||
_pan_offset = Vector2.ZERO
|
||||
queue_redraw()
|
||||
focus_world_point(Vector2.ZERO)
|
||||
|
||||
|
||||
func set_has_clipboard(state: bool) -> void:
|
||||
@@ -596,19 +658,34 @@ func _on_canvas_draw() -> void:
|
||||
draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom))
|
||||
_draw_grid()
|
||||
|
||||
if shapes.is_empty():
|
||||
if shapes.is_empty() and _edit_mode == EditMode.TRANSFORM:
|
||||
var rect := get_rect()
|
||||
var hint_pos := _screen_to_world(Vector2(rect.size.x * 0.5 - 90.0, rect.size.y * 0.5))
|
||||
var font := get_theme_default_font()
|
||||
if font != null:
|
||||
draw_string(font, hint_pos, "Right-click to add a shape", HORIZONTAL_ALIGNMENT_CENTER, -1, 14, Color(0.5, 0.5, 0.5, 0.6))
|
||||
return
|
||||
|
||||
for i: int in shapes.size():
|
||||
_draw_shape(shapes[i], i == _selected_shape_idx, _dragging_vertex if i == _selected_shape_idx else -1)
|
||||
_draw_origin_marker()
|
||||
|
||||
if _edit_mode == EditMode.TRANSFORM and _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size():
|
||||
_draw_selection_gizmos(shapes[_selected_shape_idx])
|
||||
if _edit_mode == EditMode.ATTACH and _anchor_overlay != null:
|
||||
_anchor_overlay.draw_gizmos(self, _zoom)
|
||||
|
||||
func _draw_origin_marker() -> void:
|
||||
if not _show_origin_marker:
|
||||
return
|
||||
var inv_zoom: float = 1.0 / maxf(_zoom, 0.001)
|
||||
var radius: float = 6.0 * inv_zoom
|
||||
var halo: Color = Color(0.0, 0.0, 0.0, 0.75)
|
||||
draw_circle(Vector2.ZERO, radius + 2.0 * inv_zoom, halo, false, 3.0 * inv_zoom)
|
||||
draw_line(Vector2(-radius, 0.0), Vector2(radius, 0.0), halo, 3.0 * inv_zoom)
|
||||
draw_line(Vector2(0.0, -radius), Vector2(0.0, radius), halo, 3.0 * inv_zoom)
|
||||
draw_circle(Vector2.ZERO, radius, _origin_marker_color, false, 1.25 * inv_zoom)
|
||||
draw_line(Vector2(-radius, 0.0), Vector2(radius, 0.0), _origin_marker_color, 1.25 * inv_zoom)
|
||||
draw_line(Vector2(0.0, -radius), Vector2(0.0, radius), _origin_marker_color, 1.25 * inv_zoom)
|
||||
|
||||
|
||||
func _draw_shape(sd: Dictionary, is_selected: bool, dragging_vertex: int) -> void:
|
||||
@@ -737,6 +814,12 @@ func _on_canvas_gui_input(event: InputEvent) -> void:
|
||||
return
|
||||
|
||||
var world_pos := _screen_to_world(mb.position)
|
||||
if _edit_mode == EditMode.ATTACH:
|
||||
if mb.button_index == MOUSE_BUTTON_LEFT:
|
||||
if mb.pressed: _anchor_overlay.begin_pointer(world_pos, _zoom)
|
||||
else: _anchor_overlay.end_pointer()
|
||||
return
|
||||
if mb.button_index == MOUSE_BUTTON_RIGHT: return
|
||||
if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed:
|
||||
_handle_right_click(mb, world_pos)
|
||||
return
|
||||
@@ -753,6 +836,9 @@ func _on_canvas_gui_input(event: InputEvent) -> void:
|
||||
_pan_start = mm.position
|
||||
queue_redraw()
|
||||
return
|
||||
if _edit_mode == EditMode.ATTACH:
|
||||
_anchor_overlay.update_pointer(_screen_to_world(mm.position), _zoom, _snap_enabled, _grid_size, mm.ctrl_pressed)
|
||||
return
|
||||
_handle_drag_motion(mm, _screen_to_world(mm.position), mm.ctrl_pressed)
|
||||
|
||||
|
||||
@@ -1203,6 +1289,13 @@ func _shape_bbox(sd: Dictionary) -> Rect2:
|
||||
bbox = bbox.expand(pt)
|
||||
return bbox
|
||||
|
||||
func _shape_has_geometry(sd: Dictionary) -> bool:
|
||||
var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
|
||||
if pts.size() < 2: return false
|
||||
for point: Vector2 in pts:
|
||||
if not is_finite(point.x) or not is_finite(point.y): return false
|
||||
return true
|
||||
|
||||
|
||||
func _hit_test_vertex(shape_idx: int, world_pos: Vector2) -> int:
|
||||
if shape_idx < 0 or shape_idx >= shapes.size():
|
||||
|
||||
+420
-32
@@ -16,20 +16,25 @@ const PropBlockScript := preload("res://scripts/prop_block.gd")
|
||||
const PropCanvasScript := preload("res://scripts/prop_canvas.gd")
|
||||
const PropThumbnailScript := preload("res://scripts/thumbnails/prop_thumbnail.gd")
|
||||
const ThumbnailCacheScript := preload("res://scripts/thumbnails/thumbnail_cache.gd")
|
||||
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
|
||||
const PropAttachPreviewScene := preload("res://scenes/prop_attach_preview.tscn")
|
||||
|
||||
const PRESET_LABELS: PackedStringArray = ["None", "Wood", "Rubber", "Cardboard", "Metal"]
|
||||
const PRIMITIVE_IDS: PackedStringArray = ["box", "plank", "triangle", "line", "circle"]
|
||||
const PRIMITIVE_LABELS: PackedStringArray = ["Box", "Plank", "Triangle", "Line", "Circle"]
|
||||
const EDITABLE_ROLE_STRINGS: PackedStringArray = ["grip", "emitter", "impact", "mount", "custom"]
|
||||
|
||||
# Kid-facing mode labels/emoji (Round 3); index == PropCanvas.EditMode.
|
||||
const MODE_LABELS: PackedStringArray = ["Arrange", "Reshape"]
|
||||
const MODE_EMOJI: PackedStringArray = ["🖐️", "✏️"]
|
||||
const MODE_LABELS: PackedStringArray = ["Arrange", "Reshape", "Attach"]
|
||||
const MODE_EMOJI: PackedStringArray = ["🖐️", "✏️", "📌"]
|
||||
|
||||
const UNTITLED_NAME: String = "Untitled"
|
||||
const AUTOSAVE_PATH: String = "user://prop_editor_autosave.prop"
|
||||
const AUTOSAVE_DEBOUNCE: float = 1.0
|
||||
const SETTINGS_PATH: String = "user://prop_editor_settings.json"
|
||||
const RECENT_MAX: int = 5
|
||||
const OFF_ORIGIN_WARNING_MIN_DISTANCE: float = 1024.0
|
||||
const OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER: float = 2.0
|
||||
|
||||
const MENU_NEW := 0
|
||||
const MENU_LOAD := 2
|
||||
@@ -67,6 +72,11 @@ static func update_recent_list(list: Array, path: String, max_count: int = RECEN
|
||||
result.resize(max_count)
|
||||
return result
|
||||
|
||||
static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool:
|
||||
if not has_geometry: return false
|
||||
var threshold: float = maxf(OFF_ORIGIN_WARNING_MIN_DISTANCE, OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER * bounds.size.length())
|
||||
return bounds.get_center().length() > threshold
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# On-ready node references
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -76,6 +86,11 @@ static func update_recent_list(list: Array, path: String, max_count: int = RECEN
|
||||
@onready var _prop_name_edit: LineEdit = %PropNameEdit
|
||||
@onready var _canvas: PropCanvas = %PropCanvas
|
||||
@onready var _mode_bar: HBoxContainer = %ModeBar
|
||||
@onready var _attach_tools: FlowContainer = %AttachTools
|
||||
@onready var _canvas_workspace: HSplitContainer = %CanvasWorkspace
|
||||
@onready var _main_layout: HSplitContainer = %MainLayout
|
||||
@onready var _canvas_panel: PanelContainer = %CanvasPanel
|
||||
@onready var _inspector_column: PanelContainer = %InspectorColumn
|
||||
@onready var _inspector_list: VBoxContainer = %InspectorList
|
||||
@onready var _save_dialog: FileDialog = %SaveDialog
|
||||
@onready var _load_dialog: FileDialog = %LoadDialog
|
||||
@@ -92,12 +107,15 @@ var _material_preset: int = PropBlockScript.MaterialPreset.NONE
|
||||
var _current_path: String = ""
|
||||
var _dirty: bool = false
|
||||
var _clipboard: Dictionary = {}
|
||||
var _anchors: Dictionary = PropAnchorDataScript.default_anchors()
|
||||
var _selected_anchor_id: String = ""
|
||||
var _thumb: PropThumbnail
|
||||
var _thumb_cache: ThumbnailCache
|
||||
var _ui_theme: Theme
|
||||
|
||||
# Inspector controls
|
||||
var _preset_option: OptionButton
|
||||
var _material_section: VBoxContainer
|
||||
var _mass_label: Label
|
||||
var _friction_label: Label
|
||||
var _bounce_label: Label
|
||||
@@ -119,19 +137,42 @@ var _updating_inspector: bool = false
|
||||
# Grid settings dialog (removed Round 4 - replaced by top-right controls)
|
||||
var _grid_spin: SpinBox
|
||||
var _snap_check: CheckBox
|
||||
var _center_prop_button: Button
|
||||
|
||||
# Edit-mode toolbar (Round 2/3)
|
||||
var _mode_group: ButtonGroup
|
||||
var _move_button: Button
|
||||
var _points_button: Button
|
||||
var _attach_button: Button
|
||||
|
||||
# Add-shape inspector section (hidden in Reshape mode, Round 3)
|
||||
var _add_section: VBoxContainer
|
||||
var _anchor_section: VBoxContainer
|
||||
var _anchor_list: ItemList
|
||||
var _anchor_role: OptionButton
|
||||
var _anchor_hand: OptionButton
|
||||
var _anchor_x: SpinBox
|
||||
var _anchor_y: SpinBox
|
||||
var _anchor_rotation: SpinBox
|
||||
var _anchor_z: SpinBox
|
||||
var _rename_anchor_button: Button
|
||||
var _delete_anchor_button: Button
|
||||
var _attach_preview: Control
|
||||
var _show_preview_check: CheckBox
|
||||
var _inactive_anchors_check: CheckBox
|
||||
var _rename_anchor_dialog: ConfirmationDialog
|
||||
var _rename_anchor_edit: LineEdit
|
||||
var _rename_anchor_source_id: String = ""
|
||||
var _last_canvas_mode: int = PropCanvasScript.EditMode.TRANSFORM
|
||||
var _inspector_split_initialized: bool = false
|
||||
|
||||
# Exit/new dialog + autosave (Round 3/4)
|
||||
var _exit_dialog: ConfirmationDialog
|
||||
var _off_origin_save_dialog: ConfirmationDialog
|
||||
var _pending_quit: bool = false
|
||||
var _pending_new: bool = false
|
||||
var _pending_save_path: String = ""
|
||||
var _pending_save_with_thumbnail: bool = true
|
||||
var _autosave_remaining: float = 0.0
|
||||
|
||||
# Recent props (Round 5)
|
||||
@@ -156,6 +197,12 @@ func _ready() -> void:
|
||||
_build_mode_toolbar()
|
||||
_build_top_right_controls()
|
||||
_build_inspector()
|
||||
_build_attach_tools()
|
||||
_attach_preview = PropAttachPreviewScene.instantiate() as Control
|
||||
_canvas_workspace.add_child(_attach_preview)
|
||||
_apply_control_theme(_attach_preview)
|
||||
_attach_preview.call("set_ui_hit_target_height", float(_theme.button_min_height()))
|
||||
_attach_preview.visible = false
|
||||
_setup_dialogs()
|
||||
|
||||
_canvas.shape_changed.connect(_on_canvas_shape_changed)
|
||||
@@ -163,10 +210,16 @@ func _ready() -> void:
|
||||
_canvas.shape_copy_requested.connect(_on_canvas_copy_requested)
|
||||
_canvas.paste_from_context_requested.connect(_on_canvas_paste_requested)
|
||||
_canvas.mode_changed.connect(_on_canvas_mode_changed)
|
||||
_canvas.anchors_changed.connect(_on_canvas_anchors_changed)
|
||||
_canvas.anchor_selected.connect(_on_canvas_anchor_selected)
|
||||
_canvas.set_grid_size(_theme.grid_size())
|
||||
_canvas.set_snap_enabled(false)
|
||||
_canvas.set_has_clipboard(false)
|
||||
_canvas.set_context_menu_font_size(_theme.font_context_menu())
|
||||
_canvas.set_anchor_data(_anchors)
|
||||
var origin_marker_color: Color = _theme.text_muted_color()
|
||||
origin_marker_color.a = 0.75
|
||||
_canvas.set_origin_marker_color(origin_marker_color)
|
||||
|
||||
_prop_name_edit.text_changed.connect(_on_name_changed)
|
||||
_prop_name_edit.add_theme_font_size_override("font_size", _theme.font_prop_name())
|
||||
@@ -180,6 +233,9 @@ func _ready() -> void:
|
||||
_sync_mode_controls(_canvas.get_edit_mode())
|
||||
_update_save_status()
|
||||
_update_cursor_coords()
|
||||
_canvas.call_deferred("reset_view")
|
||||
call_deferred("_initialize_inspector_split")
|
||||
_refresh_center_prop_button()
|
||||
_check_autosave_recovery()
|
||||
|
||||
|
||||
@@ -233,10 +289,13 @@ func _build_mode_toolbar() -> void:
|
||||
_mode_group = ButtonGroup.new()
|
||||
_move_button = _make_mode_button(PropCanvasScript.EditMode.TRANSFORM)
|
||||
_points_button = _make_mode_button(PropCanvasScript.EditMode.POINTS)
|
||||
_attach_button = _make_mode_button(PropCanvasScript.EditMode.ATTACH)
|
||||
_move_button.pressed.connect(_on_move_mode_pressed)
|
||||
_points_button.pressed.connect(_on_points_mode_pressed)
|
||||
_attach_button.pressed.connect(_on_attach_mode_pressed)
|
||||
_move_button.theme = _ui_theme
|
||||
_points_button.theme = _ui_theme
|
||||
_attach_button.theme = _ui_theme
|
||||
|
||||
|
||||
func _make_mode_button(mode: int) -> Button:
|
||||
@@ -261,27 +320,62 @@ func _on_move_mode_pressed() -> void:
|
||||
func _on_points_mode_pressed() -> void:
|
||||
_canvas.set_edit_mode(PropCanvasScript.EditMode.POINTS)
|
||||
|
||||
func _on_attach_mode_pressed() -> void:
|
||||
_canvas.set_edit_mode(PropCanvasScript.EditMode.ATTACH)
|
||||
|
||||
|
||||
func _on_canvas_mode_changed(mode: int) -> void:
|
||||
var entering_attach: bool = mode == PropCanvasScript.EditMode.ATTACH and _last_canvas_mode != PropCanvasScript.EditMode.ATTACH
|
||||
if entering_attach and _show_preview_check != null:
|
||||
_show_preview_check.set_pressed_no_signal(true)
|
||||
_last_canvas_mode = mode
|
||||
_sync_mode_buttons(mode)
|
||||
_sync_mode_controls(mode)
|
||||
|
||||
|
||||
func _sync_mode_buttons(mode: int) -> void:
|
||||
if _move_button == null or _points_button == null:
|
||||
if _move_button == null or _points_button == null or _attach_button == null:
|
||||
return
|
||||
_move_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.TRANSFORM)
|
||||
_points_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.POINTS)
|
||||
_attach_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.ATTACH)
|
||||
var accent: Color = _theme.accent_color()
|
||||
_move_button.add_theme_color_override("font_pressed_color", accent)
|
||||
_points_button.add_theme_color_override("font_pressed_color", accent)
|
||||
_attach_button.add_theme_color_override("font_pressed_color", accent)
|
||||
|
||||
|
||||
func _sync_mode_controls(mode: int) -> void:
|
||||
if _attach_tools != null:
|
||||
_attach_tools.visible = mode == PropCanvasScript.EditMode.ATTACH
|
||||
if _add_section != null:
|
||||
_add_section.visible = mode != PropCanvasScript.EditMode.POINTS
|
||||
_add_section.visible = mode == PropCanvasScript.EditMode.TRANSFORM
|
||||
if _delete_button != null:
|
||||
_delete_button.visible = mode != PropCanvasScript.EditMode.POINTS
|
||||
_delete_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
|
||||
if _shape_section != null:
|
||||
_shape_section.visible = mode != PropCanvasScript.EditMode.ATTACH and _canvas.get_selected_index() >= 0
|
||||
if _material_section != null:
|
||||
_material_section.visible = mode != PropCanvasScript.EditMode.ATTACH
|
||||
if _anchor_section != null:
|
||||
_anchor_section.visible = mode == PropCanvasScript.EditMode.ATTACH
|
||||
if _attach_preview != null:
|
||||
_attach_preview.visible = mode == PropCanvasScript.EditMode.ATTACH and (_show_preview_check == null or _show_preview_check.button_pressed)
|
||||
if _attach_preview.has_method("set_document"):
|
||||
_attach_preview.call("set_document", _canvas.get_shape_data(), _anchors, _selected_anchor_id)
|
||||
|
||||
func _build_attach_tools() -> void:
|
||||
_show_preview_check = CheckBox.new()
|
||||
_show_preview_check.text = "Show Stickman Preview"
|
||||
_show_preview_check.button_pressed = true
|
||||
_show_preview_check.toggled.connect(func(_enabled: bool) -> void: _sync_mode_controls(_canvas.get_edit_mode()))
|
||||
_attach_tools.add_child(_show_preview_check)
|
||||
_inactive_anchors_check = CheckBox.new()
|
||||
_inactive_anchors_check.text = "Show Other Markers"
|
||||
_inactive_anchors_check.button_pressed = true
|
||||
_inactive_anchors_check.toggled.connect(func(enabled: bool) -> void:
|
||||
_canvas.set_show_inactive_anchors(enabled)
|
||||
_canvas.set_show_origin_marker(enabled))
|
||||
_attach_tools.add_child(_inactive_anchors_check)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -322,6 +416,18 @@ func _build_control_theme() -> void:
|
||||
_ui_theme.set_stylebox("hover", "Button", panel_dark)
|
||||
_ui_theme.set_stylebox("pressed", "Button", panel_pressed)
|
||||
_ui_theme.set_stylebox("focus", "Button", panel)
|
||||
var inspector_surface := StyleBoxFlat.new()
|
||||
inspector_surface.bg_color = _theme.inspector_background_color()
|
||||
inspector_surface.border_color = _theme.border_color()
|
||||
inspector_surface.set_border_width_all(1)
|
||||
inspector_surface.content_margin_left = 8
|
||||
inspector_surface.content_margin_right = 8
|
||||
_inspector_column.add_theme_stylebox_override("panel", inspector_surface)
|
||||
var splitter := StyleBoxFlat.new()
|
||||
splitter.bg_color = _theme.border_color().lightened(0.35)
|
||||
splitter.content_margin_left = 3
|
||||
splitter.content_margin_right = 3
|
||||
_canvas_workspace.add_theme_stylebox_override("grabber", splitter)
|
||||
_ui_theme.set_stylebox("normal", "OptionButton", panel)
|
||||
_ui_theme.set_stylebox("hover", "OptionButton", panel_dark)
|
||||
_ui_theme.set_stylebox("pressed", "OptionButton", panel_dark)
|
||||
@@ -457,6 +563,35 @@ func _setup_dialogs() -> void:
|
||||
_save_dialog.file_selected.connect(_on_save_file_selected)
|
||||
_load_dialog.file_selected.connect(_on_load_file_selected)
|
||||
_build_exit_dialog()
|
||||
_rename_anchor_dialog = ConfirmationDialog.new()
|
||||
_rename_anchor_dialog.title = "Rename Anchor"
|
||||
_rename_anchor_dialog.ok_button_text = "Rename"
|
||||
_rename_anchor_dialog.theme = _ui_theme
|
||||
_rename_anchor_edit = LineEdit.new()
|
||||
_rename_anchor_edit.placeholder_text = "Anchor name"
|
||||
_rename_anchor_dialog.add_child(_rename_anchor_edit)
|
||||
add_child(_rename_anchor_dialog)
|
||||
_rename_anchor_edit.text_changed.connect(_update_rename_anchor_confirmation)
|
||||
_rename_anchor_edit.text_submitted.connect(_on_rename_anchor_text_submitted)
|
||||
_rename_anchor_dialog.confirmed.connect(_commit_anchor_rename)
|
||||
_rename_anchor_dialog.canceled.connect(_clear_pending_anchor_rename)
|
||||
|
||||
func _on_rename_anchor_text_submitted(_text: String) -> void:
|
||||
if _rename_anchor_dialog == null or not _rename_anchor_dialog.visible:
|
||||
return
|
||||
var ok_button := _rename_anchor_dialog.get_ok_button()
|
||||
if not ok_button.disabled:
|
||||
ok_button.pressed.emit()
|
||||
|
||||
func _initialize_inspector_split() -> void:
|
||||
if _inspector_split_initialized or _main_layout == null:
|
||||
return
|
||||
var inspector_minimum: float = _inspector_column.get_combined_minimum_size().x
|
||||
if _main_layout.size.x <= inspector_minimum:
|
||||
call_deferred("_initialize_inspector_split")
|
||||
return
|
||||
_main_layout.split_offset = maxi(0, int(_main_layout.size.x - inspector_minimum - _main_layout.get_theme_constant("separation")))
|
||||
_inspector_split_initialized = true
|
||||
|
||||
|
||||
func _build_top_right_controls() -> void:
|
||||
@@ -470,13 +605,22 @@ func _build_top_right_controls() -> void:
|
||||
_grid_spin.max_value = 512
|
||||
_grid_spin.value = _theme.grid_size()
|
||||
_grid_spin.custom_minimum_size.x = 90.0
|
||||
_grid_spin.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_grid_spin.value_changed.connect(_on_top_grid_changed)
|
||||
_top_right.add_child(_grid_spin)
|
||||
_snap_check = CheckBox.new()
|
||||
_snap_check.text = "Snap"
|
||||
_snap_check.button_pressed = false
|
||||
_snap_check.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_snap_check.toggled.connect(_on_top_snap_toggled)
|
||||
_top_right.add_child(_snap_check)
|
||||
_center_prop_button = Button.new()
|
||||
_center_prop_button.text = "🎯 Center Prop"
|
||||
_center_prop_button.tooltip_text = "Move the whole prop and its anchors to (0,0)."
|
||||
_center_prop_button.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_center_prop_button.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_center_prop_button.pressed.connect(func() -> void: _center_prop_on_origin())
|
||||
_top_right.add_child(_center_prop_button)
|
||||
|
||||
|
||||
func _on_top_grid_changed(value: float) -> void:
|
||||
@@ -499,6 +643,18 @@ func _build_exit_dialog() -> void:
|
||||
_exit_dialog.confirmed.connect(_on_exit_save)
|
||||
_exit_dialog.custom_action.connect(_on_exit_custom_action)
|
||||
_exit_dialog.canceled.connect(_on_exit_cancel)
|
||||
_off_origin_save_dialog = ConfirmationDialog.new()
|
||||
_off_origin_save_dialog.title = "🎯 Center Prop?"
|
||||
_off_origin_save_dialog.dialog_text = "This prop is far from (0,0). Centering it keeps its shapes and anchors together.\n\nWhat would you like to do?"
|
||||
_off_origin_save_dialog.ok_button_text = "🎯 Center & Save"
|
||||
_off_origin_save_dialog.add_button("💾 Save Anyway", false, "save_anyway")
|
||||
_off_origin_save_dialog.get_cancel_button().text = "❌ Cancel"
|
||||
_off_origin_save_dialog.theme = _ui_theme
|
||||
add_child(_off_origin_save_dialog)
|
||||
_off_origin_save_dialog.confirmed.connect(func() -> void: await _execute_pending_save(true))
|
||||
_off_origin_save_dialog.custom_action.connect(func(action: StringName) -> void:
|
||||
if action == &"save_anyway": await _execute_pending_save(false))
|
||||
_off_origin_save_dialog.canceled.connect(_cancel_pending_save_request)
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
@@ -531,19 +687,7 @@ func _on_exit_save() -> void:
|
||||
if _current_path.is_empty():
|
||||
_save_dialog.popup_centered()
|
||||
return
|
||||
await _save_and_finish(_current_path)
|
||||
|
||||
|
||||
func _save_and_finish(path: String) -> void:
|
||||
var ok: bool = await _save_to(path, false)
|
||||
if not ok:
|
||||
return
|
||||
if _pending_quit:
|
||||
_pending_quit = false
|
||||
get_tree().quit()
|
||||
elif _pending_new:
|
||||
_pending_new = false
|
||||
_do_new()
|
||||
_begin_save_request(_current_path, false)
|
||||
|
||||
|
||||
func _on_exit_custom_action(action: StringName) -> void:
|
||||
@@ -561,20 +705,27 @@ func _on_exit_custom_action(action: StringName) -> void:
|
||||
func _on_exit_cancel() -> void:
|
||||
_pending_quit = false
|
||||
_pending_new = false
|
||||
_cancel_pending_save_request()
|
||||
|
||||
|
||||
func _do_new() -> void:
|
||||
_clear_pending_anchor_rename()
|
||||
_prop_name_edit.text = ""
|
||||
_material_preset = PropBlockScript.MaterialPreset.NONE
|
||||
_preset_option.selected = _material_preset
|
||||
_update_physics_labels()
|
||||
_canvas.clear()
|
||||
_anchors = PropAnchorDataScript.default_anchors()
|
||||
_selected_anchor_id = ""
|
||||
_canvas.set_anchor_data(_anchors)
|
||||
_clipboard = {}
|
||||
_canvas.set_has_clipboard(false)
|
||||
_current_path = ""
|
||||
_dirty = false
|
||||
_clear_autosave()
|
||||
_refresh_inspector_from_selection()
|
||||
_refresh_anchor_inspector()
|
||||
_refresh_center_prop_button()
|
||||
_update_save_status()
|
||||
|
||||
|
||||
@@ -588,7 +739,7 @@ func _on_menu_id_pressed(id: int) -> void:
|
||||
if _current_path.is_empty():
|
||||
_save_dialog.popup_centered()
|
||||
else:
|
||||
await _save_to(_current_path)
|
||||
_begin_save_request(_current_path, true)
|
||||
MENU_SAVE_AS:
|
||||
_save_dialog.popup_centered()
|
||||
MENU_RESET_VIEWS:
|
||||
@@ -604,10 +755,13 @@ func _build_inspector() -> void:
|
||||
var row_height: int = _theme.row_min_height()
|
||||
|
||||
# Material preset section
|
||||
_material_section = VBoxContainer.new()
|
||||
_material_section.add_theme_constant_override("separation", 6)
|
||||
_inspector_list.add_child(_material_section)
|
||||
var material_title := Label.new()
|
||||
material_title.text = "Material"
|
||||
material_title.add_theme_font_size_override("font_size", header_size)
|
||||
_inspector_list.add_child(material_title)
|
||||
_material_section.add_child(material_title)
|
||||
|
||||
_preset_option = OptionButton.new()
|
||||
for label: String in PRESET_LABELS:
|
||||
@@ -615,14 +769,14 @@ func _build_inspector() -> void:
|
||||
_preset_option.selected = _material_preset
|
||||
_preset_option.custom_minimum_size.y = float(_theme.button_min_height())
|
||||
_preset_option.item_selected.connect(_on_preset_selected)
|
||||
_inspector_list.add_child(_preset_option)
|
||||
_material_section.add_child(_preset_option)
|
||||
|
||||
_mass_label = Label.new()
|
||||
_friction_label = Label.new()
|
||||
_bounce_label = Label.new()
|
||||
_inspector_list.add_child(_mass_label)
|
||||
_inspector_list.add_child(_friction_label)
|
||||
_inspector_list.add_child(_bounce_label)
|
||||
_material_section.add_child(_mass_label)
|
||||
_material_section.add_child(_friction_label)
|
||||
_material_section.add_child(_bounce_label)
|
||||
_update_physics_labels()
|
||||
|
||||
# Selected-shape section
|
||||
@@ -736,6 +890,160 @@ func _build_inspector() -> void:
|
||||
|
||||
_apply_control_theme(_inspector_list)
|
||||
_rebuild_primitive_params()
|
||||
_build_anchor_inspector()
|
||||
|
||||
func _build_anchor_inspector() -> void:
|
||||
_anchor_section = VBoxContainer.new()
|
||||
_anchor_section.add_theme_constant_override("separation", 6)
|
||||
_inspector_list.add_child(_anchor_section)
|
||||
var title := Label.new(); title.text = "Anchors"; title.add_theme_font_size_override("font_size", _theme.font_header()); _anchor_section.add_child(title)
|
||||
_anchor_list = ItemList.new(); _anchor_list.custom_minimum_size.y = 140.0; _anchor_list.item_selected.connect(func(index: int) -> void: _select_anchor(_anchor_list.get_item_text(index))); _anchor_section.add_child(_anchor_list)
|
||||
var actions := HBoxContainer.new(); _anchor_section.add_child(actions)
|
||||
for label: String in ["Add Anchor", "Rename", "Delete"]:
|
||||
var button := Button.new(); button.text = label; button.custom_minimum_size.y = float(_theme.button_min_height()); actions.add_child(button)
|
||||
if label == "Add Anchor": button.pressed.connect(_on_add_anchor_pressed)
|
||||
elif label == "Rename":
|
||||
_rename_anchor_button = button
|
||||
button.pressed.connect(_on_rename_anchor_pressed)
|
||||
else:
|
||||
_delete_anchor_button = button
|
||||
button.pressed.connect(_on_delete_anchor_pressed)
|
||||
_anchor_role = _anchor_option("Role", EDITABLE_ROLE_STRINGS, _on_anchor_role_selected)
|
||||
_anchor_hand = _anchor_option("Allowed Hands", PropAnchorDataScript.ALLOWED_HAND_STRINGS, _on_anchor_hand_selected)
|
||||
_anchor_x = _anchor_spin("X", -100000.0, 100000.0, _on_anchor_x_changed)
|
||||
_anchor_y = _anchor_spin("Y", -100000.0, 100000.0, _on_anchor_y_changed)
|
||||
_anchor_rotation = _anchor_spin("Rotation", -180.0, 179.0, _on_anchor_rotation_changed)
|
||||
_anchor_z = _anchor_spin("Z Offset", -5.0, 5.0, _on_anchor_z_changed)
|
||||
_anchor_z.step = 1.0
|
||||
var presets := FlowContainer.new(); _anchor_section.add_child(presets)
|
||||
for entry: Dictionary in [{"text":"One-Handed","id":"one_handed"}, {"text":"Two-Handed","id":"two_handed"}, {"text":"Tool / Nozzle","id":"tool_nozzle"}, {"text":"Melee Edge","id":"melee_edge"}]:
|
||||
var button := Button.new(); button.text = str(entry["text"]); button.pressed.connect(func() -> void: _apply_anchor_preset(str(entry["id"]))); presets.add_child(button)
|
||||
_refresh_anchor_inspector()
|
||||
|
||||
func _anchor_option(label_text: String, entries: PackedStringArray, callback: Callable) -> OptionButton:
|
||||
var row := HBoxContainer.new()
|
||||
_anchor_section.add_child(row)
|
||||
var label := Label.new()
|
||||
label.text = label_text + ":"
|
||||
label.custom_minimum_size.x = 84.0
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
row.add_child(label)
|
||||
var option := OptionButton.new()
|
||||
option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
for entry: String in entries:
|
||||
option.add_item(entry)
|
||||
option.item_selected.connect(callback)
|
||||
row.add_child(option)
|
||||
return option
|
||||
|
||||
func _anchor_spin(label_text: String, minimum: float, maximum: float, callback: Callable) -> SpinBox:
|
||||
var row := HBoxContainer.new()
|
||||
_anchor_section.add_child(row)
|
||||
var label := Label.new()
|
||||
label.text = label_text + ":"
|
||||
label.custom_minimum_size.x = 84.0
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
row.add_child(label)
|
||||
var spin := SpinBox.new()
|
||||
spin.min_value = minimum
|
||||
spin.max_value = maximum
|
||||
spin.step = 1.0
|
||||
spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
spin.value_changed.connect(callback)
|
||||
row.add_child(spin)
|
||||
return spin
|
||||
|
||||
func _refresh_anchor_inspector() -> void:
|
||||
if _anchor_list == null: return
|
||||
_updating_inspector = true; _anchor_list.clear()
|
||||
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _selected_anchor_id)
|
||||
for id: String in PropAnchorDataScript.editable_ids(_anchors): _anchor_list.add_item(id); if id == _selected_anchor_id: _anchor_list.select(_anchor_list.item_count - 1)
|
||||
var anchor: Dictionary = _anchors.get(_selected_anchor_id, {})
|
||||
var editable: bool = not anchor.is_empty()
|
||||
if not anchor.is_empty():
|
||||
var role_index: int = EDITABLE_ROLE_STRINGS.find(str(anchor.get("role", "custom")))
|
||||
_anchor_role.select(role_index if role_index >= 0 else EDITABLE_ROLE_STRINGS.find("custom"))
|
||||
_anchor_hand.select(PropAnchorDataScript.ALLOWED_HAND_STRINGS.find(str(anchor.get("allowed_hands", "both"))))
|
||||
var pos: Vector2 = anchor.get("position", Vector2.ZERO); _anchor_x.value = pos.x; _anchor_y.value = pos.y; _anchor_rotation.value = float(anchor.get("rotation_deg", 0.0)); _anchor_z.value = int(anchor.get("z_index_offset", 0))
|
||||
# OptionButton and Button inherit BaseButton and use `disabled`; SpinBox is a
|
||||
# Range control and gates editing through `editable` in Godot 4.
|
||||
for option: OptionButton in [_anchor_role, _anchor_hand]:
|
||||
option.disabled = not editable
|
||||
option.tooltip_text = "Add or select an anchor." if not editable else ""
|
||||
for spin: SpinBox in [_anchor_x, _anchor_y, _anchor_rotation, _anchor_z]:
|
||||
spin.editable = editable
|
||||
spin.tooltip_text = "Add or select an anchor." if not editable else ""
|
||||
if _rename_anchor_button != null:
|
||||
_rename_anchor_button.disabled = not editable
|
||||
if _delete_anchor_button != null:
|
||||
_delete_anchor_button.disabled = not editable
|
||||
_updating_inspector = false
|
||||
|
||||
func _select_anchor(id: String) -> void:
|
||||
if id != PropAnchorDataScript.ORIGIN_ID and _anchors.has(id): _selected_anchor_id = id; _canvas.select_anchor(id); _refresh_anchor_inspector(); _sync_attach_preview()
|
||||
func _on_add_anchor_pressed() -> void:
|
||||
var id: String = PropAnchorDataScript.normalize_id("anchor", _anchors); var bounds: Rect2 = _canvas.get_shapes_bounds(); _anchors[id] = {"role":"custom", "position": bounds.get_center() if bounds.size != Vector2.ZERO else Vector2.ZERO, "rotation_deg":0.0, "z_index_offset":0, "allowed_hands":"both"}; _selected_anchor_id = id; _anchors = PropAnchorDataScript.normalize_anchors(_anchors); _canvas.set_anchor_data(_anchors); _mark_dirty(); _refresh_anchor_inspector()
|
||||
func _on_rename_anchor_pressed() -> void:
|
||||
if _selected_anchor_id.is_empty() or not _anchors.has(_selected_anchor_id) or _rename_anchor_dialog == null:
|
||||
return
|
||||
_rename_anchor_source_id = _selected_anchor_id
|
||||
_rename_anchor_edit.text = _rename_anchor_source_id
|
||||
_update_rename_anchor_confirmation(_rename_anchor_edit.text)
|
||||
_rename_anchor_dialog.popup_centered(Vector2i(420, 150))
|
||||
_rename_anchor_edit.call_deferred("grab_focus")
|
||||
_rename_anchor_edit.call_deferred("select_all")
|
||||
|
||||
func _update_rename_anchor_confirmation(value: String) -> void:
|
||||
if _rename_anchor_dialog != null:
|
||||
_rename_anchor_dialog.get_ok_button().disabled = value.strip_edges().is_empty()
|
||||
|
||||
func _commit_anchor_rename() -> void:
|
||||
if _rename_anchor_source_id.is_empty() or not _anchors.has(_rename_anchor_source_id):
|
||||
_clear_pending_anchor_rename()
|
||||
return
|
||||
var remaining: Dictionary = _anchors.duplicate(true)
|
||||
var payload: Dictionary = remaining[_rename_anchor_source_id].duplicate(true)
|
||||
remaining.erase(_rename_anchor_source_id)
|
||||
var new_id: String = PropAnchorDataScript.normalize_id(_rename_anchor_edit.text, remaining)
|
||||
var old_id: String = _rename_anchor_source_id
|
||||
_clear_pending_anchor_rename()
|
||||
if new_id == old_id:
|
||||
return
|
||||
remaining[new_id] = payload
|
||||
_anchors = PropAnchorDataScript.normalize_anchors(remaining)
|
||||
_selected_anchor_id = new_id
|
||||
_canvas.set_anchor_data(_anchors)
|
||||
_canvas.select_anchor(new_id)
|
||||
_mark_dirty()
|
||||
_refresh_anchor_inspector()
|
||||
_sync_attach_preview()
|
||||
|
||||
func _clear_pending_anchor_rename() -> void:
|
||||
_rename_anchor_source_id = ""
|
||||
func _on_delete_anchor_pressed() -> void:
|
||||
if _selected_anchor_id.is_empty(): return
|
||||
var ids: Array[String] = PropAnchorDataScript.editable_ids(_anchors)
|
||||
var old_index: int = ids.find(_selected_anchor_id)
|
||||
_anchors.erase(_selected_anchor_id)
|
||||
ids = PropAnchorDataScript.editable_ids(_anchors)
|
||||
_selected_anchor_id = ids[clampi(old_index - 1, 0, ids.size() - 1)] if not ids.is_empty() else ""
|
||||
_canvas.set_anchor_data(_anchors); _canvas.select_anchor(_selected_anchor_id); _mark_dirty(); _refresh_anchor_inspector(); _sync_attach_preview()
|
||||
func _on_anchor_role_selected(index: int) -> void:
|
||||
if index >= 0 and index < EDITABLE_ROLE_STRINGS.size():
|
||||
_set_anchor_field("role", EDITABLE_ROLE_STRINGS[index])
|
||||
func _on_anchor_hand_selected(index: int) -> void: _set_anchor_field("allowed_hands", PropAnchorDataScript.ALLOWED_HAND_STRINGS[index])
|
||||
func _on_anchor_x_changed(value: float) -> void:
|
||||
if not _updating_inspector: _set_anchor_field("position", Vector2(value, _anchor_y.value))
|
||||
func _on_anchor_y_changed(value: float) -> void:
|
||||
if not _updating_inspector: _set_anchor_field("position", Vector2(_anchor_x.value, value))
|
||||
func _on_anchor_rotation_changed(value: float) -> void:
|
||||
if not _updating_inspector: _set_anchor_field("rotation_deg", value)
|
||||
func _on_anchor_z_changed(value: float) -> void:
|
||||
if not _updating_inspector: _set_anchor_field("z_index_offset", int(value))
|
||||
func _set_anchor_field(field: String, value: Variant) -> void:
|
||||
if _updating_inspector or _selected_anchor_id.is_empty(): return
|
||||
var anchor: Dictionary = _anchors[_selected_anchor_id].duplicate(true); anchor[field] = value; _anchors[_selected_anchor_id] = anchor; _anchors = PropAnchorDataScript.normalize_anchors(_anchors); _canvas.set_anchor_data(_anchors); _mark_dirty(); _refresh_anchor_inspector(); _sync_attach_preview()
|
||||
func _apply_anchor_preset(preset: String) -> void: _anchors = PropAnchorDataScript.preset_layout(preset, _canvas.get_shapes_bounds()); _selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors); _canvas.set_anchor_data(_anchors); _canvas.select_anchor(_selected_anchor_id); _mark_dirty(); _refresh_anchor_inspector(); _sync_attach_preview()
|
||||
|
||||
|
||||
func _apply_control_theme(node: Node) -> void:
|
||||
@@ -885,6 +1193,50 @@ func _refresh_inspector_from_selection() -> void:
|
||||
func _on_canvas_shape_changed(_shapes: Array[Dictionary]) -> void:
|
||||
_mark_dirty()
|
||||
_refresh_inspector_from_selection()
|
||||
_refresh_anchor_inspector()
|
||||
_refresh_center_prop_button()
|
||||
_sync_attach_preview()
|
||||
|
||||
func _on_canvas_anchors_changed(anchors: Dictionary) -> void:
|
||||
_anchors = PropAnchorDataScript.normalize_anchors(anchors)
|
||||
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _canvas.get_selected_anchor_id())
|
||||
_mark_dirty()
|
||||
_refresh_anchor_inspector()
|
||||
_sync_attach_preview()
|
||||
|
||||
func _on_canvas_anchor_selected(anchor_id: String) -> void:
|
||||
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, anchor_id)
|
||||
_refresh_anchor_inspector()
|
||||
_sync_attach_preview()
|
||||
|
||||
func _sync_attach_preview() -> void:
|
||||
if _attach_preview != null and _attach_preview.has_method("set_document"):
|
||||
_attach_preview.call("set_document", _canvas.get_shape_data(), _anchors, _selected_anchor_id)
|
||||
|
||||
func _refresh_center_prop_button() -> void:
|
||||
if _center_prop_button == null: return
|
||||
var bounds: Rect2 = _canvas.get_shapes_bounds()
|
||||
_center_prop_button.disabled = not _canvas.has_shape_geometry() or bounds.get_center().is_zero_approx()
|
||||
|
||||
func _center_prop_on_origin(focus_view: bool = true) -> bool:
|
||||
if not _canvas.has_shape_geometry(): return false
|
||||
var bounds: Rect2 = _canvas.get_shapes_bounds()
|
||||
var delta: Vector2 = -bounds.get_center()
|
||||
if not is_finite(delta.x) or not is_finite(delta.y): return false
|
||||
if delta.is_zero_approx():
|
||||
if focus_view: _canvas.focus_world_point(Vector2.ZERO)
|
||||
return false
|
||||
_anchors = PropAnchorDataScript.translated_non_origin(_anchors, delta)
|
||||
_canvas.set_anchor_data(_anchors)
|
||||
if not _canvas.translate_all_shapes(delta): return false
|
||||
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _selected_anchor_id)
|
||||
_canvas.select_anchor(_selected_anchor_id)
|
||||
_refresh_inspector_from_selection()
|
||||
_refresh_anchor_inspector()
|
||||
_refresh_center_prop_button()
|
||||
_sync_attach_preview()
|
||||
if focus_view: _canvas.focus_world_point(Vector2.ZERO)
|
||||
return true
|
||||
|
||||
|
||||
func _on_canvas_shape_selected(_idx: int) -> void:
|
||||
@@ -977,10 +1329,11 @@ func _check_autosave_recovery() -> void:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _build_json_data() -> Dictionary:
|
||||
return PropFileScript.to_file_dict(_prop_name_edit.text, _material_preset, _canvas.get_shape_data())
|
||||
return PropFileScript.to_file_dict(_prop_name_edit.text, _material_preset, _canvas.get_shape_data(), _anchors)
|
||||
|
||||
|
||||
func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
|
||||
_clear_pending_anchor_rename()
|
||||
var model: Dictionary = PropFileScript.from_file_dict(json)
|
||||
if model.is_empty():
|
||||
return "The file could not be read: unsupported version or malformed data."
|
||||
@@ -991,9 +1344,16 @@ func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
|
||||
_update_physics_labels()
|
||||
var shapes: Array = model.get("shapes", [])
|
||||
_canvas.set_shape_data(shapes)
|
||||
_anchors = PropAnchorDataScript.normalize_anchors(model.get("anchors", {}))
|
||||
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors)
|
||||
_canvas.set_anchor_data(_anchors)
|
||||
_canvas.select_anchor(_selected_anchor_id)
|
||||
_dirty = is_recovery
|
||||
_autosave_remaining = 0.0
|
||||
_refresh_inspector_from_selection()
|
||||
_refresh_anchor_inspector()
|
||||
_refresh_center_prop_button()
|
||||
_canvas.call_deferred("reset_view")
|
||||
_update_save_status()
|
||||
return ""
|
||||
|
||||
@@ -1025,16 +1385,44 @@ func _save_prop_thumbnail() -> void:
|
||||
_thumb_cache.save_png(tex, _thumb_cache.prop_file_png(_current_path))
|
||||
|
||||
|
||||
func _begin_save_request(path: String, with_thumbnail: bool) -> void:
|
||||
_pending_save_path = path
|
||||
_pending_save_with_thumbnail = with_thumbnail
|
||||
if should_warn_off_origin(_canvas.has_shape_geometry(), _canvas.get_shapes_bounds()):
|
||||
_off_origin_save_dialog.popup_centered()
|
||||
return
|
||||
await _execute_pending_save(false)
|
||||
|
||||
func _execute_pending_save(center_first: bool) -> void:
|
||||
var path: String = _pending_save_path
|
||||
var with_thumbnail: bool = _pending_save_with_thumbnail
|
||||
if path.is_empty():
|
||||
_cancel_pending_save_request()
|
||||
return
|
||||
if center_first: _center_prop_on_origin(true)
|
||||
var ok: bool = await _save_to(path, with_thumbnail)
|
||||
if not ok:
|
||||
_cancel_pending_save_request()
|
||||
return
|
||||
_pending_save_path = ""
|
||||
_pending_save_with_thumbnail = true
|
||||
if _pending_quit:
|
||||
_pending_quit = false
|
||||
get_tree().quit()
|
||||
elif _pending_new:
|
||||
_pending_new = false
|
||||
_do_new()
|
||||
|
||||
func _cancel_pending_save_request() -> void:
|
||||
_pending_save_path = ""
|
||||
_pending_save_with_thumbnail = true
|
||||
_pending_quit = false
|
||||
_pending_new = false
|
||||
|
||||
func _on_save_file_selected(path: String) -> void:
|
||||
if path.get_extension().to_lower() != "prop":
|
||||
path = path.get_basename() + ".prop"
|
||||
var ok: bool = await _save_to(path, not (_pending_quit or _pending_new))
|
||||
if ok and _pending_quit:
|
||||
_pending_quit = false
|
||||
get_tree().quit()
|
||||
elif ok and _pending_new:
|
||||
_pending_new = false
|
||||
_do_new()
|
||||
_begin_save_request(path, not (_pending_quit or _pending_new))
|
||||
|
||||
|
||||
func _load_from(path: String) -> void:
|
||||
|
||||
@@ -9,6 +9,7 @@ extends RefCounted
|
||||
|
||||
const PropBlockScript := preload("res://scripts/prop_block.gd")
|
||||
const TerrainUtilsScript := preload("res://scripts/terrain_utils.gd")
|
||||
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
|
||||
|
||||
const FILE_VERSION: String = "1.0"
|
||||
const FILE_EXT: String = ".prop"
|
||||
@@ -61,7 +62,7 @@ static func sanitize_shape(shape: Dictionary, grid_size: float = TerrainUtilsScr
|
||||
return result
|
||||
|
||||
|
||||
static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary]) -> Dictionary:
|
||||
static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary], anchors: Dictionary = {}) -> Dictionary:
|
||||
var serialized: Array = []
|
||||
for shape: Dictionary in shapes:
|
||||
serialized.append(_serialize_shape(shape))
|
||||
@@ -71,6 +72,7 @@ static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[
|
||||
"prop_name": prop_name,
|
||||
"material_preset": preset_to_string(material_preset),
|
||||
"shapes": serialized,
|
||||
"anchors": PropAnchorDataScript.serialize_anchors(anchors),
|
||||
"metadata": {"created_at": now, "modified_at": now},
|
||||
}
|
||||
|
||||
@@ -105,6 +107,7 @@ static func from_file_dict(data: Variant) -> Dictionary:
|
||||
"prop_name": str(dict.get("prop_name", "")),
|
||||
"material_preset": preset,
|
||||
"shapes": shapes,
|
||||
"anchors": PropAnchorDataScript.normalize_anchors(dict.get("anchors", {})),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ const DEFAULT_CONFIG: Dictionary = {
|
||||
"ui": {
|
||||
"fonts": {"body": 18, "header": 24, "menu": 22, "menu_bar": 22, "mode": 26, "status": 18, "prop_name": 20, "context_menu": 22},
|
||||
"hit_targets": {"button_min_height": 36, "row_min_height": 36, "mode_button_min_height": 56},
|
||||
"colors": {"text": "#F2F2F2", "text_muted": "#9A9A9A", "border": "#FFFFFF33", "accent": "#4FA3FF"},
|
||||
"colors": {"text": "#F2F2F2", "text_muted": "#9A9A9A", "border": "#FFFFFF33", "accent": "#4FA3FF", "inspector_background": "#262C39"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -218,6 +218,10 @@ func accent_color() -> Color:
|
||||
return color("ui.colors.accent", Color("#4FA3FF"))
|
||||
|
||||
|
||||
func inspector_background_color() -> Color:
|
||||
return color("ui.colors.inspector_background", Color("#262C39"))
|
||||
|
||||
|
||||
func invalidate_caches() -> void:
|
||||
_warned.clear()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user