diff --git a/scripts/smoke_test_studio_interaction.gd b/scripts/smoke_test_studio_interaction.gd new file mode 100644 index 0000000..4551bf9 --- /dev/null +++ b/scripts/smoke_test_studio_interaction.gd @@ -0,0 +1,158 @@ +extends SceneTree +## Headless smoke test verifying Studio floor collision, selection, +## gizmo ray picking, inspector property sync, and transformation manipulation. + +var _failures: int = 0 +var _checks: int = 0 + +const StudioMainClass = preload("res://scripts/studio/studio_main.gd") +const TransformGizmo3DClass = preload("res://scripts/studio/gizmos/transform_gizmo_3d.gd") + +func _init() -> void: + print("--- Running Smoke Test: Studio Viewport Interaction & Floor Collision ---") + test_floor_collision_body() + test_inspector_sync_and_visual_transform() + test_gizmo_picking_and_dragging() + test_nudge_shortcuts_and_delete() + + if _failures == 0: + print("--- PASS: All %d studio interaction checks passed! ---" % _checks) + quit(0) + else: + printerr("--- FAIL: %d failures out of %d checks ---" % [_failures, _checks]) + quit(1) + + +func _check(desc: String, cond: bool) -> void: + _checks += 1 + if cond: + print(" ✓ %s" % desc) + else: + _failures += 1 + printerr(" ✗ FAIL: %s" % desc) + + +func test_floor_collision_body() -> void: + print("Testing Studio floor collision setup...") + var studio := StudioMainClass.new() + root.add_child(studio) + + var floor_body = studio._sub_viewport.find_child("StudioFloorBody", true, false) + _check("StudioFloorBody exists in 3D viewport", floor_body != null) + if floor_body is StaticBody3D: + var sb := floor_body as StaticBody3D + _check("Floor body has collision layer 1 enabled", sb.get_collision_layer_value(1)) + _check("Floor body has collision mask 1 enabled", sb.get_collision_mask_value(1)) + var col = sb.find_child("*", true, false) as CollisionShape3D + _check("Floor body has CollisionShape3D", col != null and col.shape is BoxShape3D) + if col != null and col.shape is BoxShape3D: + var box := col.shape as BoxShape3D + _check("Floor collider spans at least 100x100m", box.size.x >= 100.0 and box.size.z >= 100.0) + + studio.queue_free() + + +func test_inspector_sync_and_visual_transform() -> void: + print("Testing Inspector property sync to 3D visual nodes...") + var studio := StudioMainClass.new() + root.add_child(studio) + + # Place an object + var obj_id := studio.core.place_object("crate_small", Vector3(10, 20, 0)) + studio._spawn_visual_node(obj_id) + studio._on_selection_changed(obj_id) + + var visual_node := studio._world_container.get_node_or_null(obj_id) as Node3D + _check("Visual node exists in world container", visual_node != null) + + # Simulate inspector position change (moving object via Inspector) + studio._inspector_dock._pos_x.value = 15.0 + studio._inspector_dock._pos_y.value = 3.0 + studio._inspector_dock._pos_z.value = 25.0 + studio._inspector_dock._on_pos_changed(0.0) + + _check("Visual node position updated to match Inspector X", is_equal_approx(visual_node.position.x, 15.0)) + _check("Visual node position updated to match Inspector Y (height)", is_equal_approx(visual_node.position.y, 3.0)) + _check("Visual node position updated to match Inspector Z (depth)", is_equal_approx(visual_node.position.z, 25.0)) + + # Simulate inspector yaw rotation change + studio._inspector_dock._rot_y.value = 90.0 + studio._inspector_dock._on_rot_changed(90.0) + _check("Visual node rotated to match Inspector Yaw", absf(visual_node.rotation_degrees.y - 90.0) < 0.1) + + # Simulate inspector scale change + studio._inspector_dock._scale_x.value = 2.0 + studio._inspector_dock._scale_y.value = 1.5 + studio._inspector_dock._scale_z.value = 2.0 + studio._inspector_dock._on_scale_changed(0.0) + _check("Visual node scale updated to match Inspector", is_equal_approx(visual_node.scale.x, 2.0) and is_equal_approx(visual_node.scale.y, 1.5)) + + studio.queue_free() + + +func test_gizmo_picking_and_dragging() -> void: + print("Testing Gizmo geometric picking and axis detection...") + var studio := StudioMainClass.new() + root.add_child(studio) + + var obj_id := studio.core.place_object("bison", Vector3(0, 0, 0)) + studio._spawn_visual_node(obj_id) + studio._on_selection_changed(obj_id) + + var gizmo = studio._gizmo_3d + _check("Gizmo is attached and visible on selection", gizmo != null and gizmo.visible) + + # Test X axis picking (ray aimed near +X arrow at (1.0, 0, 0) relative to gizmo) + var ray_origin: Vector3 = gizmo.position + Vector3(1.0, 5.0, 0.0) + var ray_dir: Vector3 = Vector3(0.0, -1.0, 0.0) + var picked_axis = gizmo.pick_axis(ray_origin, ray_dir) + _check("Gizmo ray picking correctly identified X axis", picked_axis == TransformGizmo3DClass.Axis.X) + + # Test Y axis picking (ray aimed near +Y arrow at (0, 1.0, 0) relative to gizmo) + ray_origin = gizmo.position + Vector3(0.0, 1.0, 5.0) + ray_dir = Vector3(0.0, 0.0, -1.0) + picked_axis = gizmo.pick_axis(ray_origin, ray_dir) + _check("Gizmo ray picking correctly identified Y axis", picked_axis == TransformGizmo3DClass.Axis.Y) + + # Test Z axis picking (ray aimed near +Z arrow at (0, 0, 1.0) relative to gizmo) + ray_origin = gizmo.position + Vector3(0.0, 5.0, 1.0) + ray_dir = Vector3(0.0, -1.0, 0.0) + picked_axis = gizmo.pick_axis(ray_origin, ray_dir) + _check("Gizmo ray picking correctly identified Z axis", picked_axis == TransformGizmo3DClass.Axis.Z) + + # Test Center picking (ray aimed at gizmo origin) + ray_origin = gizmo.position + Vector3(0.0, 5.0, 0.0) + ray_dir = Vector3(0.0, -1.0, 0.0) + picked_axis = gizmo.pick_axis(ray_origin, ray_dir) + _check("Gizmo ray picking correctly identified Center/Plane handle", picked_axis == TransformGizmo3DClass.Axis.XY) + + studio.queue_free() + + +func test_nudge_shortcuts_and_delete() -> void: + print("Testing keyboard nudge shortcuts and object deletion...") + var studio := StudioMainClass.new() + root.add_child(studio) + + var obj_id := studio.core.place_object("crate_small", Vector3(0, 0, 0)) + studio._spawn_visual_node(obj_id) + studio.core.select_object(obj_id) + studio._on_selection_changed(obj_id) + + var visual_node := studio._world_container.get_node_or_null(obj_id) as Node3D + + # Nudge position right (+X) + var old_x = visual_node.position.x + studio._nudge_selected_position(Vector3(1, 0, 0)) + _check("Nudge right moved visual node +X by 1.0", is_equal_approx(visual_node.position.x, old_x + 1.0)) + + # Nudge rotation + studio._nudge_selected_rotation(45.0) + _check("Nudge rotation rotated visual node by 45 degrees", is_equal_approx(visual_node.rotation_degrees.y, 45.0)) + + # Delete object + studio._delete_selected_object(obj_id) + _check("Object deleted from Core model", studio.core.model.find_object(obj_id) == null) + _check("Selection cleared after delete", studio.core.selection.get_selected() == "") + + studio.queue_free() diff --git a/scripts/studio/gizmos/transform_gizmo_3d.gd b/scripts/studio/gizmos/transform_gizmo_3d.gd index cade290..2f3c4a2 100644 --- a/scripts/studio/gizmos/transform_gizmo_3d.gd +++ b/scripts/studio/gizmos/transform_gizmo_3d.gd @@ -1,6 +1,8 @@ class_name TransformGizmo3D extends Node3D ## Interactive 3D manipulator gizmo supporting Translate, Rotate, and Scale modes with snapping. +## Renders visually clear axis handles (arrows for move, rings for rotate, cubes for scale) +## with priority depth rendering and geometric raycast picking. signal transform_started signal transform_updated(mode: String, delta: Variant) @@ -24,17 +26,26 @@ var _drag_start_pos: Vector3 = Vector3.ZERO var _drag_start_rot: Vector3 = Vector3.ZERO var _drag_start_scale: Vector3 = Vector3.ONE +# Mode visual root nodes +var _translate_root: Node3D +var _rotate_root: Node3D +var _scale_root: Node3D + func _ready() -> void: _build_gizmo_visuals() + _update_visual_mode() func attach_to_node(node: Node3D) -> void: target_node = node - if target_node != null: - visible = true - global_position = target_node.global_position - global_rotation = target_node.global_rotation + if target_node != null and is_instance_valid(target_node): + visible = (current_mode != Mode.NONE) + position = target_node.position + if current_mode == Mode.ROTATE or current_mode == Mode.SCALE: + quaternion = target_node.quaternion + else: + quaternion = Quaternion.IDENTITY else: visible = false @@ -42,6 +53,8 @@ func attach_to_node(node: Node3D) -> void: func set_mode(new_mode: Mode) -> void: current_mode = new_mode _update_visual_mode() + if target_node != null and is_instance_valid(target_node): + attach_to_node(target_node) func set_snap(enabled: bool, translate_step: float = 1.0, rotate_step: float = 15.0) -> void: @@ -50,63 +63,269 @@ func set_snap(enabled: bool, translate_step: float = 1.0, rotate_step: float = 1 snap_rotate_deg = rotate_step +func _update_visual_mode() -> void: + if _translate_root != null: + _translate_root.visible = (current_mode == Mode.TRANSLATE) + if _rotate_root != null: + _rotate_root.visible = (current_mode == Mode.ROTATE) + if _scale_root != null: + _scale_root.visible = (current_mode == Mode.SCALE) + visible = (target_node != null and current_mode != Mode.NONE) + + +# ── Visual Construction ─────────────────────────────────────────────── + +func _make_gizmo_mat(color: Color) -> StandardMaterial3D: + var mat := StandardMaterial3D.new() + mat.albedo_color = color + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.no_depth_test = true + mat.render_priority = 10 + return mat + + func _build_gizmo_visuals() -> void: - # Build colored 3D axis handles for X (Red), Y (Green), Z (Blue) - var x_mat := StandardMaterial3D.new() - x_mat.albedo_color = Color(0.9, 0.2, 0.2) - x_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + var x_mat := _make_gizmo_mat(Color(0.95, 0.22, 0.22)) + var y_mat := _make_gizmo_mat(Color(0.22, 0.90, 0.25)) + var z_mat := _make_gizmo_mat(Color(0.25, 0.50, 0.98)) + var center_mat := _make_gizmo_mat(Color(1.0, 0.88, 0.20)) - var y_mat := StandardMaterial3D.new() - y_mat.albedo_color = Color(0.2, 0.85, 0.2) - y_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + # 1. TRANSLATE Visuals (Arrows + Center Ground Plane Quad) + _translate_root = Node3D.new() + _translate_root.name = "TranslateRoot" + add_child(_translate_root) - var z_mat := StandardMaterial3D.new() - z_mat.albedo_color = Color(0.2, 0.4, 0.95) - z_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + _translate_root.add_child(_create_arrow(Vector3(1, 0, 0), x_mat, "ArrowX")) + _translate_root.add_child(_create_arrow(Vector3(0, 1, 0), y_mat, "ArrowY")) + _translate_root.add_child(_create_arrow(Vector3(0, 0, 1), z_mat, "ArrowZ")) - # X axis line + arrow - var x_axis := _create_axis_cylinder(Vector3(1, 0, 0), x_mat) - x_axis.name = "AxisX" - add_child(x_axis) + # Center ground plane indicator + var plane_inst := MeshInstance3D.new() + var plane_mesh := BoxMesh.new() + plane_mesh.size = Vector3(0.4, 0.02, 0.4) + plane_inst.mesh = plane_mesh + plane_inst.material_override = center_mat + plane_inst.position = Vector3(0.2, 0.01, 0.2) + _translate_root.add_child(plane_inst) - # Y axis line + arrow - var y_axis := _create_axis_cylinder(Vector3(0, 1, 0), y_mat) - y_axis.name = "AxisY" - add_child(y_axis) + # 2. ROTATE Visuals (Rings) + _rotate_root = Node3D.new() + _rotate_root.name = "RotateRoot" + add_child(_rotate_root) - # Z axis line + arrow - var z_axis := _create_axis_cylinder(Vector3(0, 0, 1), z_mat) - z_axis.name = "AxisZ" - add_child(z_axis) + _rotate_root.add_child(_create_torus(Vector3.UP, y_mat, "RingYaw")) + _rotate_root.add_child(_create_torus(Vector3.RIGHT, x_mat, "RingPitch")) + _rotate_root.add_child(_create_torus(Vector3.FORWARD, z_mat, "RingRoll")) + + # 3. SCALE Visuals (Lines + End Cubes) + _scale_root = Node3D.new() + _scale_root.name = "ScaleRoot" + add_child(_scale_root) + + _scale_root.add_child(_create_scale_handle(Vector3(1, 0, 0), x_mat, "ScaleX")) + _scale_root.add_child(_create_scale_handle(Vector3(0, 1, 0), y_mat, "ScaleY")) + _scale_root.add_child(_create_scale_handle(Vector3(0, 0, 1), z_mat, "ScaleZ")) + + # Center uniform scale cube + var center_cube := MeshInstance3D.new() + var c_box := BoxMesh.new() + c_box.size = Vector3(0.25, 0.25, 0.25) + center_cube.mesh = c_box + center_cube.material_override = center_mat + _scale_root.add_child(center_cube) -func _create_axis_cylinder(dir: Vector3, mat: Material) -> Node3D: +func _create_arrow(dir: Vector3, mat: Material, node_name: String) -> Node3D: var container := Node3D.new() + container.name = node_name + + # Shaft var shaft := MeshInstance3D.new() var cyl := CylinderMesh.new() - cyl.top_radius = 0.03 - cyl.bottom_radius = 0.03 - cyl.height = 1.5 - cyl.material = mat + cyl.top_radius = 0.035 + cyl.bottom_radius = 0.035 + cyl.height = 1.3 shaft.mesh = cyl + shaft.material_override = mat + + # Cone Head + var head := MeshInstance3D.new() + var cone := CylinderMesh.new() + cone.top_radius = 0.0 + cone.bottom_radius = 0.12 + cone.height = 0.35 + head.mesh = cone + head.material_override = mat - # Point cylinder along direction if dir == Vector3(1, 0, 0): shaft.rotation_degrees = Vector3(0, 0, -90) - shaft.position = Vector3(0.75, 0, 0) + shaft.position = Vector3(0.65, 0, 0) + head.rotation_degrees = Vector3(0, 0, -90) + head.position = Vector3(1.45, 0, 0) elif dir == Vector3(0, 1, 0): - shaft.position = Vector3(0, 0.75, 0) + shaft.position = Vector3(0, 0.65, 0) + head.position = Vector3(0, 1.45, 0) elif dir == Vector3(0, 0, 1): shaft.rotation_degrees = Vector3(90, 0, 0) - shaft.position = Vector3(0, 0, 0.75) + shaft.position = Vector3(0, 0, 0.65) + head.rotation_degrees = Vector3(90, 0, 0) + head.position = Vector3(0, 0, 1.45) container.add_child(shaft) + container.add_child(head) return container -func _update_visual_mode() -> void: - visible = (target_node != null and current_mode != Mode.NONE) +func _create_torus(normal: Vector3, mat: Material, node_name: String) -> Node3D: + var inst := MeshInstance3D.new() + inst.name = node_name + var torus := TorusMesh.new() + torus.inner_radius = 1.25 + torus.outer_radius = 1.35 + torus.rings = 32 + torus.ring_segments = 8 + inst.mesh = torus + inst.material_override = mat + if normal == Vector3.RIGHT: + inst.rotation_degrees = Vector3(0, 0, 90) + elif normal == Vector3.FORWARD: + inst.rotation_degrees = Vector3(90, 0, 0) + + return inst + + +func _create_scale_handle(dir: Vector3, mat: Material, node_name: String) -> Node3D: + var container := Node3D.new() + container.name = node_name + + # Shaft + var shaft := MeshInstance3D.new() + var cyl := CylinderMesh.new() + cyl.top_radius = 0.035 + cyl.bottom_radius = 0.035 + cyl.height = 1.3 + shaft.mesh = cyl + shaft.material_override = mat + + # Cube Tip + var tip := MeshInstance3D.new() + var box := BoxMesh.new() + box.size = Vector3(0.2, 0.2, 0.2) + tip.mesh = box + tip.material_override = mat + + if dir == Vector3(1, 0, 0): + shaft.rotation_degrees = Vector3(0, 0, -90) + shaft.position = Vector3(0.65, 0, 0) + tip.position = Vector3(1.35, 0, 0) + elif dir == Vector3(0, 1, 0): + shaft.position = Vector3(0, 0.65, 0) + tip.position = Vector3(0, 1.35, 0) + elif dir == Vector3(0, 0, 1): + shaft.rotation_degrees = Vector3(90, 0, 0) + shaft.position = Vector3(0, 0, 0.65) + tip.position = Vector3(0, 0, 1.35) + + container.add_child(shaft) + container.add_child(tip) + return container + + +# ── Geometric Axis Picking ──────────────────────────────────────────── + +func pick_axis(ray_origin: Vector3, ray_dir: Vector3) -> Axis: + if not visible or target_node == null: + return Axis.NONE + + var origin := position + # 1. Center / Ground plane handle test (radius 0.45) + var d_center := _point_to_ray_dist(origin, ray_origin, ray_dir) + if d_center < 0.45: + return Axis.XY + + match current_mode: + Mode.TRANSLATE: + var d_x := get_ray_segment_distance(ray_origin, ray_dir, origin, origin + Vector3(1.6, 0, 0)) + var d_y := get_ray_segment_distance(ray_origin, ray_dir, origin, origin + Vector3(0, 1.6, 0)) + var d_z := get_ray_segment_distance(ray_origin, ray_dir, origin, origin + Vector3(0, 0, 1.6)) + var min_d := minf(d_x, minf(d_y, d_z)) + if min_d < 0.35: + if min_d == d_y: return Axis.Y + if min_d == d_x: return Axis.X + if min_d == d_z: return Axis.Z + + Mode.ROTATE: + var d_yaw := _ray_ring_distance(ray_origin, ray_dir, origin, Vector3.UP, 1.3) + var d_pitch := _ray_ring_distance(ray_origin, ray_dir, origin, Vector3.RIGHT, 1.3) + var d_roll := _ray_ring_distance(ray_origin, ray_dir, origin, Vector3.FORWARD, 1.3) + var min_r := minf(d_yaw, minf(d_pitch, d_roll)) + if min_r < 0.40: + if min_r == d_yaw: return Axis.Y + if min_r == d_pitch: return Axis.X + if min_r == d_roll: return Axis.Z + + Mode.SCALE: + var d_x := get_ray_segment_distance(ray_origin, ray_dir, origin, origin + Vector3(1.5, 0, 0)) + var d_y := get_ray_segment_distance(ray_origin, ray_dir, origin, origin + Vector3(0, 1.5, 0)) + var d_z := get_ray_segment_distance(ray_origin, ray_dir, origin, origin + Vector3(0, 0, 1.5)) + var min_d := minf(d_x, minf(d_y, d_z)) + if min_d < 0.35: + if min_d == d_y: return Axis.Y + if min_d == d_x: return Axis.X + if min_d == d_z: return Axis.Z + + return Axis.NONE + + +static func _point_to_ray_dist(p: Vector3, ray_origin: Vector3, ray_dir: Vector3) -> float: + var u := ray_dir.normalized() + var to_p := p - ray_origin + var proj := to_p.dot(u) + if proj < 0.0: + return 1e9 + var closest := ray_origin + u * proj + return p.distance_to(closest) + + +static func _ray_ring_distance(ray_origin: Vector3, ray_dir: Vector3, ring_center: Vector3, ring_normal: Vector3, ring_radius: float) -> float: + var denom := ray_dir.dot(ring_normal) + if absf(denom) < 1e-5: + return 1e9 + var t := (ring_center - ray_origin).dot(ring_normal) / denom + if t < 0.0: + return 1e9 + var hit := ray_origin + ray_dir * t + var d_from_center := hit.distance_to(ring_center) + return absf(d_from_center - ring_radius) + + +static func get_ray_segment_distance(ray_origin: Vector3, ray_dir: Vector3, seg_a: Vector3, seg_b: Vector3) -> float: + var u := ray_dir.normalized() + var v := seg_b - seg_a + var w0 := ray_origin - seg_a + var a := u.dot(u) + var b := u.dot(v) + var c := v.dot(v) + var d := u.dot(w0) + var e := v.dot(w0) + var denom := a * c - b * b + var s: float = 0.0 + var t: float = 0.0 + if denom > 1e-6: + s = (b * e - c * d) / denom + t = (a * e - b * d) / denom + else: + s = 0.0 + t = e / c if c > 1e-6 else 0.0 + s = maxf(0.0, s) + t = clampf(t, 0.0, 1.0) + var pt_ray := ray_origin + u * s + var pt_seg := seg_a + v * t + return pt_ray.distance_to(pt_seg) + + +# ── Drag Lifecycle (Backwards Compatibility) ────────────────────────── func start_drag(axis: Axis) -> void: if target_node == null: diff --git a/scripts/studio/studio_inspector_dock.gd b/scripts/studio/studio_inspector_dock.gd index 3725b85..91d7ef6 100644 --- a/scripts/studio/studio_inspector_dock.gd +++ b/scripts/studio/studio_inspector_dock.gd @@ -2,6 +2,7 @@ class_name StudioInspectorDock extends VBoxContainer ## Property Inspector Dock for ModBison Studio. ## Renders editable properties for selected world object(s) with undo/redo support. +## Synchronizes Godot 3D world transforms (X, Y_height, Z_depth) with Chunk storage. signal property_changed(object_id: String, key: String, value: Variant) signal delete_requested(object_id: String) @@ -15,7 +16,7 @@ var _empty_label: Label var _scroll: ScrollContainer var _content_vbox: VBoxContainer -# Transform fields +# Transform fields (Godot 3D: X, Y_up, Z_depth) var _pos_x: SpinBox var _pos_y: SpinBox var _pos_z: SpinBox @@ -27,6 +28,7 @@ var _scale_z: SpinBox # Metadata var _name_edit: LineEdit var _asset_label: Label +var _btn_delete: Button func _init() -> void: @@ -83,27 +85,40 @@ func _build_ui() -> void: # Transform header var t_header := Label.new() - t_header.text = "Transform" + t_header.text = "\nTransform (3D Space)" t_header.add_theme_font_size_override("font_size", 11) t_header.add_theme_color_override("font_color", Color(0.35, 0.85, 1.0)) _content_vbox.add_child(t_header) _pos_x = _add_spin("Pos X", _on_pos_changed) - _pos_y = _add_spin("Pos Y", _on_pos_changed) + _pos_y = _add_spin("Pos Y (Up)", _on_pos_changed, -50.0, 500.0, 0.5) _pos_z = _add_spin("Pos Z", _on_pos_changed) - _rot_y = _add_spin("Yaw (°)", _on_rot_changed, -360.0, 360.0, 1.0) + _rot_y = _add_spin("Yaw (°)", _on_rot_changed, -360.0, 360.0, 15.0) - _scale_x = _add_spin("Scale X", _on_scale_changed, 0.05, 50.0, 0.1) - _scale_y = _add_spin("Scale Y", _on_scale_changed, 0.05, 50.0, 0.1) - _scale_z = _add_spin("Scale Z", _on_scale_changed, 0.05, 50.0, 0.1) + _scale_x = _add_spin("Scale X", _on_scale_changed, 0.1, 50.0, 0.1) + _scale_y = _add_spin("Scale Y", _on_scale_changed, 0.1, 50.0, 0.1) + _scale_z = _add_spin("Scale Z", _on_scale_changed, 0.1, 50.0, 0.1) + + # Actions + var act_sep := HSeparator.new() + _content_vbox.add_child(act_sep) + + _btn_delete = Button.new() + _btn_delete.text = "🗑 Delete Object" + _btn_delete.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) + _btn_delete.pressed.connect(func(): + if _selected_id != "": + delete_requested.emit(_selected_id) + ) + _content_vbox.add_child(_btn_delete) func _add_spin(label: String, callback: Callable, min_val: float = -500.0, max_val: float = 500.0, step: float = 0.5) -> SpinBox: var row := HBoxContainer.new() var lbl := Label.new() lbl.text = label + ":" - lbl.custom_minimum_size = Vector2(70, 0) + lbl.custom_minimum_size = Vector2(80, 0) lbl.add_theme_font_size_override("font_size", 10) row.add_child(lbl) @@ -118,6 +133,18 @@ func _add_spin(label: String, callback: Callable, min_val: float = -500.0, max_v return spin +func set_transform_values(godot_pos: Vector3, yaw_deg: float, godot_scale: Vector3) -> void: + _updating = true + if _pos_x: _pos_x.value = godot_pos.x + if _pos_y: _pos_y.value = godot_pos.y + if _pos_z: _pos_z.value = godot_pos.z + if _rot_y: _rot_y.value = yaw_deg + if _scale_x: _scale_x.value = godot_scale.x + if _scale_y: _scale_y.value = godot_scale.y + if _scale_z: _scale_z.value = godot_scale.z + _updating = false + + func _on_selection_changed(obj_id: String) -> void: _selected_id = obj_id if _selected_id == "" or _core == null: @@ -141,16 +168,18 @@ func _on_selection_changed(obj_id: String) -> void: _name_edit.text = dname _asset_label.text = "Asset: %s" % obj.asset_id - _pos_x.value = obj.position.x - _pos_y.value = obj.position.y - _pos_z.value = obj.position.z + # Map chunk coords (X, Y_ground, Z_height) to Godot 3D (X, Y_height, Z_depth) + var godot_pos := Vector3(obj.position.x, obj.position.z, obj.position.y) + _pos_x.value = godot_pos.x + _pos_y.value = godot_pos.y + _pos_z.value = godot_pos.z - var euler = Quaternion(obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w).get_euler() + var euler = Quaternion(obj.rotation.x, obj.rotation.z, obj.rotation.y, obj.rotation.w).get_euler() _rot_y.value = rad_to_deg(euler.y) _scale_x.value = obj.scale.x - _scale_y.value = obj.scale.y - _scale_z.value = obj.scale.z + _scale_y.value = obj.scale.z + _scale_z.value = obj.scale.y _updating = false @@ -164,23 +193,29 @@ func _on_name_changed(new_name: String) -> void: func _on_pos_changed(_val: float) -> void: if _updating or _core == null or _selected_id == "": return - var new_pos := Vector3(_pos_x.value, _pos_y.value, _pos_z.value) - _core.move_object(_selected_id, new_pos) - property_changed.emit(_selected_id, "position", new_pos) + var godot_pos := Vector3(_pos_x.value, _pos_y.value, _pos_z.value) + # Convert Godot 3D (X, Y_up, Z_depth) to Chunk (X, Y_ground, Z_height) + var chunk_pos := Vector3(godot_pos.x, godot_pos.z, godot_pos.y) + _core.move_object(_selected_id, chunk_pos) + property_changed.emit(_selected_id, "position", godot_pos) func _on_rot_changed(_val: float) -> void: if _updating or _core == null or _selected_id == "": return - var quat := Quaternion.from_euler(Vector3(0, deg_to_rad(_rot_y.value), 0)) - var rot_vec := Vector4(quat.x, quat.y, quat.z, quat.w) + var deg := _rot_y.value + var godot_q := Quaternion(Vector3.UP, deg_to_rad(deg)) + # Convert to chunk rotation quat + var chunk_q := Quaternion(godot_q.x, godot_q.z, godot_q.y, godot_q.w) + var rot_vec := Vector4(chunk_q.x, chunk_q.y, chunk_q.z, chunk_q.w) _core.rotate_object(_selected_id, rot_vec) - property_changed.emit(_selected_id, "rotation", rot_vec) + property_changed.emit(_selected_id, "rotation", godot_q) func _on_scale_changed(_val: float) -> void: if _updating or _core == null or _selected_id == "": return - var new_scale := Vector3(_scale_x.value, _scale_y.value, _scale_z.value) - _core.scale_object(_selected_id, new_scale) - property_changed.emit(_selected_id, "scale", new_scale) + var godot_scale := Vector3(_scale_x.value, _scale_y.value, _scale_z.value) + var chunk_scale := Vector3(godot_scale.x, godot_scale.z, godot_scale.y) + _core.scale_object(_selected_id, chunk_scale) + property_changed.emit(_selected_id, "scale", godot_scale) diff --git a/scripts/studio/studio_main.gd b/scripts/studio/studio_main.gd index b7c642e..2544545 100644 --- a/scripts/studio/studio_main.gd +++ b/scripts/studio/studio_main.gd @@ -56,6 +56,18 @@ var _world_container: Node3D var _gizmo_3d: Node3D var _baseplate_mesh: MeshInstance3D +# Drag & Drop / Transformation Interaction State +var _is_dragging: bool = false +var _drag_axis: int = 0 +var _drag_start_mouse: Vector2 = Vector2.ZERO +var _drag_start_ground: Vector3 = Vector3.ZERO +var _drag_start_node_pos: Vector3 = Vector3.ZERO +var _drag_start_node_rot_deg: Vector3 = Vector3.ZERO +var _drag_start_node_scale: Vector3 = Vector3.ONE +var _drag_accum_y: float = 0.0 +var _drag_accum_rot: float = 0.0 +var _drag_accum_scale: float = 0.0 + # Docks & Dialogs var _hierarchy_dock = null var _inspector_dock = null @@ -140,6 +152,7 @@ func _build_studio_layout() -> void: left_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL _hierarchy_dock = StudioHierarchyDockClass.new() _hierarchy_dock.setup(core) + _hierarchy_dock.delete_requested.connect(_delete_selected_object) left_panel.add_child(_hierarchy_dock) workspace_hbox.add_child(left_panel) @@ -168,6 +181,8 @@ func _build_studio_layout() -> void: _inspector_dock = StudioInspectorDockClass.new() _inspector_dock.setup(core) + _inspector_dock.property_changed.connect(_on_inspector_property_changed) + _inspector_dock.delete_requested.connect(_delete_selected_object) tabs.add_child(_inspector_dock) tabs.set_tab_title(0, "Inspector") @@ -306,7 +321,7 @@ func _build_toolbar(parent: Control) -> void: _toolbar.add_child(snap_lbl) _snap_spin = SpinBox.new() - _snap_spin.min_value = 0.1 + _snap_spin.min_value = 0.5 _snap_spin.max_value = 10.0 _snap_spin.step = 0.5 _snap_spin.value = 1.0 @@ -393,13 +408,15 @@ func _quick_insert(cat_id: String, display_name: String) -> void: if _camera_3d != null: spawn_pos = _camera_3d.focal_point spawn_pos.y = 0.0 - var new_id := core.place_object(cat_id, spawn_pos, Vector4(0, 0, 0, 1), Vector3.ONE, { + var chunk_pos := _godot_to_chunk_pos(spawn_pos) + var new_id := core.place_object(cat_id, chunk_pos, Vector4(0, 0, 0, 1), Vector3.ONE, { "display_name": display_name, "catalog_id": cat_id }) if new_id != "": - core.select_object(new_id) _spawn_visual_node(new_id) + core.select_object(new_id) + _on_selection_changed(new_id) _hierarchy_dock.refresh_tree() _set_gizmo_mode(TransformGizmo3DClass.Mode.TRANSLATE) _status_bar_label.text = "Spawned %s at (%.1f, 0, %.1f) | ✛ Move gizmo active" % [display_name, spawn_pos.x, spawn_pos.z] @@ -425,13 +442,26 @@ func _setup_3d_viewport_scene(vp: SubViewport) -> void: # Studio Baseplate Ground Mesh _baseplate_mesh = MeshInstance3D.new() var plane := PlaneMesh.new() - plane.size = Vector2(64, 64) + plane.size = Vector2(128, 128) var gmat := StandardMaterial3D.new() gmat.albedo_color = Color(0.20, 0.23, 0.27) gmat.roughness = 0.85 _baseplate_mesh.mesh = plane vp.add_child(_baseplate_mesh) + # Solid Physics Floor Collider (layer 1, 500x500m surface at Y=0) + var floor_body := StaticBody3D.new() + floor_body.name = "StudioFloorBody" + floor_body.set_collision_layer_value(1, true) + floor_body.set_collision_mask_value(1, true) + var floor_col := CollisionShape3D.new() + var floor_box := BoxShape3D.new() + floor_box.size = Vector3(500.0, 2.0, 500.0) + floor_col.shape = floor_box + floor_col.position = Vector3(0, -1.0, 0) + floor_body.add_child(floor_col) + vp.add_child(floor_body) + # 3D Grid floor lines var grid := _create_3d_grid(32, 1.0) vp.add_child(grid) @@ -470,7 +500,7 @@ func _setup_viewport_hud_overlay(parent: Control) -> void: hud_bar.position = Vector2(16, -42) var hud_text := Label.new() - hud_text.text = "🎥 RMB+WASD: Fly | 🖱️ LMB: Click to Select | ✛ [2] Move | ↻ [3] Rotate | [F] Focus | ▶ [F5] Play | ❓ [F1] Help" + hud_text.text = "🎥 RMB+WASD: Fly | 🖱️ LMB: Click/Drag to Move | ✛ [2] Move | ↻ [3] Rotate | [F] Focus | ▶ [F5] Play | ❓ [F1] Help" hud_text.add_theme_font_size_override("font_size", 11) hud_text.add_theme_color_override("font_color", Color(0.9, 0.92, 0.96)) hud_bar.add_child(hud_text) @@ -497,6 +527,8 @@ func _create_3d_grid(size: int, step: float) -> Node3D: return mi +# ── Keyboard Shortcuts & Nudge ──────────────────────────────────────── + func _setup_shortcuts() -> void: set_process_unhandled_key_input(true) @@ -524,6 +556,26 @@ func _unhandled_key_input(event: InputEvent) -> void: _simulation_mgr.stop_simulation() else: _simulation_mgr.start_play(true) + elif k.keycode == KEY_DELETE or k.keycode == KEY_BACKSPACE: + var sel := _get_selected_id() + if sel != "": + _delete_selected_object(sel) + elif k.keycode == KEY_BRACKETLEFT and not k.is_command_or_control_pressed(): + _nudge_selected_rotation(-45.0) + elif k.keycode == KEY_BRACKETRIGHT and not k.is_command_or_control_pressed(): + _nudge_selected_rotation(45.0) + elif k.keycode == KEY_LEFT and not k.is_command_or_control_pressed(): + _nudge_selected_position(Vector3(-1, 0, 0)) + elif k.keycode == KEY_RIGHT and not k.is_command_or_control_pressed(): + _nudge_selected_position(Vector3(1, 0, 0)) + elif k.keycode == KEY_UP and not k.is_command_or_control_pressed(): + _nudge_selected_position(Vector3(0, 0, -1)) + elif k.keycode == KEY_DOWN and not k.is_command_or_control_pressed(): + _nudge_selected_position(Vector3(0, 0, 1)) + elif k.keycode == KEY_PAGEUP: + _nudge_selected_position(Vector3(0, 1, 0)) + elif k.keycode == KEY_PAGEDOWN: + _nudge_selected_position(Vector3(0, -1, 0)) elif k.keycode == KEY_1 and not k.is_command_or_control_pressed(): _set_gizmo_mode(TransformGizmo3DClass.Mode.NONE) elif k.keycode == KEY_2 and not k.is_command_or_control_pressed(): @@ -533,13 +585,66 @@ func _unhandled_key_input(event: InputEvent) -> void: elif k.keycode == KEY_4 and not k.is_command_or_control_pressed(): _set_gizmo_mode(TransformGizmo3DClass.Mode.SCALE) elif k.keycode == KEY_F and not k.is_command_or_control_pressed(): - var sel_id = core.selection.get_selected_id() if core.selection.has_method("get_selected_id") else "" + var sel_id := _get_selected_id() if sel_id != "": - var n = _world_container.get_node_or_null(sel_id) + var n := _world_container.get_node_or_null(sel_id) if n is Node3D and _camera_3d != null: _camera_3d.focus_on(n.global_position) +func _nudge_selected_position(dir: Vector3) -> void: + var sel := _get_selected_id() + if sel == "": + return + var node := _world_container.get_node_or_null(sel) as Node3D + if node == null: + return + var step: float = _snap_spin.value if _snap_spin else 1.0 + var new_pos := node.position + dir * step + new_pos.y = maxf(0.0, new_pos.y) + node.position = new_pos + if _gizmo_3d != null: + _gizmo_3d.position = node.position + var chunk_pos := _godot_to_chunk_pos(new_pos) + core.move_object(sel, chunk_pos) + if _inspector_dock != null: + _inspector_dock.set_transform_values(new_pos, node.rotation_degrees.y, node.scale) + _status_bar_label.text = "Nudged %s to (%.1f, %.1f, %.1f)" % [sel, new_pos.x, new_pos.y, new_pos.z] + + +func _nudge_selected_rotation(delta_deg: float) -> void: + var sel := _get_selected_id() + if sel == "": + return + var node := _world_container.get_node_or_null(sel) as Node3D + if node == null: + return + var new_yaw := fmod(node.rotation_degrees.y + delta_deg, 360.0) + node.rotation_degrees.y = new_yaw + if _gizmo_3d != null: + _gizmo_3d.quaternion = node.quaternion + var q := node.quaternion + var chunk_q := _godot_to_chunk_quat(q) + core.rotate_object(sel, Vector4(chunk_q.x, chunk_q.y, chunk_q.z, chunk_q.w)) + if _inspector_dock != null: + _inspector_dock.set_transform_values(node.position, new_yaw, node.scale) + _status_bar_label.text = "Rotated %s to %.0f°" % [sel, new_yaw] + + +func _delete_selected_object(obj_id: String) -> void: + if obj_id == "": + obj_id = _get_selected_id() + if obj_id == "": + return + core.delete_object(obj_id) + var node := _world_container.get_node_or_null(obj_id) + if node != null: + node.queue_free() + core.select_object("") + _hierarchy_dock.refresh_tree() + _status_bar_label.text = "Deleted object '%s' | Undo with [Ctrl+Z]" % obj_id + + func _set_gizmo_mode(mode: TransformGizmo3DClass.Mode) -> void: if _gizmo_3d != null: _gizmo_3d.set_mode(mode) @@ -565,21 +670,188 @@ func _return_to_main_menu() -> void: get_tree().change_scene_to_file("res://scenes/main_menu.tscn") -# ── Interactive 3D Viewport Input & Raycast Picking ───────────────────── +# ── Interactive 3D Viewport Input & Picking ─────────────────────────── func handle_viewport_input(event: InputEvent) -> void: if event is InputEventMouseButton: var mb := event as InputEventMouseButton - if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed: - var hit_obj := _raycast_find_object(mb.position) - if hit_obj != null: - core.select_object(hit_obj.name) - if mb.double_click and _camera_3d != null: - _camera_3d.focus_on(hit_obj.global_position) - _status_bar_label.text = "Selected: %s | ✛ Drag handles or [2-4] switch tools" % hit_obj.name + if mb.button_index == MOUSE_BUTTON_LEFT: + if mb.pressed: + _handle_left_press(mb) else: - core.select_object("") - _status_bar_label.text = "Deselected | Click an object or [+ Add] to build" + _handle_left_release(mb) + + elif event is InputEventMouseMotion and _is_dragging: + var mm := event as InputEventMouseMotion + _handle_drag_motion(mm) + + +func _handle_left_press(mb: InputEventMouseButton) -> void: + if _camera_3d == null or _world_container == null: + return + + var ray_from: Vector3 = _camera_3d.project_ray_origin(mb.position) + var ray_dir: Vector3 = _camera_3d.project_ray_normal(mb.position) + var sel_id: String = _get_selected_id() + + # 1. First priority: Check if clicking on Gizmo handle + if _gizmo_3d != null and _gizmo_3d.visible and sel_id != "": + var picked_axis: TransformGizmo3DClass.Axis = _gizmo_3d.pick_axis(ray_from, ray_dir) + if picked_axis != TransformGizmo3DClass.Axis.NONE: + _start_drag(picked_axis, mb.position) + return + + # 2. Second priority: Check if clicking on a 3D world object + var hit_obj := _raycast_find_object(mb.position) + if hit_obj != null: + if hit_obj.name == sel_id and _gizmo_3d != null and _gizmo_3d.current_mode != TransformGizmo3DClass.Mode.NONE: + # Directly drag already selected object + _start_drag(TransformGizmo3DClass.Axis.XY, mb.position) + return + + # Select new object + core.select_object(hit_obj.name) + if mb.double_click and _camera_3d != null: + _camera_3d.focus_on(hit_obj.global_position) + _status_bar_label.text = "Selected: %s | ✛ Drag model or handles to move" % hit_obj.name + return + + # 3. Third: Clicked empty space -> deselect + _is_dragging = false + core.select_object("") + _status_bar_label.text = "Deselected | Click an object or [+ Add] to build" + + +func _start_drag(axis: TransformGizmo3DClass.Axis, mouse_pos: Vector2) -> void: + var sel_id := _get_selected_id() + if sel_id == "": + return + var node := _world_container.get_node_or_null(sel_id) as Node3D + if node == null: + return + + _is_dragging = true + _drag_axis = axis + _drag_start_mouse = mouse_pos + _drag_start_ground = _raycast_ground_plane(mouse_pos) + _drag_start_node_pos = node.position + _drag_start_node_rot_deg = node.rotation_degrees + _drag_start_node_scale = node.scale + _drag_accum_y = 0.0 + _drag_accum_rot = 0.0 + _drag_accum_scale = 0.0 + + +func _handle_drag_motion(mm: InputEventMouseMotion) -> void: + var sel_id := _get_selected_id() + if sel_id == "": + _is_dragging = false + return + var node := _world_container.get_node_or_null(sel_id) as Node3D + if node == null: + _is_dragging = false + return + + var snap_step: float = _snap_spin.value if _snap_spin else 1.0 + + match _gizmo_3d.current_mode if _gizmo_3d else TransformGizmo3DClass.Mode.TRANSLATE: + TransformGizmo3DClass.Mode.TRANSLATE: + if _drag_axis == TransformGizmo3DClass.Axis.Y: + var dy: float = -mm.relative.y * 0.05 + _drag_accum_y += dy + var new_y := maxf(0.0, _drag_start_node_pos.y + _drag_accum_y) + if snap_step > 0.0: + new_y = snappedf(new_y, snap_step) + node.position.y = new_y + elif _drag_axis == TransformGizmo3DClass.Axis.X: + var curr_ground := _raycast_ground_plane(mm.position) + var dx := curr_ground.x - _drag_start_ground.x + if snap_step > 0.0: + dx = snappedf(dx, snap_step) + node.position.x = _drag_start_node_pos.x + dx + elif _drag_axis == TransformGizmo3DClass.Axis.Z: + var curr_ground := _raycast_ground_plane(mm.position) + var dz := curr_ground.z - _drag_start_ground.z + if snap_step > 0.0: + dz = snappedf(dz, snap_step) + node.position.z = _drag_start_node_pos.z + dz + else: + # Free ground plane movement (XZ) + var curr_ground := _raycast_ground_plane(mm.position) + var delta := curr_ground - _drag_start_ground + var new_x := _drag_start_node_pos.x + delta.x + var new_z := _drag_start_node_pos.z + delta.z + if snap_step > 0.0: + new_x = snappedf(new_x, snap_step) + new_z = snappedf(new_z, snap_step) + node.position.x = new_x + node.position.z = new_z + + if _gizmo_3d != null: + _gizmo_3d.position = node.position + if _inspector_dock != null: + _inspector_dock.set_transform_values(node.position, node.rotation_degrees.y, node.scale) + _status_bar_label.text = "Moving %s to (%.1f, %.1f, %.1f)" % [sel_id, node.position.x, node.position.y, node.position.z] + + TransformGizmo3DClass.Mode.ROTATE: + var rot_step: float = 15.0 + _drag_accum_rot += mm.relative.x * 0.8 + var new_yaw := _drag_start_node_rot_deg.y - _drag_accum_rot + if rot_step > 0.0: + new_yaw = snappedf(new_yaw, rot_step) + node.rotation_degrees.y = new_yaw + if _gizmo_3d != null: + _gizmo_3d.quaternion = node.quaternion + if _inspector_dock != null: + _inspector_dock.set_transform_values(node.position, new_yaw, node.scale) + _status_bar_label.text = "Rotating %s to %.0f°" % [sel_id, new_yaw] + + TransformGizmo3DClass.Mode.SCALE: + var scale_step: float = 0.1 + _drag_accum_scale += -mm.relative.y * 0.02 + var factor := maxf(0.05, 1.0 + _drag_accum_scale) + var new_scale := _drag_start_node_scale * factor + if scale_step > 0.0: + new_scale.x = snappedf(new_scale.x, scale_step) + new_scale.y = snappedf(new_scale.y, scale_step) + new_scale.z = snappedf(new_scale.z, scale_step) + node.scale = new_scale + if _gizmo_3d != null: + _gizmo_3d.attach_to_node(node) + if _inspector_dock != null: + _inspector_dock.set_transform_values(node.position, node.rotation_degrees.y, new_scale) + _status_bar_label.text = "Scaling %s to (%.2f, %.2f, %.2f)" % [sel_id, new_scale.x, new_scale.y, new_scale.z] + + +func _handle_left_release(_mb: InputEventMouseButton) -> void: + if not _is_dragging: + return + _is_dragging = false + var sel_id := _get_selected_id() + if sel_id == "": + return + var node := _world_container.get_node_or_null(sel_id) as Node3D + if node == null: + return + + match _gizmo_3d.current_mode if _gizmo_3d else TransformGizmo3DClass.Mode.TRANSLATE: + TransformGizmo3DClass.Mode.TRANSLATE: + var chunk_pos := _godot_to_chunk_pos(node.position) + core.move_object(sel_id, chunk_pos) + _status_bar_label.text = "Moved %s to (%.1f, %.1f, %.1f) | Undo with [Ctrl+Z]" % [sel_id, node.position.x, node.position.y, node.position.z] + + TransformGizmo3DClass.Mode.ROTATE: + var q := node.quaternion + var chunk_q := _godot_to_chunk_quat(q) + core.rotate_object(sel_id, Vector4(chunk_q.x, chunk_q.y, chunk_q.z, chunk_q.w)) + _status_bar_label.text = "Rotated %s to %.0f° | Undo with [Ctrl+Z]" % [sel_id, node.rotation_degrees.y] + + TransformGizmo3DClass.Mode.SCALE: + var chunk_scale := _godot_to_chunk_scale(node.scale) + core.scale_object(sel_id, chunk_scale) + _status_bar_label.text = "Scaled %s to (%.2f, %.2f, %.2f) | Undo with [Ctrl+Z]" % [sel_id, node.scale.x, node.scale.y, node.scale.z] + + _hierarchy_dock.refresh_tree() func handle_viewport_drop(at_pos: Vector2, data: Variant) -> void: @@ -591,21 +863,24 @@ func handle_viewport_drop(at_pos: Vector2, data: Variant) -> void: var new_id: String = "" if target_obj != null and target_obj.name != "": - new_id = core.place_object_stacked(cat_id, target_obj.name, drop_pos, Vector3(0, 0, 1), 0.0, false, { + var target_chunk_pos := _godot_to_chunk_pos(drop_pos) + new_id = core.place_object_stacked(cat_id, target_obj.name, target_chunk_pos, Vector3(0, 0, 1), 0.0, false, { "display_name": data.get("name", cat_id), "catalog_id": cat_id, "asset_id": data.get("asset_id", "") }) else: - new_id = core.place_object(cat_id, drop_pos, Vector4(0, 0, 0, 1), Vector3.ONE, { + var chunk_pos := _godot_to_chunk_pos(drop_pos) + new_id = core.place_object(cat_id, chunk_pos, Vector4(0, 0, 0, 1), Vector3.ONE, { "display_name": data.get("name", cat_id), "catalog_id": cat_id, "asset_id": data.get("asset_id", "") }) if new_id != "": - core.select_object(new_id) _spawn_visual_node(new_id) + core.select_object(new_id) + _on_selection_changed(new_id) _hierarchy_dock.refresh_tree() _set_gizmo_mode(TransformGizmo3DClass.Mode.TRANSLATE) _status_bar_label.text = "Placed %s at (%.1f, %.1f, %.1f)" % [data.get("name", cat_id), drop_pos.x, drop_pos.y, drop_pos.z] @@ -661,17 +936,63 @@ func _raycast_ground_plane(screen_pos: Vector2) -> Vector3: return from + dir * t +# ── Coordinate Conversions ──────────────────────────────────────────── + +static func _godot_to_chunk_pos(g: Vector3) -> Vector3: + return Vector3(g.x, g.z, g.y) + +static func _chunk_to_godot_pos(c: Vector3) -> Vector3: + return Vector3(c.x, c.z, c.y) + +static func _godot_to_chunk_quat(q: Quaternion) -> Quaternion: + return Quaternion(q.x, q.z, q.y, q.w) + +static func _chunk_to_godot_quat(q: Quaternion) -> Quaternion: + return Quaternion(q.x, q.z, q.y, q.w) + +static func _godot_to_chunk_scale(s: Vector3) -> Vector3: + return Vector3(s.x, s.z, s.y) + +static func _chunk_to_godot_scale(s: Vector3) -> Vector3: + return Vector3(s.x, s.z, s.y) + + # ── Selection & Scene Visual Sync ───────────────────────────────────── +func _get_selected_id() -> String: + if core != null and core.selection != null: + return core.selection.get_selected() + return "" + + func _on_selection_changed(obj_id: String) -> void: if obj_id == "": - _gizmo_3d.attach_to_node(null) + if _gizmo_3d != null: + _gizmo_3d.attach_to_node(null) return - var obj_node = _world_container.get_node_or_null(obj_id) - if obj_node is Node3D: + var obj_node := _world_container.get_node_or_null(obj_id) + if obj_node is Node3D and _gizmo_3d != null: + if _gizmo_3d.current_mode == TransformGizmo3DClass.Mode.NONE: + _set_gizmo_mode(TransformGizmo3DClass.Mode.TRANSLATE) _gizmo_3d.attach_to_node(obj_node) - _camera_3d.focus_on(obj_node.global_position) + + +func _on_inspector_property_changed(obj_id: String, prop_name: String, val: Variant) -> void: + var node := _world_container.get_node_or_null(obj_id) as Node3D + if node == null: + return + match prop_name: + "position": + node.position = val as Vector3 + if _gizmo_3d != null: + _gizmo_3d.position = node.position + "rotation": + node.quaternion = val as Quaternion + if _gizmo_3d != null: + _gizmo_3d.quaternion = node.quaternion + "scale": + node.scale = val as Vector3 func _on_collection_model_selected(model_data: Dictionary) -> void: @@ -680,14 +1001,16 @@ func _on_collection_model_selected(model_data: Dictionary) -> void: if _camera_3d != null: spawn_pos = _camera_3d.focal_point spawn_pos.y = 0.0 - var new_id := core.place_object(cat_id, spawn_pos, Vector4(0, 0, 0, 1), Vector3.ONE, { + var chunk_pos := _godot_to_chunk_pos(spawn_pos) + var new_id := core.place_object(cat_id, chunk_pos, Vector4(0, 0, 0, 1), Vector3.ONE, { "display_name": model_data.get("name", cat_id), "catalog_id": cat_id, "asset_id": model_data.get("asset_id", "") }) if new_id != "": - core.select_object(new_id) _spawn_visual_node(new_id) + core.select_object(new_id) + _on_selection_changed(new_id) _hierarchy_dock.refresh_tree() _set_gizmo_mode(TransformGizmo3DClass.Mode.TRANSLATE) _status_bar_label.text = "Placed %s at (%.1f, 0, %.1f)" % [model_data.get("name", cat_id), spawn_pos.x, spawn_pos.z] @@ -707,7 +1030,9 @@ func _spawn_visual_node(obj_id: String) -> void: var node = StaticObjectNodeClass.new() node.name = obj.id node.world_object = wo - node.position = Vector3(obj.position.x, obj.position.z, obj.position.y) + node.position = _chunk_to_godot_pos(obj.position) + node.quaternion = _chunk_to_godot_quat(Quaternion(obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w)) + node.scale = _chunk_to_godot_scale(obj.scale) _world_container.add_child(node) @@ -736,9 +1061,10 @@ func load_template(template_name: String) -> void: obj.get("properties", {}) ) _refresh_scene_visuals() - # Select mascot or first object to showcase inspector & gizmo if not core.model.payload.objects.is_empty(): - core.select_object(core.model.payload.objects[0].id) + var first_id: String = core.model.payload.objects[0].id + core.select_object(first_id) + _on_selection_changed(first_id) _set_gizmo_mode(TransformGizmo3DClass.Mode.TRANSLATE) _status_bar_label.text = "Loaded template: %s | Press [F1] for Controls" % project_title @@ -770,6 +1096,10 @@ func _update_simulation_ui(playing: bool) -> void: _btn_play.disabled = playing _btn_stop.disabled = not playing _status_bar_label.text = "Simulation: PLAYING (F5 to Stop)" if playing else "Simulation: STOPPED (Edit mode)" + if not playing: + if _camera_3d != null: + _camera_3d.current = true + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE # ── Menu Bar Dispatch ──────────────────────────────────────────────── @@ -789,7 +1119,7 @@ func _on_edit_menu_pressed(id: int) -> void: 0: core.undo(); _refresh_scene_visuals() 1: core.redo(); _refresh_scene_visuals() 2: _hierarchy_dock._on_duplicate_pressed() - 3: _hierarchy_dock._on_delete_pressed() + 3: _delete_selected_object(_get_selected_id()) func _on_view_menu_pressed(id: int) -> void: @@ -799,9 +1129,9 @@ func _on_view_menu_pressed(id: int) -> void: 2: _camera_3d.set_view_preset(StudioViewportCameraClass.ViewPreset.FRONT) 3: _camera_3d.set_view_preset(StudioViewportCameraClass.ViewPreset.SIDE) 4: - var sel_id = core.selection.get_selected_id() if core.selection.has_method("get_selected_id") else "" + var sel_id := _get_selected_id() if sel_id != "": - var n = _world_container.get_node_or_null(sel_id) + var n := _world_container.get_node_or_null(sel_id) if n is Node3D and _camera_3d != null: _camera_3d.focus_on(n.global_position) diff --git a/scripts/studio/studio_simulation_manager.gd b/scripts/studio/studio_simulation_manager.gd index db43501..d02915f 100644 --- a/scripts/studio/studio_simulation_manager.gd +++ b/scripts/studio/studio_simulation_manager.gd @@ -132,9 +132,18 @@ func _spawn_test_avatar() -> void: _spawned_avatar = p +func _physics_process(_delta: float) -> void: + if state == SimulationState.PLAYING and _spawned_avatar != null and is_instance_valid(_spawned_avatar): + if _spawned_avatar is Node3D and (_spawned_avatar as Node3D).global_position.y < -30.0: + (_spawned_avatar as Node3D).global_position = Vector3(0, 1.5, 0) + if _spawned_avatar is CharacterBody3D: + (_spawned_avatar as CharacterBody3D).velocity = Vector3.ZERO + + func is_playing() -> bool: return state == SimulationState.PLAYING func is_editing() -> bool: return state == SimulationState.EDIT +