diff --git a/addons/godot-xr-tools/materials/highlight.tres b/addons/godot-xr-tools/materials/highlight.tres index 6213abb6..712b5bae 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 ce16b222..c445529c 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 94b96c69..d0d0d554 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/project.godot b/project.godot index de00f145..17bee150 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 3ef3efad..ca5d06cf 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] @@ -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/procedural_link_ui.tscn b/scenes/UI/procedural_link_ui.tscn index 345033cb..b1d58b5b 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 @@ -33,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 a56fd9e4..232959b4 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 a15b0c30..964dcbac 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 7913efd5..e729a4e5 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 2a7791d2..0b33d5b2 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 @@ -35,25 +37,36 @@ var _is_closing: bool = false ## Whether grabbing is enabled for this menu. var _grabbable: bool = true + ## 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 +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() if Engine.is_editor_hint(): return - + + _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 + 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. @@ -64,7 +77,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 @@ -80,14 +93,20 @@ 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 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 + 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 @@ -111,7 +130,6 @@ func setup(control: Control, options: Dictionary = {}) -> void: _viewport_2d._dirty = _viewport_2d._DIRTY_ALL & ~_viewport_2d._DIRTY_SCENE _viewport_2d._update_render() - ## Play the open (grow) animation. func open() -> void: scale = Vector3(0.01, 0.01, 0.01) @@ -138,6 +156,69 @@ 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 + 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 + + +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 + + if target == null: + _pointer_over_edge = false + if not is_picked_up(): + _set_grabbable_enabled(false) + return + + # 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: + _set_grabbable_enabled(_grabbable) + elif not is_picked_up(): + _set_grabbable_enabled(false) + ## Immediately close without animation (used when replacing menus). @@ -159,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/config.gd b/scripts/singletons/config.gd index 8d4b8579..ac2b33eb 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 diff --git a/scripts/singletons/main.gd b/scripts/singletons/main.gd index 7b0fda90..0db6cf74 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 00000000..39dc787d --- /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 00000000..4e13688f --- /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 cfc7e6ac..ac1b49aa 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 48265076..624e4ec3 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])