From 30f1015d36cca74d8d4bc8b1193cfa44bd8c1530 Mon Sep 17 00:00:00 2001 From: lukiepookieee Date: Fri, 5 Jun 2026 16:47:32 -0700 Subject: [PATCH 1/5] menu grab edges --- project.godot | 4 ++ scenes/Player/player.tscn | 5 ++- scenes/UI/vr_menu.tscn | 14 +++++++ scripts/UI/vr_menu.gd | 77 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 95 insertions(+), 5 deletions(-) diff --git a/project.godot b/project.godot index de00f14..17bee15 100644 --- a/project.godot +++ b/project.godot @@ -48,6 +48,10 @@ version_control/autoload_on_startup=true enabled=PackedStringArray("res://addons/godot-xr-tools/plugin.cfg", "res://addons/import_cleaner/addons/ImportCleaner/plugin.cfg", "res://addons/volume_layered_shader/plugin.cfg") +[global_group] + +Player="" + [input] ui_menu={ diff --git a/scenes/Player/player.tscn b/scenes/Player/player.tscn index 3ef3efa..6f72bc7 100644 --- a/scenes/Player/player.tscn +++ b/scenes/Player/player.tscn @@ -14,7 +14,7 @@ [ext_resource type="PackedScene" uid="uid://dgdb1texynduw" path="res://addons/godot-xr-tools/objects/virtual_keyboard.tscn" id="13_bwjto"] [ext_resource type="PackedScene" uid="uid://wtpox7m5vu2b" path="res://addons/godot-xr-tools/effects/fade.tscn" id="14_606se"] -[node name="XROrigin3D" type="XROrigin3D" unique_id=1551876214] +[node name="XROrigin3D" type="XROrigin3D" unique_id=1551876214 groups=["Player"]] script = ExtResource("1_ivps1") [node name="XRCamera3D" type="XRCamera3D" parent="." unique_id=101239405] @@ -56,7 +56,7 @@ tracker = &"right_hand" [node name="RightHand" parent="RightHandController" unique_id=114374070 instance=ExtResource("2_4pri4")] [node name="FunctionPickup" parent="RightHandController" unique_id=1643629898 instance=ExtResource("2_v33fx")] -ranged_distance = 10.0 +ranged_distance = 5.45 ranged_angle = 45.0 impulse_factor = 2.0 @@ -76,6 +76,7 @@ show_laser = 2 laser_length = 1 show_target = true collision_mask = 5242884 +collide_with_areas = true [node name="VirtualKeyboard" parent="." unique_id=943683133 instance=ExtResource("13_bwjto")] transform = Transform3D(1, 0, 0, 0, 0.75354, 0.657402, 0, -0.657402, 0.75354, 0, -0.49593, -0.728043) diff --git a/scenes/UI/vr_menu.tscn b/scenes/UI/vr_menu.tscn index a56fd9e..ca7630b 100644 --- a/scenes/UI/vr_menu.tscn +++ b/scenes/UI/vr_menu.tscn @@ -71,3 +71,17 @@ viewport_size = Vector2(1024, 614) [node name="CollisionShape3D" type="CollisionShape3D" parent="Viewport2DIn3D/StaticBody3D" unique_id=1524281763] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -0.01) shape = SubResource("BoxShape3D_pointer") + +[node name="Edges" type="Node3D" parent="." unique_id=1701097937] + +[node name="Left" type="Area3D" parent="Edges" unique_id=393946160 groups=["menu_edge"]] +collision_layer = 1048577 + +[node name="Right" type="Area3D" parent="Edges" unique_id=1598732294 groups=["menu_edge"]] +collision_layer = 1048577 + +[node name="Top" type="Area3D" parent="Edges" unique_id=2039004928 groups=["menu_edge"]] +collision_layer = 1048577 + +[node name="Bottom" type="Area3D" parent="Edges" unique_id=1978189476 groups=["menu_edge"]] +collision_layer = 1048577 diff --git a/scripts/UI/vr_menu.gd b/scripts/UI/vr_menu.gd index 2a7791d..5820bd9 100644 --- a/scripts/UI/vr_menu.gd +++ b/scripts/UI/vr_menu.gd @@ -35,6 +35,8 @@ var _is_closing: bool = false ## Whether grabbing is enabled for this menu. var _grabbable: bool = true +var _edge_overlap_count = 0 + ## If true, the content Control is removed from the viewport before the menu ## is freed — keeping it alive for reuse (e.g., NetworkGateway). var _preserve_content: bool = false @@ -45,7 +47,8 @@ func _ready() -> void: if Engine.is_editor_hint(): return - + + enabled = false # Configure as a floating, non-physics object gravity_scale = 0.0 freeze = true @@ -54,6 +57,10 @@ func _ready() -> void: # XRToolsPickable config: hold to grab, restore frozen when released press_to_hold = true release_mode = ReleaseMode.FROZEN + var player = get_tree().get_first_node_in_group("Player") + var pointer = player.get_node("FunctionPointer") + + pointer.pointing_event.connect(_on_pointing_event) ## Configure the menu with a Control and options dictionary. @@ -80,10 +87,15 @@ func setup(control: Control, options: Dictionary = {}) -> void: # Position it behind the viewport body so the pointer raycast hits the # viewport StaticBody3D first (enabling click-through to 2D UI). var grab_shape: CollisionShape3D = $GrabCollision + + #if grab_shape: + #grab_shape.disabled = true if grab_shape and grab_shape.shape is BoxShape3D: var box = grab_shape.shape as BoxShape3D - box.size = Vector3(screen_size.x + 0.1, screen_size.y + 0.1, 0.05) - grab_shape.position.z = -0.04 + box.size = Vector3(screen_size.x - 0.1, screen_size.y - 0.1, 0.05) + #grab_shape.position.z = -0.04 + + adjust_area_collisions(screen_size) # Disable grab if not grabbable if not _grabbable: @@ -111,6 +123,56 @@ func setup(control: Control, options: Dictionary = {}) -> void: _viewport_2d._dirty = _viewport_2d._DIRTY_ALL & ~_viewport_2d._DIRTY_SCENE _viewport_2d._update_render() +func adjust_area_collisions(screen_size: Vector2) -> void: + var edge_thickness := 0.12 + var depth := 0.12 + var z := -0.04 + + var w := screen_size.x + var h := screen_size.y + + var data := { + "Left": { + "size": Vector3(edge_thickness, h, depth), + "pos": Vector3(-w * 0.5 + edge_thickness * 0.5, 0.0, z), + }, + "Right": { + "size": Vector3(edge_thickness, h, depth), + "pos": Vector3(w * 0.5 - edge_thickness * 0.5, 0.0, z), + }, + "Top": { + "size": Vector3(w, edge_thickness, depth), + "pos": Vector3(0.0, h * 0.5 - edge_thickness * 0.5, z), + }, + "Bottom": { + "size": Vector3(w, edge_thickness, depth), + "pos": Vector3(0.0, -h * 0.5 + edge_thickness * 0.5, z), + }, + } + + for area in $Edges.get_children(): + if not data.has(area.name): + continue + + area.monitoring = true + area.monitorable = true + area.add_to_group("menu_edge") + + var collision := area.get_node_or_null("CollisionShape3D") as CollisionShape3D + if collision == null: + collision = CollisionShape3D.new() + collision.name = "CollisionShape3D" + area.add_child(collision) + collision.debug_color = Color.RED + var box := collision.shape as BoxShape3D + if box == null: + box = BoxShape3D.new() + collision.shape = box + + box.size = data[area.name]["size"] + area.position = data[area.name]["pos"] + collision.position = Vector3.ZERO + ## Play the open (grow) animation. func open() -> void: @@ -139,6 +201,15 @@ func close() -> void: .set_trans(Tween.TRANS_CUBIC) _tween.tween_callback(_on_close_complete) +func _on_pointing_event(event) -> void: + for property in event.get_property_list(): + print(property.name) + var collider = event.target + + if collider and collider.is_in_group("menu_edge"): + enabled = true + else: + enabled = false ## Immediately close without animation (used when replacing menus). func close_immediate() -> void: From 7da15dc3f959732747d2df07fa8433c89e68d34a Mon Sep 17 00:00:00 2001 From: lukiepookieee Date: Mon, 8 Jun 2026 15:38:49 -0700 Subject: [PATCH 2/5] procedural UI padding --- scenes/UI/procedural_link_ui.tscn | 10 ++++++++-- scripts/UI/vr_menu.gd | 30 +++++++++++++++--------------- scripts/singletons/config.gd | 2 +- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/scenes/UI/procedural_link_ui.tscn b/scenes/UI/procedural_link_ui.tscn index 345033c..a2f894f 100644 --- a/scenes/UI/procedural_link_ui.tscn +++ b/scenes/UI/procedural_link_ui.tscn @@ -19,11 +19,17 @@ size_flags_vertical = 3 [node name="VBoxContainer2" type="VBoxContainer" parent="MarginContainer" unique_id=998015335] layout_mode = 2 -[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer/VBoxContainer2" unique_id=1051116865] +[node name="MarginContainer" type="MarginContainer" parent="MarginContainer/VBoxContainer2" unique_id=1370265326] layout_mode = 2 size_flags_vertical = 3 +theme_override_constants/margin_left = 24 +theme_override_constants/margin_right = 24 -[node name="ProceduralForm" type="VBoxContainer" parent="MarginContainer/VBoxContainer2/ScrollContainer" unique_id=638409883] +[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer/VBoxContainer2/MarginContainer" unique_id=1051116865] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="ProceduralForm" type="VBoxContainer" parent="MarginContainer/VBoxContainer2/MarginContainer/ScrollContainer" unique_id=638409883] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 diff --git a/scripts/UI/vr_menu.gd b/scripts/UI/vr_menu.gd index 5820bd9..f52ee94 100644 --- a/scripts/UI/vr_menu.gd +++ b/scripts/UI/vr_menu.gd @@ -124,29 +124,30 @@ func setup(control: Control, options: Dictionary = {}) -> void: _viewport_2d._update_render() func adjust_area_collisions(screen_size: Vector2) -> void: - var edge_thickness := 0.12 - var depth := 0.12 - var z := -0.04 + var edge_thickness = 0.12 + var depth = 0.12 + # using the same z as the grabCollision + var z = -0.04 - var w := screen_size.x - var h := screen_size.y + var width = screen_size.x + var height = screen_size.y var data := { "Left": { - "size": Vector3(edge_thickness, h, depth), - "pos": Vector3(-w * 0.5 + edge_thickness * 0.5, 0.0, z), + "size": Vector3(edge_thickness, height, depth), + "pos": Vector3(-width * 0.5 + edge_thickness * 0.5, 0.0, z), }, "Right": { - "size": Vector3(edge_thickness, h, depth), - "pos": Vector3(w * 0.5 - edge_thickness * 0.5, 0.0, z), + "size": Vector3(edge_thickness, height, depth), + "pos": Vector3(width * 0.5 - edge_thickness * 0.5, 0.0, z), }, "Top": { - "size": Vector3(w, edge_thickness, depth), - "pos": Vector3(0.0, h * 0.5 - edge_thickness * 0.5, z), + "size": Vector3(width, edge_thickness, depth), + "pos": Vector3(0.0, height * 0.5 - edge_thickness * 0.5, z), }, "Bottom": { - "size": Vector3(w, edge_thickness, depth), - "pos": Vector3(0.0, -h * 0.5 + edge_thickness * 0.5, z), + "size": Vector3(width, edge_thickness, depth), + "pos": Vector3(0.0, -height * 0.5 + edge_thickness * 0.5, z), }, } @@ -202,8 +203,7 @@ func close() -> void: _tween.tween_callback(_on_close_complete) func _on_pointing_event(event) -> void: - for property in event.get_property_list(): - print(property.name) + var collider = event.target if collider and collider.is_in_group("menu_edge"): diff --git a/scripts/singletons/config.gd b/scripts/singletons/config.gd index 8d4b857..ac2b33e 100644 --- a/scripts/singletons/config.gd +++ b/scripts/singletons/config.gd @@ -8,6 +8,6 @@ extends Node ## Ascribe-Link HTTP server URL ## Use 127.0.0.1 instead of localhost to avoid IPv6 issues on Windows #@export var ascribe_link_url = "http://127.0.0.1:8000" -@export var ascribe_link_url = "http://vision.lbl.gov:8000" +@export var ascribe_link_url = "http://127.0.0.1:8000" const CHUNK_SIZE = 20000 From 1a854fe9ae9d48b6a469ac30c679d38aa56d1c5c Mon Sep 17 00:00:00 2001 From: lukiepookieee Date: Mon, 8 Jun 2026 17:36:36 -0700 Subject: [PATCH 3/5] fixes so that the menus can be interacted with and grabbed --- scenes/UI/vr_menu.tscn | 14 ----- scripts/UI/vr_menu.gd | 122 ++++++++++++++++++++--------------------- 2 files changed, 59 insertions(+), 77 deletions(-) diff --git a/scenes/UI/vr_menu.tscn b/scenes/UI/vr_menu.tscn index ca7630b..a56fd9e 100644 --- a/scenes/UI/vr_menu.tscn +++ b/scenes/UI/vr_menu.tscn @@ -71,17 +71,3 @@ viewport_size = Vector2(1024, 614) [node name="CollisionShape3D" type="CollisionShape3D" parent="Viewport2DIn3D/StaticBody3D" unique_id=1524281763] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -0.01) shape = SubResource("BoxShape3D_pointer") - -[node name="Edges" type="Node3D" parent="." unique_id=1701097937] - -[node name="Left" type="Area3D" parent="Edges" unique_id=393946160 groups=["menu_edge"]] -collision_layer = 1048577 - -[node name="Right" type="Area3D" parent="Edges" unique_id=1598732294 groups=["menu_edge"]] -collision_layer = 1048577 - -[node name="Top" type="Area3D" parent="Edges" unique_id=2039004928 groups=["menu_edge"]] -collision_layer = 1048577 - -[node name="Bottom" type="Area3D" parent="Edges" unique_id=1978189476 groups=["menu_edge"]] -collision_layer = 1048577 diff --git a/scripts/UI/vr_menu.gd b/scripts/UI/vr_menu.gd index f52ee94..4888645 100644 --- a/scripts/UI/vr_menu.gd +++ b/scripts/UI/vr_menu.gd @@ -41,6 +41,8 @@ var _edge_overlap_count = 0 ## is freed — keeping it alive for reuse (e.g., NetworkGateway). var _preserve_content: bool = false +var _screen_size := Vector2(2.0, 1.2) +var _pointer_over_edge := false func _ready() -> void: super._ready() @@ -71,7 +73,7 @@ func setup(control: Control, options: Dictionary = {}) -> void: var viewport_size: Vector2 = options.get("viewport_size", Vector2(1024, 614)) _grabbable = options.get("grabbable", true) _preserve_content = options.get("preserve_content", false) - + _screen_size = screen_size # Configure the Viewport2DIn3D _viewport_2d.screen_size = screen_size _viewport_2d.viewport_size = viewport_size @@ -88,14 +90,13 @@ func setup(control: Control, options: Dictionary = {}) -> void: # viewport StaticBody3D first (enabling click-through to 2D UI). var grab_shape: CollisionShape3D = $GrabCollision - #if grab_shape: - #grab_shape.disabled = true + if grab_shape and grab_shape.shape is BoxShape3D: var box = grab_shape.shape as BoxShape3D - box.size = Vector3(screen_size.x - 0.1, screen_size.y - 0.1, 0.05) - #grab_shape.position.z = -0.04 + box.size = Vector3(screen_size.x + 0.1, screen_size.y + 0.1, 0.05) + grab_shape.position.z = -0.04 - adjust_area_collisions(screen_size) + # Disable grab if not grabbable if not _grabbable: @@ -123,58 +124,6 @@ func setup(control: Control, options: Dictionary = {}) -> void: _viewport_2d._dirty = _viewport_2d._DIRTY_ALL & ~_viewport_2d._DIRTY_SCENE _viewport_2d._update_render() -func adjust_area_collisions(screen_size: Vector2) -> void: - var edge_thickness = 0.12 - var depth = 0.12 - # using the same z as the grabCollision - var z = -0.04 - - var width = screen_size.x - var height = screen_size.y - - var data := { - "Left": { - "size": Vector3(edge_thickness, height, depth), - "pos": Vector3(-width * 0.5 + edge_thickness * 0.5, 0.0, z), - }, - "Right": { - "size": Vector3(edge_thickness, height, depth), - "pos": Vector3(width * 0.5 - edge_thickness * 0.5, 0.0, z), - }, - "Top": { - "size": Vector3(width, edge_thickness, depth), - "pos": Vector3(0.0, height * 0.5 - edge_thickness * 0.5, z), - }, - "Bottom": { - "size": Vector3(width, edge_thickness, depth), - "pos": Vector3(0.0, -height * 0.5 + edge_thickness * 0.5, z), - }, - } - - for area in $Edges.get_children(): - if not data.has(area.name): - continue - - area.monitoring = true - area.monitorable = true - area.add_to_group("menu_edge") - - var collision := area.get_node_or_null("CollisionShape3D") as CollisionShape3D - if collision == null: - collision = CollisionShape3D.new() - collision.name = "CollisionShape3D" - area.add_child(collision) - collision.debug_color = Color.RED - var box := collision.shape as BoxShape3D - if box == null: - box = BoxShape3D.new() - collision.shape = box - - box.size = data[area.name]["size"] - area.position = data[area.name]["pos"] - collision.position = Vector3.ZERO - - ## Play the open (grow) animation. func open() -> void: scale = Vector3(0.01, 0.01, 0.01) @@ -202,15 +151,62 @@ func close() -> void: .set_trans(Tween.TRANS_CUBIC) _tween.tween_callback(_on_close_complete) -func _on_pointing_event(event) -> void: +func _is_node_in_this_menu(node: Node) -> bool: + while node: + if node == self: + return true + node = node.get_parent() + return false + +func _is_local_pos_on_edge(local_pos: Vector3) -> bool: + var edge_thickness := 0.12 + # half the width and height to get where the edges are later + var half_w = _screen_size.x * 0.5 + var half_h = _screen_size.y * 0.5 + + var x = local_pos.x + var y = local_pos.y + + var inside_panel = abs(x) <= half_w and abs(y) <= half_h + if not inside_panel: + return false + # this is how we can check if we are on the top, bottom, left, right edge + var on_left = x <= -half_w + edge_thickness + var on_right = x >= half_w - edge_thickness + var on_top = y >= half_h - edge_thickness + var on_bottom = y <= -half_h + edge_thickness + + return on_left or on_right or on_top or on_bottom - var collider = event.target +func _on_pointing_event(event) -> void: + + var target = event.target + + if target == null: + _pointer_over_edge = false + if not is_picked_up(): + enabled = false + return - if collider and collider.is_in_group("menu_edge"): - enabled = true - else: + # make sure the pointer is hitting this menu or one of its children. + if not _is_node_in_this_menu(target): + return + + var hit_pos: Vector3 = event.position + var local_pos: Vector3 = to_local(hit_pos) + # if these local coordinates are where we classify as an edge + # we can then grab + _pointer_over_edge = _is_local_pos_on_edge(local_pos) + + if _pointer_over_edge: + enabled = _grabbable + elif not is_picked_up(): enabled = false + #if _edge_overlap_count == 0 and not is_picked_up(): + #enabled = false + + ## Immediately close without animation (used when replacing menus). func close_immediate() -> void: if _is_closing: From 7ae61e114523a7c0585ac98364aa22ff6535228e Mon Sep 17 00:00:00 2001 From: lukiepookieee Date: Wed, 10 Jun 2026 13:06:30 -0700 Subject: [PATCH 4/5] edge thickness --- scenes/Player/player.tscn | 2 +- scripts/UI/vr_menu.gd | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/scenes/Player/player.tscn b/scenes/Player/player.tscn index 6f72bc7..ca5d06c 100644 --- a/scenes/Player/player.tscn +++ b/scenes/Player/player.tscn @@ -56,7 +56,7 @@ tracker = &"right_hand" [node name="RightHand" parent="RightHandController" unique_id=114374070 instance=ExtResource("2_4pri4")] [node name="FunctionPickup" parent="RightHandController" unique_id=1643629898 instance=ExtResource("2_v33fx")] -ranged_distance = 5.45 +ranged_distance = 10.0 ranged_angle = 45.0 impulse_factor = 2.0 diff --git a/scripts/UI/vr_menu.gd b/scripts/UI/vr_menu.gd index 4888645..5785248 100644 --- a/scripts/UI/vr_menu.gd +++ b/scripts/UI/vr_menu.gd @@ -35,7 +35,6 @@ var _is_closing: bool = false ## Whether grabbing is enabled for this menu. var _grabbable: bool = true -var _edge_overlap_count = 0 ## If true, the content Control is removed from the viewport before the menu ## is freed — keeping it alive for reuse (e.g., NetworkGateway). @@ -43,6 +42,7 @@ var _preserve_content: bool = false var _screen_size := Vector2(2.0, 1.2) var _pointer_over_edge := false +@export var edge_thickness = 0.40 func _ready() -> void: super._ready() @@ -159,7 +159,6 @@ func _is_node_in_this_menu(node: Node) -> bool: return false func _is_local_pos_on_edge(local_pos: Vector3) -> bool: - var edge_thickness := 0.12 # half the width and height to get where the edges are later var half_w = _screen_size.x * 0.5 var half_h = _screen_size.y * 0.5 @@ -203,8 +202,6 @@ func _on_pointing_event(event) -> void: elif not is_picked_up(): enabled = false - #if _edge_overlap_count == 0 and not is_picked_up(): - #enabled = false ## Immediately close without animation (used when replacing menus). From e0ee0bebe0cf278e6bfba15d83de6a0aa9b13695 Mon Sep 17 00:00:00 2001 From: lukiepookieee Date: Thu, 18 Jun 2026 14:50:58 -0700 Subject: [PATCH 5/5] vr menu edge highlights, proc cancel button, documentation Added highlights on the VR menu whenever grabbable. Documentation has been added for performance metrics, procedural UI, tiff reader, updated the vr menu Proc ui cancel button --- .../godot-xr-tools/materials/highlight.tres | 32 ------- .../objects/highlight/highlight_ring.tres | 14 +-- addons/godot-xr-tools/objects/pickable.tscn | 6 +- scenes/UI/procedural_link_ui.tscn | 2 +- scenes/UI/vr_menu.tscn | 23 ++++- scripts/DataClasses/mesh_data.gd | 6 ++ scripts/UI/procedural_link_ui.gd | 92 ++++++++++++------- scripts/UI/vr_menu.gd | 34 +++++-- scripts/singletons/main.gd | 82 ++++++++++++++--- shaders/highlight.gdshader | 15 +++ shaders/highlight.gdshader.uid | 1 + testscenes/performance-metrics.gd | 84 ++++++++++++++--- tools/tiff-converter.py | 81 ++++++++++++++++ 13 files changed, 357 insertions(+), 115 deletions(-) create mode 100644 shaders/highlight.gdshader create mode 100644 shaders/highlight.gdshader.uid diff --git a/addons/godot-xr-tools/materials/highlight.tres b/addons/godot-xr-tools/materials/highlight.tres index 6213abb..712b5ba 100644 --- a/addons/godot-xr-tools/materials/highlight.tres +++ b/addons/godot-xr-tools/materials/highlight.tres @@ -1,35 +1,3 @@ [gd_resource type="ShaderMaterial" format=3 uid="uid://dyuaw57o8y3i"] -[sub_resource type="VisualShaderNodeColorParameter" id="VisualShaderNodeColorParameter_nl6jr"] -parameter_name = "Color" - -[sub_resource type="VisualShaderNodeVectorOp" id="VisualShaderNodeVectorOp_8dcmn"] -default_input_values = [0, Vector3(0, 0, 0), 1, Vector3(0.5, 0.5, 0.5)] -operator = 2 - -[sub_resource type="VisualShaderNodeFloatConstant" id="VisualShaderNodeFloatConstant_2331j"] -constant = 0.1 - -[sub_resource type="VisualShaderNodeFresnel" id="VisualShaderNodeFresnel_tghd5"] - -[sub_resource type="VisualShaderNodeVectorOp" id="VisualShaderNodeVectorOp_wy3ip"] -operator = 2 - -[sub_resource type="VisualShader" id="VisualShader_wb0u4"] -nodes/fragment/0/position = Vector2(660, 60) -nodes/fragment/2/node = SubResource("VisualShaderNodeColorParameter_nl6jr") -nodes/fragment/2/position = Vector2(40, 40) -nodes/fragment/3/node = SubResource("VisualShaderNodeVectorOp_8dcmn") -nodes/fragment/3/position = Vector2(360, 60) -nodes/fragment/4/node = SubResource("VisualShaderNodeFloatConstant_2331j") -nodes/fragment/4/position = Vector2(20, 180) -nodes/fragment/5/node = SubResource("VisualShaderNodeFresnel_tghd5") -nodes/fragment/5/position = Vector2(40, 340) -nodes/fragment/6/node = SubResource("VisualShaderNodeVectorOp_wy3ip") -nodes/fragment/6/position = Vector2(360, 220) -nodes/fragment/connections = PackedInt32Array(2, 0, 3, 0, 3, 0, 0, 0, 4, 0, 3, 1, 2, 0, 6, 0, 5, 0, 6, 1, 6, 0, 0, 5) - [resource] -render_priority = 0 -shader = SubResource("VisualShader_wb0u4") -shader_parameter/Color = Color(0.301961, 0.392157, 0.988235, 1) diff --git a/addons/godot-xr-tools/objects/highlight/highlight_ring.tres b/addons/godot-xr-tools/objects/highlight/highlight_ring.tres index ce16b22..c445529 100644 --- a/addons/godot-xr-tools/objects/highlight/highlight_ring.tres +++ b/addons/godot-xr-tools/objects/highlight/highlight_ring.tres @@ -1,10 +1,10 @@ -[gd_resource type="StandardMaterial3D" load_steps=2 format=2] +[gd_resource type="StandardMaterial3D" format=3 uid="uid://c6qkxj11rbbly"] -[ext_resource path="res://addons/godot-xr-tools/images/ring.png" type="Texture2D" id=1] +[ext_resource type="Texture2D" uid="uid://8s03ra8tp0hr" path="res://addons/godot-xr-tools/images/ring.png" id="1"] [resource] -flags_transparent = true -flags_unshaded = true -flags_no_depth_test = true -params_billboard_mode = 1 -albedo_texture = ExtResource( 1 ) +transparency = 1 +no_depth_test = true +shading_mode = 0 +albedo_texture = ExtResource("1") +billboard_mode = 1 diff --git a/addons/godot-xr-tools/objects/pickable.tscn b/addons/godot-xr-tools/objects/pickable.tscn index 94b96c6..d0d0d55 100644 --- a/addons/godot-xr-tools/objects/pickable.tscn +++ b/addons/godot-xr-tools/objects/pickable.tscn @@ -1,11 +1,11 @@ -[gd_scene load_steps=2 format=3 uid="uid://c8l60rnugru40"] +[gd_scene format=3 uid="uid://c8l60rnugru40"] [ext_resource type="Script" uid="uid://bfrlpbsqg5lqr" path="res://addons/godot-xr-tools/objects/pickable.gd" id="1"] -[node name="PickableObject" type="RigidBody3D"] +[node name="PickableObject" type="RigidBody3D" unique_id=1392687463] collision_layer = 4 collision_mask = 196615 freeze_mode = 1 script = ExtResource("1") -[node name="CollisionShape3D" type="CollisionShape3D" parent="."] +[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=1647278992] diff --git a/scenes/UI/procedural_link_ui.tscn b/scenes/UI/procedural_link_ui.tscn index a2f894f..b1d58b5 100644 --- a/scenes/UI/procedural_link_ui.tscn +++ b/scenes/UI/procedural_link_ui.tscn @@ -39,7 +39,7 @@ size_flags_vertical = 3 layout_mode = 2 alignment = 1 -[node name="Button" type="Button" parent="MarginContainer/VBoxContainer2/ButtonContainer" unique_id=856813400] +[node name="CancelButton" type="Button" parent="MarginContainer/VBoxContainer2/ButtonContainer" unique_id=856813400] unique_name_in_owner = true layout_mode = 2 text = "Cancel" diff --git a/scenes/UI/vr_menu.tscn b/scenes/UI/vr_menu.tscn index a56fd9e..232959b 100644 --- a/scenes/UI/vr_menu.tscn +++ b/scenes/UI/vr_menu.tscn @@ -2,24 +2,35 @@ [ext_resource type="Script" uid="uid://1id0q8i7gwbp" path="res://scripts/UI/vr_menu.gd" id="1_vrmenu"] [ext_resource type="Script" uid="uid://eavk7267igp7" path="res://addons/godot-xr-tools/objects/viewport_2d_in_3d.gd" id="2_v2d3d"] +[ext_resource type="Shader" uid="uid://bdcl4inxv0h1u" path="res://shaders/highlight.gdshader" id="2_w1ybm"] [ext_resource type="Script" uid="uid://cpl5yiyf1ypft" path="res://addons/godot-xr-tools/objects/viewport_2d_in_3d_body.gd" id="3_v2dbody"] [sub_resource type="BoxShape3D" id="BoxShape3D_grab"] resource_local_to_scene = true size = Vector3(2.1, 1.3, 0.05) +[sub_resource type="ShaderMaterial" id="ShaderMaterial_w1ybm"] +render_priority = 0 +shader = ExtResource("2_w1ybm") +shader_parameter/glow_color = Color(1, 0.8, 0.1, 1) +shader_parameter/edge_width = 0.02 +shader_parameter/glow_strength = 2.0 + +[sub_resource type="QuadMesh" id="QuadMesh_3cb7g"] +material = SubResource("ShaderMaterial_w1ybm") + [sub_resource type="QuadMesh" id="QuadMesh_screen"] resource_local_to_scene = true size = Vector2(2, 1.2) -[sub_resource type="ViewportTexture" id="ViewportTexture_5iaxp"] +[sub_resource type="ViewportTexture" id="ViewportTexture_4ojia"] viewport_path = NodePath("Viewport2DIn3D/Viewport") -[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_w1ybm"] +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_chs5w"] transparency = 1 cull_mode = 2 shading_mode = 0 -albedo_texture = SubResource("ViewportTexture_5iaxp") +albedo_texture = SubResource("ViewportTexture_4ojia") texture_filter = 1 [sub_resource type="BoxShape3D" id="BoxShape3D_pointer"] @@ -33,6 +44,7 @@ gravity_scale = 0.0 freeze = true freeze_mode = 1 script = ExtResource("1_vrmenu") +highlight_when_enabled = null release_mode = 1 [node name="GrabCollision" type="CollisionShape3D" parent="." unique_id=263098466] @@ -40,6 +52,9 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -0.02) shape = SubResource("BoxShape3D_grab") debug_color = Color(0.9927115, 0, 0.2546581, 0.41960785) +[node name="Highlight" type="MeshInstance3D" parent="." unique_id=872886749] +mesh = SubResource("QuadMesh_3cb7g") + [node name="Viewport2DIn3D" type="Node3D" parent="." unique_id=69049931] script = ExtResource("2_v2d3d") screen_size = Vector2(2, 1.2) @@ -59,7 +74,7 @@ render_target_update_mode = 1 [node name="Screen" type="MeshInstance3D" parent="Viewport2DIn3D" unique_id=1869170162] mesh = SubResource("QuadMesh_screen") -surface_material_override/0 = SubResource("StandardMaterial3D_w1ybm") +surface_material_override/0 = SubResource("StandardMaterial3D_chs5w") [node name="StaticBody3D" type="StaticBody3D" parent="Viewport2DIn3D" unique_id=1200919137] collision_layer = 5242881 diff --git a/scripts/DataClasses/mesh_data.gd b/scripts/DataClasses/mesh_data.gd index a15b0c3..964dcba 100644 --- a/scripts/DataClasses/mesh_data.gd +++ b/scripts/DataClasses/mesh_data.gd @@ -9,6 +9,7 @@ var normals: PackedFloat32Array var flip_normals: bool = false var _cached_mesh: ArrayMesh = null +var highlight_mesh: ArrayMesh = null func is_valid() -> bool: @@ -19,10 +20,12 @@ func is_valid() -> bool: func get_data() -> ArrayMesh: if _cached_mesh == null and is_valid(): _cached_mesh = MeshUtils.build_mesh(to_dict(), flip_normals) + highlight_mesh = _cached_mesh return _cached_mesh func invalidate_mesh() -> void: _cached_mesh = null + highlight_mesh = null ## Set data from a dictionary (as received from network or loaders). ## Supports both legacy format {"vertices": [...], "indices": [...]} @@ -54,6 +57,7 @@ func set_from_dict(data: Dictionary) -> void: normals = n _cached_mesh = null + highlight_mesh = null data_ready.emit() @@ -71,6 +75,7 @@ func clear() -> void: indices = PackedInt32Array() normals = PackedFloat32Array() _cached_mesh = null + highlight_mesh = null ## Set data from the binary envelope body. @@ -119,5 +124,6 @@ func set_from_bytes(preamble: Dictionary, body: PackedByteArray, offset: int) -> normals = PackedFloat32Array() _cached_mesh = null + highlight_mesh = null data_ready.emit() return true diff --git a/scripts/UI/procedural_link_ui.gd b/scripts/UI/procedural_link_ui.gd index 7913efd..e729a4e 100644 --- a/scripts/UI/procedural_link_ui.gd +++ b/scripts/UI/procedural_link_ui.gd @@ -1,6 +1,6 @@ extends Panel - -## Builds a form from a JSON Schema and defers submission to SceneManager. + +## ProceduralLinkUI -- Builds a form from a JSON Schema and defers submission to SceneManager. ## Submission, progress, and result loading are coordinated across peers by ## SceneManager RPCs, so each connected client runs the same state machine. @@ -9,10 +9,12 @@ signal loading_started signal loading_finished signal specimen_loaded(instance: Node) - +## References to the form and buttons to cancel/submit @onready var submit_button: Button = %SubmitButton @onready var proc_ui_container = %ProceduralForm +@onready var cancel_button: Button = %CancelButton +## Our schema is represented as a dictionary from python var _schema: Dictionary = {} var _schema_pending: bool = false @@ -26,6 +28,7 @@ var _progress_label: RichTextLabel = null var _last_params: Dictionary = {} var _submitted: bool = false +## Whenever we have an assigned schema, we need to build the UI @export var schema: Dictionary: set(value): _schema = value @@ -36,18 +39,23 @@ var _submitted: bool = false get: return _schema +## states for building the UI var in_range: bool = false var in_drop_down: bool = false var _is_applying_remote_value: bool = false +## we need references to the slider related components at different points var slider_dict: Dictionary = {} var slider_spin_box: SpinBox = null var slider_h_box: HBoxContainer = null +## Keep track of all controls added to the UI and the current container var param_controls: Dictionary = {} var current_container: Container func _ready() -> void: + # Connect signals and build UI on start up submit_button.pressed.connect(on_submit_pressed) + cancel_button.pressed.connect(on_cancel_pressed) if _schema_pending: _build_ui_from_schema.rpc() _schema_pending = false @@ -65,9 +73,10 @@ func _build_ui_from_schema() -> void: in_range = false in_drop_down = false param_controls.clear() - + # To build the UI, go through each property and make the UI for that property + # We need to start with a label that goes into the left side, and we can fix + # the scaling and visuals along the way. for keyword in _schema["properties"].keys(): - var properties_dict: Dictionary = _schema["properties"][keyword] var new_label = Label.new() current_container = HBoxContainer.new() @@ -86,7 +95,7 @@ func _build_ui_from_schema() -> void: separator.size_flags_horizontal = Control.SIZE_EXPAND_FILL proc_ui_container.add_child(separator) - +## helper to set up drop down menus if we come across an enum func setup_drop_down(enum_possibilities: Array) -> OptionButton: var drop_down: OptionButton = OptionButton.new() drop_down.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN @@ -95,7 +104,11 @@ func setup_drop_down(enum_possibilities: Array) -> OptionButton: drop_down.add_item(possibility) return drop_down - +## helper to handle more simple data types in schema -- string, int, bool, etc. +## Boolean --> Checkbox +## number (int/float) --> SpinBox +## textarea --> multiline textbox +## string --> single line textbox func set_property_types(type, default, param_name: String): match type: "boolean": @@ -188,13 +201,14 @@ func _send_param_value(param_name: String, value) -> void: func create_slider(slider_values: Array, initial_position, param_name: String): - var slider_row := HBoxContainer.new() - var slider_container := VBoxContainer.new() - + # sliders are made up of a spin box, and the slider itself next to each other + var slider_row = HBoxContainer.new() + var slider_container = VBoxContainer.new() + # we want it to expand the whole viewport slider_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL slider_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL slider_row.add_theme_constant_override("separation", 8) - + # convert true false values in schema if initial_position is String: if initial_position == "true": initial_position = 1.0 @@ -202,14 +216,14 @@ func create_slider(slider_values: Array, initial_position, param_name: String): initial_position = 0.0 else: return - - var slider := HSlider.new() + # a slider is made up of the two values in the passed in array + var slider = HSlider.new() slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL - slider.ticks_on_borders = true + slider.ticks_on_gorders = true slider.min_value = slider_values[0] slider.max_value = slider_values[1] slider.value = initial_position - + # same for spin box var spin_box := SpinBox.new() spin_box.min_value = slider_values[0] spin_box.max_value = slider_values[1] @@ -218,28 +232,28 @@ func create_slider(slider_values: Array, initial_position, param_name: String): slider_row.add_child(slider) slider_row.add_child(spin_box) - - var range_row := HBoxContainer.new() + # we need to have a new row under the slider to show the ranges + var range_row = HBoxContainer.new() range_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL range_row.add_theme_constant_override("separation", 8) - var slider_range := HBoxContainer.new() + var slider_range = HBoxContainer.new() slider_range.size_flags_horizontal = Control.SIZE_EXPAND_FILL - var min_label := Label.new() + var min_label = Label.new() min_label.text = str(slider.min_value) - - var spacer := Control.new() + # this lets us have a gap between the two labels + var spacer = Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL - var max_label := Label.new() + var max_label = Label.new() max_label.text = str(slider.max_value) max_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT slider_range.add_child(min_label) slider_range.add_child(spacer) slider_range.add_child(max_label) - + # this lets us have a gap between slider ranges and spin box var spinbox_spacer := Control.new() spinbox_spacer.custom_minimum_size.x = spin_box.custom_minimum_size.x @@ -250,8 +264,9 @@ func create_slider(slider_values: Array, initial_position, param_name: String): slider_container.add_child(range_row) slider_dict[slider] = spin_box + # add this to the param controls for later param_controls[param_name] = slider - + # connect signal so we can have the spin box update slider and vice versa slider.value_changed.connect(func(new_value: float): if _is_applying_remote_value: return @@ -266,9 +281,10 @@ func create_slider(slider_values: Array, initial_position, param_name: String): slider.value = new_value _send_param_value(param_name, new_value) ) - + # add to the container (VBOX) current_container.add_child(slider_container) - + +## ProcUI supports multiline text edits, but we need to do set ups first func setup_autogrow_text_edit(text_edit: TextEdit, min_lines: int = 2, max_lines: int = 8) -> void: text_edit.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY text_edit.scroll_fit_content_height = true @@ -288,7 +304,8 @@ func _update_text_edit_height(text_edit: TextEdit, min_lines: int, max_lines: in var bottom_padding := 8 text_edit.custom_minimum_size.y = line_count * line_height + top_padding + bottom_padding - + +## Schema can have default values, the procUI builds with these in mind func _get_default_for_type(properties: Dictionary) -> Variant: if properties.has('default'): return properties['default'] @@ -309,10 +326,11 @@ func _get_default_for_type(properties: Dictionary) -> Variant: return "" - +## Gets called for each parameter in the schema +## the properties variable is the dictionary containing keys for 'enum' or 'type' func make_ui(properties: Dictionary, param_name: String): var default_value = _get_default_for_type(properties) - + # creates drop down for enums if properties.has("enum"): var drop_down_menu = setup_drop_down(properties["enum"]) drop_down_menu.selected = properties["enum"].find(default_value) @@ -322,14 +340,16 @@ func make_ui(properties: Dictionary, param_name: String): ) param_controls[param_name] = drop_down_menu return - + # make a slider if we have min and max values if properties.get("type") == "number" and properties.has("minimum") and properties.has("maximum"): create_slider([properties["minimum"], properties["maximum"]], default_value, param_name) return - + # send to a more general function, where we can make UI for basic types if properties.has("type"): set_property_types(properties["type"], default_value, param_name) +## function to extract parameters from the param_dict variable +## this lets us properly submit those values to specimen generation func extract_parameters() -> Dictionary: var param_dict: Dictionary = {} @@ -349,7 +369,7 @@ func extract_parameters() -> Dictionary: return param_dict - +## signal functions to tie slider and spinboxes together func on_slider_value_changed(new_value: float, slider: HSlider) -> void: if slider_dict.has(slider): var spin_box: SpinBox = slider_dict[slider] @@ -360,9 +380,9 @@ func on_spinbox_value_changed(new_value: float, slider: HSlider) -> void: if slider_dict.has(slider): slider.value = new_value - +## unused for now -- wanted virtual keyboard func _on_text_section_entered(): - print("keyboard") + DisplayServer.virtual_keyboard_show("") # --------------------------------------------------------------------------- @@ -380,7 +400,9 @@ func on_submit_pressed() -> void: _last_params = extract_parameters() ui_accept.emit(_last_params) SceneManager.request_submit(function_name, _last_params) - + +func on_cancel_pressed() -> void: + SceneManager.cancel_procedural_request.rpc() ## Called via SceneManager RPC once any peer has submitted: hide the form, ## show a progress panel so every client sees the same loading screen. diff --git a/scripts/UI/vr_menu.gd b/scripts/UI/vr_menu.gd index 5785248..0b33d5b 100644 --- a/scripts/UI/vr_menu.gd +++ b/scripts/UI/vr_menu.gd @@ -25,6 +25,8 @@ var _content: Control = null ## Reference to the Viewport2DIn3D child. @onready var _viewport_2d: XRToolsViewport2DIn3D = $Viewport2DIn3D +@onready var highlight_mesh: MeshInstance3D = $Highlight + ## Animation tween. var _tween: Tween @@ -43,6 +45,8 @@ var _preserve_content: bool = false var _screen_size := Vector2(2.0, 1.2) var _pointer_over_edge := false @export var edge_thickness = 0.40 +@export var highlight_when_enabled: bool = true + func _ready() -> void: super._ready() @@ -50,12 +54,12 @@ func _ready() -> void: if Engine.is_editor_hint(): return - enabled = false + _set_grabbable_enabled(false) # Configure as a floating, non-physics object gravity_scale = 0.0 freeze = true freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC - + highlight_mesh.visible = false # XRToolsPickable config: hold to grab, restore frozen when released press_to_hold = true release_mode = ReleaseMode.FROZEN @@ -95,12 +99,14 @@ func setup(control: Control, options: Dictionary = {}) -> void: var box = grab_shape.shape as BoxShape3D box.size = Vector3(screen_size.x + 0.1, screen_size.y + 0.1, 0.05) grab_shape.position.z = -0.04 + highlight_mesh.mesh.size = screen_size + highlight_mesh.position.z = 0.06 # Disable grab if not grabbable if not _grabbable: - enabled = false # XRToolsPickable.enabled — prevents pick_up + _set_grabbable_enabled(false) # XRToolsPickable.enabled — prevents pick_up # Add the Control to the viewport and wire up the Viewport2DIn3D render # pipeline. Normally Viewport2DIn3D expects a PackedScene set via its @@ -150,14 +156,16 @@ func close() -> void: .set_ease(Tween.EASE_IN) \ .set_trans(Tween.TRANS_CUBIC) _tween.tween_callback(_on_close_complete) - + +## helper for edge detection func _is_node_in_this_menu(node: Node) -> bool: while node: if node == self: return true node = node.get_parent() return false - + +## VRMenu Edge detection func _is_local_pos_on_edge(local_pos: Vector3) -> bool: # half the width and height to get where the edges are later var half_w = _screen_size.x * 0.5 @@ -177,6 +185,15 @@ func _is_local_pos_on_edge(local_pos: Vector3) -> bool: return on_left or on_right or on_top or on_bottom + +func _set_grabbable_enabled(value: bool) -> void: + enabled = value + + if highlight_mesh: + highlight_mesh.visible = value and highlight_when_enabled + +## Checking if something is grabbable depends on the pointer event +## If pointer is on an edge, then enabled is set to true, false otherwise func _on_pointing_event(event) -> void: var target = event.target @@ -184,7 +201,7 @@ func _on_pointing_event(event) -> void: if target == null: _pointer_over_edge = false if not is_picked_up(): - enabled = false + _set_grabbable_enabled(false) return # make sure the pointer is hitting this menu or one of its children. @@ -198,9 +215,9 @@ func _on_pointing_event(event) -> void: _pointer_over_edge = _is_local_pos_on_edge(local_pos) if _pointer_over_edge: - enabled = _grabbable + _set_grabbable_enabled(_grabbable) elif not is_picked_up(): - enabled = false + _set_grabbable_enabled(false) @@ -223,6 +240,7 @@ func accept() -> void: accepted.emit() + func _on_close_complete() -> void: # Preserve content if requested — remove it from the viewport before # queue_free destroys the entire VRMenu tree. diff --git a/scripts/singletons/main.gd b/scripts/singletons/main.gd index 7b0fda9..0db6cf7 100644 --- a/scripts/singletons/main.gd +++ b/scripts/singletons/main.gd @@ -28,6 +28,8 @@ var _is_submitter: bool = false ## would be freed before the HTTP polling loop finishes. var _active_job_client: AscribeLinkClient = null +var procedural_request_id = 0 +var procedural_cancelled = false func _ready() -> void: world_3d = $/root/Main/Sketchfab_Scene @@ -195,6 +197,8 @@ func _position_specimen(specimen: Specimen) -> void: ## Show the procedural-parameter form on every peer for the given specimen. @rpc("any_peer", "call_local", "reliable") func show_procedural_ui(specimen_id: String, metadata: Dictionary) -> void: + procedural_request_id += 1 + procedural_cancelled = false _close_procedural_ui() _active_specimen_id = specimen_id @@ -224,14 +228,20 @@ func request_submit(function_name: String, params: Dictionary) -> void: push_warning("SceneManager: request_submit called with no active procedural UI") return _is_submitter = true + procedural_cancelled = false + procedural_request_id += 1 + + var request_id = procedural_request_id var room_id = "ascribe" if Config.webrtcroomname: room_id = Config.webrtcroomname - specimen_job_submitted.rpc(function_name, params, room_id) + specimen_job_submitted.rpc(function_name, params, room_id, request_id) @rpc("any_peer", "call_local", "reliable") -func specimen_job_submitted(function_name: String, params: Dictionary, room_id: String) -> void: +func specimen_job_submitted(function_name: String, params: Dictionary, room_id: String, request_id: int) -> void: + procedural_request_id = request_id + procedural_cancelled = false _active_function_name = function_name _active_room_id = room_id _active_params = params @@ -240,31 +250,55 @@ func specimen_job_submitted(function_name: String, params: Dictionary, room_id: # Only the peer that submitted (via request_submit) runs the job. if _is_submitter: - _run_job(function_name, params, room_id) + _run_job(function_name, params, room_id, request_id) -func _run_job(function_name: String, params: Dictionary, room_id: String) -> void: +func _run_job(function_name: String, params: Dictionary, room_id: String, request_id: int) -> void: _active_job_client = AscribeLinkClient.new(Config.ascribe_link_url) _active_job_client.setup(self) - _active_job_client.job_progress.connect(_on_submitter_progress) - _active_job_client.job_complete.connect(_on_submitter_complete) - _active_job_client.job_error.connect(_on_submitter_error) + # pass in the requestID for the sake of the procedural ui + _active_job_client.job_progress.connect(func(text: String): + _on_submitter_progress(text, request_id) + ) + + _active_job_client.job_complete.connect(func(result: Dictionary): + _on_submitter_complete(result, request_id) + ) + + _active_job_client.job_error.connect(func(error: String): + _on_submitter_error(error, request_id) + ) + _active_job_client.run_job(function_name, params, room_id) -func _on_submitter_progress(text: String) -> void: +func _on_submitter_progress(text: String, request_id: int) -> void: + # we need to cancel progress if the user has hit the cancel button + if procedural_cancelled or request_id != procedural_request_id: + return + specimen_progress.rpc(text) -func _on_submitter_complete(result: Dictionary) -> void: +func _on_submitter_complete(result: Dictionary, request_id: int) -> void: + # We should not submit anything + if procedural_cancelled or request_id != procedural_request_id: + _active_job_client = null + return # Every peer (including the submitter) fetches the result from the room # cache via the /data endpoint. The submitter just finished computing it, # so the cache is warm for everyone. - specimen_job_done.rpc(_active_specimen_id, _active_function_name, _active_room_id) + specimen_job_done.rpc(_active_specimen_id, _active_function_name, _active_room_id, request_id) _active_job_client = null -func _on_submitter_error(error: String) -> void: +func _on_submitter_error(error: String, request_id: int) -> void: + # No error if procedural cancel button has been pressed + # Nothing to Submit + if procedural_cancelled or request_id != procedural_request_id: + _active_job_client = null + return + specimen_job_error.rpc(error) _active_job_client = null @@ -276,9 +310,11 @@ func specimen_progress(text: String) -> void: @rpc("any_peer", "call_local", "reliable") -func specimen_job_done(specimen_id: String, function_name: String, room_id: String) -> void: +func specimen_job_done(specimen_id: String, function_name: String, room_id: String, request_id: int) -> void: + if procedural_cancelled or request_id != procedural_request_id: + return # Each peer independently fetches the cached result and loads it as a mesh. - _fetch_and_load_result(specimen_id, function_name, room_id) + _fetch_and_load_result(specimen_id, function_name, room_id, request_id) @rpc("any_peer", "call_local", "reliable") @@ -287,8 +323,11 @@ func specimen_job_error(error: String) -> void: _active_procedural_ui.show_error(error) -func _fetch_and_load_result(specimen_id: String, function_name: String, room_id: String) -> void: +func _fetch_and_load_result(specimen_id: String, function_name: String, room_id: String, request_id: int) -> void: var metadata := await _fetch_metadata_for_active(specimen_id) + # don't need to load if we cancelled + if procedural_cancelled or request_id != procedural_request_id: + return var params_json := JSON.stringify(_active_params) var query := "params=%s&room_id=%s" % [params_json.uri_encode(), room_id.uri_encode()] @@ -299,6 +338,10 @@ func _fetch_and_load_result(specimen_id: String, function_name: String, room_id: if packed == null: push_error("SceneManager: Failed to load %s" % scene_path) return + + if procedural_cancelled or request_id != procedural_request_id: + return + var specimen: Specimen = packed.instantiate() # Refuse mixed scale modes. @@ -343,6 +386,17 @@ func _fetch_metadata_for_active(specimen_id: String) -> Dictionary: client.setup(self) return await client.fetch_specimen_metadata(specimen_id) +@rpc("any_peer", "call_local", "reliable") +func cancel_procedural_request() -> void: + procedural_cancelled = true + procedural_request_id += 1 + + MenuManager.close_menu("specimen") + show_mainmenu() + + # optional, depending on your SceneManager: + # cancel_current_specimen_load() + # clear_pending_specimen() func _close_procedural_ui() -> void: if _active_procedural_ui: diff --git a/shaders/highlight.gdshader b/shaders/highlight.gdshader new file mode 100644 index 0000000..39dc787 --- /dev/null +++ b/shaders/highlight.gdshader @@ -0,0 +1,15 @@ +shader_type spatial; +render_mode unshaded, cull_disabled, depth_draw_never, blend_mix; + +uniform vec4 glow_color : source_color = vec4(1.0, 0.8, 0.1, 1.0); +uniform float edge_width = 0.04; +uniform float glow_strength = 2.0; + +void fragment() { + float d = min(min(UV.x, 1.0 - UV.x), min(UV.y, 1.0 - UV.y)); + float edge = 1.0 - smoothstep(0.0, edge_width, d); + + ALBEDO = glow_color.rgb; + EMISSION = glow_color.rgb * edge * glow_strength; + ALPHA = edge * glow_color.a; +} \ No newline at end of file diff --git a/shaders/highlight.gdshader.uid b/shaders/highlight.gdshader.uid new file mode 100644 index 0000000..4e13688 --- /dev/null +++ b/shaders/highlight.gdshader.uid @@ -0,0 +1 @@ +uid://bdcl4inxv0h1u diff --git a/testscenes/performance-metrics.gd b/testscenes/performance-metrics.gd index cfc7e6a..ac1b49a 100644 --- a/testscenes/performance-metrics.gd +++ b/testscenes/performance-metrics.gd @@ -1,80 +1,142 @@ extends Node +## Mesh whose material will be swapped during benchmarking. @export var mesh: MeshInstance3D + +## Material containing the shader being evaluated. @export var shader_material: Material + +## Reference material used as the performance baseline. @export var baseline_material: Material +## Number of frames ignored before sampling begins. +## This allows rendering and engine state to stabilize. @export var warmup_frames := 6000 + +## Number of frames sampled during each benchmark phase. @export var sample_frames := 60000 +## Benchmark state machine. +## +## BASELINE: +## Measures performance using the baseline material. +## +## SHADER: +## Measures performance using the shader material. +## +## DONE: +## Benchmark completed. enum Phase { BASELINE, SHADER, DONE } +## Current benchmark phase. var phase = Phase.BASELINE + +## Counts frames elapsed within the current phase. var frame_count = 0 + +## Stores collected TIME_PROCESS samples (milliseconds). var samples: Array[float] = [] +## Average TIME_PROCESS measured during the baseline phase. var baseline_avg := 0.0 + +## Average TIME_PROCESS measured during the shader phase. var shader_avg := 0.0 +## Initializes the benchmark and applies the baseline material. func _ready(): Input.mouse_mode = Input.MOUSE_MODE_VISIBLE print("Starting shader benchmark...") _apply_baseline() +## Collects TIME_PROCESS samples each frame. +## +## The benchmark proceeds as follows: +## 1. Ignore warmup frames. +## 2. Record sample_frames samples. +## 3. Compute average process time. +## 4. Switch from BASELINE to SHADER phase. +## 5. Repeat sampling. +## 6. Output results. func _process(_delta): - var frame_time_ms = Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0 - + var frame_time_ms = Performance.get_monitor( + Performance.TIME_PROCESS + ) * 1000.0 + frame_count += 1 - - # Skip warmup frames + + # Ignore warmup period. if frame_count > warmup_frames: samples.append(frame_time_ms) - + + # End phase after collecting all samples. if frame_count >= warmup_frames + sample_frames: var avg = _average(samples) - + match phase: Phase.BASELINE: baseline_avg = avg print("Baseline avg (ms): ", baseline_avg) _start_shader_phase() - + Phase.SHADER: shader_avg = avg print("Shader avg (ms): ", shader_avg) _finish() - + +## Applies the baseline material and resets benchmark counters. func _apply_baseline(): mesh.material_override = baseline_material _reset() +## Switches to the shader test phase. +## +## Applies the shader material and clears previously +## collected samples so a new measurement can begin. func _start_shader_phase(): phase = Phase.SHADER mesh.material_override = shader_material _reset() +## Computes and prints final benchmark results. +## +## Reported metrics: +## - Baseline average process time +## - Shader average process time +## - Difference between the two +## - Approximate FPS equivalent func _finish(): var delta = shader_avg - baseline_avg - + print("\n=== RESULT ===") print("Baseline: ", baseline_avg, " ms") print("Shader: ", shader_avg, " ms") print("Cost: ", delta, " ms/frame") - print("FPS impact: ", 1000.0 / baseline_avg, " → ", 1000.0 / shader_avg) - + print( + "FPS impact: ", + 1000.0 / baseline_avg, + " → ", + 1000.0 / shader_avg + ) + phase = Phase.DONE set_process(false) +## Resets frame counters and clears collected samples. +## +## Called whenever a new benchmark phase begins. func _reset(): frame_count = 0 samples.clear() + func _average(arr: Array[float]) -> float: var sum := 0.0 for v in arr: sum += v + return sum / arr.size() diff --git a/tools/tiff-converter.py b/tools/tiff-converter.py index 4826507..624e4ec 100644 --- a/tools/tiff-converter.py +++ b/tools/tiff-converter.py @@ -6,6 +6,87 @@ from PIL import Image import tifffile +""" +TIFF to JPEG Volume Converter + +This script converts TIFF image data into JPEG image slices. + +It supports two TIFF input layouts: + +1. A directory of 2D TIFF files + - Each TIFF is treated as one slice of a larger 3D volume. + - All 2D TIFF files are loaded and stacked into one 3D NumPy array. + - The combined volume has shape: + + z, y, x + +2. Individual 3D TIFF stack files + - Each 3D TIFF is treated as an already-combined volume. + - The volume is processed directly. + +Usage: + python tiff-converter.py + +Arguments: + input_dir: + Directory containing .tif or .tiff files. + + output_dir: + Directory where JPEG files will be written. + The directory is created if it does not already exist. + + x_factor: + Downsampling factor for the x-axis, or image width. + Use 1 to keep full resolution. + Use 2 to keep every 2nd pixel. + Use 4 to keep every 4th pixel. + + y_factor: + Downsampling factor for the y-axis, or image height. + Use 1 to keep full resolution. + + z_factor: + Downsampling factor for the z-axis, or stack depth. + Use 1 to keep every slice. + Use 2 to keep every 2nd slice. + + frame_steps: + Controls which frames are saved as JPEGs after downsampling. + Use 1 to save every frame. + Use 2 to save every 2nd frame. + Use 10 to save every 10th frame. + + invert: + Whether to invert image brightness. + Use 0 for normal brightness. + Use 1 to invert, so dark becomes light and light becomes dark. + +Example: + python tiff-converter.py ./input_tiffs ./output_jpegs 2 2 2 1 0 + + This reads TIFFs from ./input_tiffs, downsamples x/y/z by 2, + saves every frame, does not invert brightness, and writes JPEGs + to ./output_jpegs. + +Processing flow: + 1. Read TIFF files from the input directory. + 2. If TIFFs are 2D, collect them and stack them into one 3D volume. + 3. If a TIFF is already 3D, process it directly. + 4. Downsample the volume using NumPy slicing: + + volume[::z_factor, ::y_factor, ::x_factor] + + 5. Normalize the image intensity values to 8-bit range, 0-255. + 6. Optionally invert brightness. + 7. Save selected frames as JPEG files. + +Notes: + - JPEG images must be 8-bit, so the script normalizes input data. + - The downsampling method is simple decimation, meaning it keeps every nth voxel. + It does not average or interpolate between pixels. + - Output JPEG files are named using the source file or combined stack name, + followed by the frame index. +""" input_dir = Path(sys.argv[1]) output_dir = Path(sys.argv[2])