@tool class_name RoomLayout extends Control signal item_selected( item_id: StringName ) signal wall_selected( wall_id: StringName ) signal layout_changed signal wall_geometry_edit_requested( wall_id: StringName, old_start: Vector2, old_end: Vector2, new_start: Vector2, new_end: Vector2 ) signal boundary_geometry_edit_requested( old_points: PackedVector2Array, new_points: PackedVector2Array ) signal edit_rejected(message: String) signal zoom_changed( zoom_value: float ) signal item_position_edit_requested( item_id: StringName, old_position: Vector2, new_position: Vector2 ) signal refresh_requested signal binding_route_edit_requested( cabinet_id: StringName, action_id: StringName, device_id: StringName, old_points: PackedVector2Array, new_points: PackedVector2Array ) @export var item_scene: PackedScene @export var snap_enabled: bool = true @export_group("View") @export_range(10.0, 500.0, 1.0) var pixels_per_meter: float = 100.0 @export_range(0.01, 2.0, 0.001) var grid_size_meters: float = 0.3048 var view_offset: Vector2 = Vector2( 80.0, 80.0 ) var selected_item_id: StringName = &"" var selected_wall_id: StringName = &"" var room: RoomDefinition var scenario: ScenarioDefinition var item_nodes: Dictionary = {} # # Runtime/editor simulation activity. # var active_bindings: Dictionary = {} # # Editor selection only. # var highlighted_bindings: Dictionary = {} var binding_labels: Array[Label] = [] var show_connections: bool = true var selected_binding_key: String = "" var hovered_binding_route_point: int = -1 var active_binding_route_point: int = -1 var selected_binding_route_point: int = -1 var binding_route_drag_original_points := ( PackedVector2Array() ) enum EditMode { SELECT, DRAW_WALL, EDIT_BOUNDARY, ADD_BOUNDARY_POINT, DELETE_BOUNDARY_POINT } var edit_mode: EditMode = EditMode.SELECT enum WallHandle { NONE, START, END, } var active_wall_handle: WallHandle = WallHandle.NONE var hovered_wall_handle: WallHandle = WallHandle.NONE var wall_handle_drag_wall_id: StringName = &"" var wall_handle_drag_original_start: Vector2 var wall_handle_drag_original_end: Vector2 var wall_draw_start: Vector2 var wall_draw_current: Vector2 var is_drawing_wall: bool = false var zoom: float = 1.0 var is_panning: bool = false var pan_last_mouse_position: Vector2 var hovered_boundary_vertex: int = -1 var active_boundary_vertex: int = -1 var boundary_drag_original_points: PackedVector2Array = ( PackedVector2Array() ) var hovered_boundary_edge: int = -1 func _ready() -> void: focus_mode = Control.FOCUS_ALL func configure( room_definition: RoomDefinition, scenario_definition: ScenarioDefinition ) -> void: room = room_definition scenario = scenario_definition _rebuild() func _rebuild() -> void: for child in get_children(): child.queue_free() item_nodes.clear() binding_labels.clear() active_bindings.clear() highlighted_bindings.clear() selected_binding_key = "" hovered_binding_route_point = -1 active_binding_route_point = -1 selected_binding_route_point = -1 binding_route_drag_original_points = ( PackedVector2Array() ) selected_item_id = &"" if room == null: return _build_cabinets() _build_devices() _build_binding_labels() queue_redraw() func _build_binding_labels() -> void: if room == null: return for binding in room.bindings: if binding == null: continue if not item_nodes.has( binding.cabinet_id ): continue if not item_nodes.has( binding.device_id ): continue var label := Label.new() label.text = String( binding.hardware_action_id ) label.mouse_filter = ( Control.MOUSE_FILTER_IGNORE ) add_child(label) binding_labels.append(label) _position_binding_label( binding, label ) label.move_to_front() func _position_binding_label( binding: RoomDeviceBinding, label: Label ) -> void: var route_points := ( _get_binding_route_points( binding ) ) if route_points.size() < 2: return var midpoint := ( _get_polyline_midpoint( route_points ) ) label.position = ( midpoint - Vector2( label.size.x * 0.5, 18.0 ) ) func _get_polyline_midpoint( points: PackedVector2Array ) -> Vector2: if points.is_empty(): return Vector2.ZERO if points.size() == 1: return points[0] var total_length: float = 0.0 for index in range( points.size() - 1 ): total_length += ( points[index].distance_to( points[index + 1] ) ) if total_length <= 0.0: return points[0] var target_length := ( total_length * 0.5 ) var travelled: float = 0.0 for index in range( points.size() - 1 ): var start := points[index] var end := points[index + 1] var segment_length := ( start.distance_to( end ) ) if ( travelled + segment_length >= target_length ): if segment_length <= 0.0: return start var amount := ( ( target_length - travelled ) / segment_length ) return start.lerp( end, amount ) travelled += segment_length return points[ points.size() - 1 ] func _build_cabinets() -> void: if scenario == null: return for assignment in scenario.assignments: if assignment == null: continue if assignment.cabinet == null: continue if assignment.game == null: continue var placement := room.get_cabinet_placement( assignment.cabinet.cabinet_id ) if placement == null: continue var item := _create_item() if item == null: continue # IMPORTANT: # Add it to the scene tree before calling configure(). add_child(item) item.configure( assignment.cabinet.cabinet_id, assignment.cabinet.display_name, assignment.game.display_name ) var footprint_width_pixels := ( assignment.cabinet.footprint_width_m * pixels_per_meter * zoom ) var footprint_depth_pixels := ( assignment.cabinet.footprint_depth_m * pixels_per_meter * zoom ) item.configure_cabinet_footprint( assignment.cabinet.footprint_width_m * pixels_per_meter * zoom, assignment.cabinet.footprint_depth_m * pixels_per_meter * zoom ) item.configure_editor_texture( assignment.cabinet.editor_texture, assignment.cabinet.editor_texture_scale ) var center_screen := ( world_to_screen( placement.position ) ) var center_offset := ( item.get_physical_center_offset() ) item.position = ( center_screen - center_offset ) item.pivot_offset = ( center_offset ) item.rotation_degrees = ( placement.rotation_degrees ) item.moved.connect( _on_cabinet_moved ) item.selected.connect( _on_item_selected ) item_nodes[ assignment.cabinet.cabinet_id ] = item item.moving.connect( _on_item_moving ) func _update_binding_labels() -> void: if room == null: return var label_index := 0 for binding in room.bindings: if binding == null: continue if label_index >= binding_labels.size(): break var label := binding_labels[ label_index ] _position_binding_label( binding, label ) label_index += 1 func _build_devices() -> void: for device in room.devices: if device == null: continue var item := _create_item() if item == null: continue # Same thing here. add_child(item) item.configure( device.device_id, device.display_name, _device_type_to_text( device.device_type ) ) # # Device dimensions are authored in meters. # item.configure_device_footprint( device.physical_size_m.x * pixels_per_meter * zoom, device.physical_size_m.y * pixels_per_meter * zoom ) item.configure_editor_texture( device.editor_texture, device.editor_texture_scale ) # # Device.position currently represents the # top-left physical position in room space. # item.position = world_to_screen( device.position ) item.moved.connect( _on_device_moved ) item.selected.connect( _on_item_selected ) item_nodes[ device.device_id ] = item item.moving.connect( _on_item_moving ) func _on_item_selected( item_id: StringName ) -> void: selected_binding_key = "" hovered_binding_route_point = -1 active_binding_route_point = -1 selected_binding_route_point = -1 selected_item_id = item_id selected_wall_id = &"" for id in item_nodes: var item: RoomLayoutItem = ( item_nodes[id] ) item.set_selected( id == selected_item_id ) item_selected.emit( item_id ) _update_relationship_highlight() func _on_item_moving( _item_id: StringName, _new_position: Vector2 ) -> void: _update_binding_labels() queue_redraw() func _create_item() -> RoomLayoutItem: if item_scene == null: push_error( "RoomLayout has no item_scene assigned." ) return null var instance := item_scene.instantiate() if not instance is RoomLayoutItem: push_error( "RoomLayout item_scene root must inherit from " + "RoomLayoutItem. Got: %s" % instance.get_class() ) instance.queue_free() return null return instance as RoomLayoutItem func set_device_active( device_id: StringName, active: bool ) -> void: if not item_nodes.has(device_id): return var item: RoomLayoutItem = ( item_nodes[device_id] ) item.set_active(active) func _on_cabinet_moved( cabinet_id: StringName, _old_screen_position: Vector2, new_screen_position: Vector2 ) -> void: if room == null: return var placement := ( room.get_cabinet_placement( cabinet_id ) ) if placement == null: return var item := ( item_nodes[ cabinet_id ] as RoomLayoutItem ) var center_offset := ( item.get_physical_center_offset() ) var new_center_screen := ( new_screen_position + center_offset ) # Preserve the exact stored position for Undo, including off-grid placements. var old_world_position := placement.position var new_world_position := ( screen_to_world( new_center_screen ) ) new_world_position = ( snap_item_position_for_editor( cabinet_id, new_world_position ) ) # # Restore the visual item to the original position. # Workspace will apply the actual change through UndoRedo. # if item_nodes.has( cabinet_id ): var cabinet_item: RoomLayoutItem = ( item_nodes[ cabinet_id ] ) var restored_center_screen := ( world_to_screen( old_world_position ) ) cabinet_item.position = ( restored_center_screen - cabinet_item.get_physical_center_offset() ) item_position_edit_requested.emit( cabinet_id, old_world_position, new_world_position ) _update_binding_labels() queue_redraw() func _on_device_moved( device_id: StringName, old_screen_position: Vector2, new_screen_position: Vector2 ) -> void: if room == null: return var device := ( room.get_device( device_id ) ) if device == null: return var old_world_position := ( screen_to_world( old_screen_position ) ) var new_world_position := ( screen_to_world( new_screen_position ) ) old_world_position = ( _snap_world_position( old_world_position ) ) new_world_position = ( _snap_world_position( new_world_position ) ) if item_nodes.has( device_id ): var item: RoomLayoutItem = ( item_nodes[ device_id ] ) item.position = ( world_to_screen( old_world_position ) ) item_position_edit_requested.emit( device_id, old_world_position, new_world_position ) _update_binding_labels() queue_redraw() func get_selected_position() -> Vector2: if selected_item_id == &"": return Vector2.ZERO var placement := ( room.get_cabinet_placement( selected_item_id ) ) if placement != null: return placement.position var device := room.get_device( selected_item_id ) if device != null: return device.position return Vector2.ZERO func get_selected_rotation() -> float: if selected_item_id == &"": return 0.0 var placement := ( room.get_cabinet_placement( selected_item_id ) ) if placement != null: return placement.rotation_degrees return 0.0 func selected_item_is_cabinet() -> bool: if room == null: return false return ( room.get_cabinet_placement( selected_item_id ) != null ) func set_selected_position( new_position: Vector2 ) -> void: if selected_item_id == &"": return var final_position := ( snap_item_position_for_editor( selected_item_id, new_position ) ) var placement := ( room.get_cabinet_placement( selected_item_id ) ) if placement != null: placement.position = ( final_position ) else: var device := ( room.get_device( selected_item_id ) ) if device == null: return device.position = ( final_position ) if item_nodes.has( selected_item_id ): var item: RoomLayoutItem = ( item_nodes[ selected_item_id ] ) if placement != null: var center_screen := ( world_to_screen( final_position ) ) item.position = ( center_screen - item.get_physical_center_offset() ) else: item.position = ( world_to_screen( final_position ) ) layout_changed.emit() _update_binding_labels() queue_redraw() func set_selected_rotation( new_rotation: float ) -> void: if selected_item_id == &"": return var placement := ( room.get_cabinet_placement( selected_item_id ) ) if placement == null: return placement.rotation_degrees = ( new_rotation ) if item_nodes.has( selected_item_id ): var item: RoomLayoutItem = ( item_nodes[ selected_item_id ] ) item.rotation_degrees = ( new_rotation ) layout_changed.emit() func _device_type_to_text( device_type: RoomDeviceDefinition.DeviceType ) -> String: match device_type: RoomDeviceDefinition.DeviceType.COIN_RELEASE: return "Coin Release" RoomDeviceDefinition.DeviceType.LIGHT: return "Light" RoomDeviceDefinition.DeviceType.DOOR_LOCK: return "Door Lock" RoomDeviceDefinition.DeviceType.DRAWER_LOCK: return "Drawer Lock" RoomDeviceDefinition.DeviceType.BUZZER: return "Buzzer" _: return "Device" func _make_binding_key( cabinet_id: StringName, action_id: StringName, device_id: StringName ) -> String: return "%s::%s::%s" % [ cabinet_id, action_id, device_id ] func set_binding_active( cabinet_id: StringName, action_id: StringName, device_id: StringName, active: bool ) -> void: var key := _make_binding_key( cabinet_id, action_id, device_id ) if active: active_bindings[key] = true else: active_bindings.erase(key) queue_redraw() func _draw() -> void: _draw_grid() _draw_room_boundary() _draw_walls() if not show_connections: return if room == null: return for binding in room.bindings: if binding == null: continue _draw_binding( binding ) if is_drawing_wall: _draw_wall_preview() func set_connections_visible( visible: bool ) -> void: show_connections = visible for label in binding_labels: label.visible = visible queue_redraw() func _draw_binding( binding: RoomDeviceBinding ) -> void: var route_points := ( _get_binding_route_points( binding ) ) if route_points.size() < 2: return var key := _make_binding_key( binding.cabinet_id, binding.hardware_action_id, binding.device_id ) var is_active := active_bindings.has( key ) var is_highlighted := ( highlighted_bindings.has( key ) ) var is_selected := ( key == selected_binding_key ) var line_color := Color( 0.45, 0.45, 0.45, 0.8 ) var line_width: float = 2.0 # # Editor selection. # if ( is_highlighted or is_selected ): line_color = Color( 0.35, 0.7, 1.0, 1.0 ) line_width = 4.0 # # Live simulation always wins visually. # if is_active: line_color = Color( 1.0, 0.8, 0.2, 1.0 ) line_width = 6.0 # # Draw every segment of the authored cable route. # for index in range( route_points.size() - 1 ): draw_line( route_points[index], route_points[index + 1], line_color, line_width, true ) # # Keep the endpoint marker on the device end. # var end_position := ( route_points[ route_points.size() - 1 ] ) _draw_connection_endpoint( end_position, line_color, is_active or is_highlighted or is_selected ) if is_selected: _draw_binding_route_handles( binding ) func _draw_binding_route_handles( binding: RoomDeviceBinding ) -> void: if binding == null: return for index in range( binding.route_points.size() ): var screen_position := ( world_to_screen( binding.route_points[index] ) ) var radius: float = 7.0 var color := Color( 0.35, 0.7, 1.0, 1.0 ) if index == hovered_binding_route_point: radius = 10.0 color = Color( 0.65, 0.85, 1.0, 1.0 ) if index == selected_binding_route_point: radius = 9.0 color = Color( 1.0, 1.0, 1.0, 1.0 ) if index == active_binding_route_point: radius = 11.0 color = Color( 1.0, 0.7, 0.2, 1.0 ) draw_circle( screen_position, radius, color ) draw_circle( screen_position, radius, Color.WHITE, false, 2.0 ) func _get_binding_route_points( binding: RoomDeviceBinding ) -> PackedVector2Array: var points := PackedVector2Array() if binding == null: return points if not item_nodes.has( binding.cabinet_id ): return points if not item_nodes.has( binding.device_id ): return points var cabinet_item: RoomLayoutItem = ( item_nodes[ binding.cabinet_id ] ) var device_item: RoomLayoutItem = ( item_nodes[ binding.device_id ] ) # # Start at the visual center of the cabinet. # var start_position := ( cabinet_item.position + cabinet_item.get_physical_center_offset() ) points.append( start_position ) # # Authored route points are stored in room/world meters, # so convert them to screen coordinates here. # for room_point in binding.route_points: points.append( world_to_screen( room_point ) ) # # Finish at the visual center of the device. # var end_position := ( device_item.position + device_item.get_physical_center_offset() ) points.append( end_position ) return points func _get_selected_binding() -> RoomDeviceBinding: if room == null: return null if selected_binding_key.is_empty(): return null for binding in room.bindings: if binding == null: continue var key := _make_binding_key( binding.cabinet_id, binding.hardware_action_id, binding.device_id ) if key == selected_binding_key: return binding return null func _find_binding_at_screen_position( screen_position: Vector2 ) -> RoomDeviceBinding: if room == null: return null if not show_connections: return null var hit_radius: float = 10.0 for binding in room.bindings: if binding == null: continue var route_points := ( _get_binding_route_points( binding ) ) if route_points.size() < 2: continue for index in range( route_points.size() - 1 ): var closest := ( Geometry2D.get_closest_point_to_segment( screen_position, route_points[index], route_points[index + 1] ) ) if ( closest.distance_to( screen_position ) <= hit_radius ): return binding return null func _select_binding( binding: RoomDeviceBinding ) -> void: if binding == null: return selected_binding_key = ( _make_binding_key( binding.cabinet_id, binding.hardware_action_id, binding.device_id ) ) selected_item_id = &"" selected_wall_id = &"" hovered_binding_route_point = -1 active_binding_route_point = -1 selected_binding_route_point = -1 for id in item_nodes: var item: RoomLayoutItem = ( item_nodes[id] ) item.set_selected( false ) queue_redraw() func _find_binding_route_point_at_screen_position( screen_position: Vector2 ) -> int: var binding := ( _get_selected_binding() ) if binding == null: return -1 var best_index: int = -1 var best_distance: float = 12.0 for index in range( binding.route_points.size() ): var point_screen := ( world_to_screen( binding.route_points[index] ) ) var distance := ( point_screen.distance_to( screen_position ) ) if distance <= best_distance: best_distance = distance best_index = index return best_index func _begin_binding_route_point_drag( point_index: int ) -> void: var binding := ( _get_selected_binding() ) if binding == null: return if ( point_index < 0 or point_index >= binding.route_points.size() ): return active_binding_route_point = ( point_index ) selected_binding_route_point = ( point_index ) binding_route_drag_original_points = ( binding.route_points.duplicate() ) queue_redraw() func _update_binding_route_point_drag( screen_position: Vector2 ) -> void: var binding := ( _get_selected_binding() ) if binding == null: return if active_binding_route_point < 0: return if ( active_binding_route_point >= binding.route_points.size() ): return var world_position := ( screen_to_world( screen_position ) ) var snapped_position := ( _snap_world_position( world_position ) ) var points := ( binding.route_points.duplicate() ) points[ active_binding_route_point ] = snapped_position binding.route_points = points _update_binding_labels() queue_redraw() func _finish_binding_route_point_drag() -> void: var binding := ( _get_selected_binding() ) if binding == null: _cancel_binding_route_point_drag() return if active_binding_route_point < 0: return var new_points := ( binding.route_points.duplicate() ) # # Restore the original model before handing # the completed change to Workspace UndoRedo. # binding.route_points = ( binding_route_drag_original_points.duplicate() ) var old_points := ( binding_route_drag_original_points.duplicate() ) binding_route_edit_requested.emit( binding.cabinet_id, binding.hardware_action_id, binding.device_id, old_points, new_points ) active_binding_route_point = -1 binding_route_drag_original_points = ( PackedVector2Array() ) _update_binding_labels() queue_redraw() func _cancel_binding_route_point_drag() -> void: var binding := ( _get_selected_binding() ) if binding != null: binding.route_points = ( binding_route_drag_original_points.duplicate() ) active_binding_route_point = -1 binding_route_drag_original_points = ( PackedVector2Array() ) _update_binding_labels() queue_redraw() func _draw_connection_endpoint( position: Vector2, color: Color, is_active: bool ) -> void: var radius := 5.0 if is_active: radius = 9.0 draw_circle( position, radius, color ) func _request_selected_binding_route_point_delete() -> void: var binding := ( _get_selected_binding() ) if binding == null: return if selected_binding_route_point < 0: return if ( selected_binding_route_point >= binding.route_points.size() ): return var old_points := ( binding.route_points.duplicate() ) var new_points := ( binding.route_points.duplicate() ) new_points.remove_at( selected_binding_route_point ) binding_route_edit_requested.emit( binding.cabinet_id, binding.hardware_action_id, binding.device_id, old_points, new_points ) selected_binding_route_point = -1 hovered_binding_route_point = -1 active_binding_route_point = -1 queue_redraw() func _update_relationship_highlight() -> void: for id in item_nodes: var item: RoomLayoutItem = ( item_nodes[id] ) item.modulate = Color.WHITE if selected_item_id == &"": return var related_ids: Dictionary = {} related_ids[selected_item_id] = true for binding in room.bindings: if binding == null: continue if ( binding.cabinet_id == selected_item_id ): related_ids[ binding.device_id ] = true elif ( binding.device_id == selected_item_id ): related_ids[ binding.cabinet_id ] = true for id in item_nodes: if related_ids.has(id): continue var item: RoomLayoutItem = ( item_nodes[id] ) item.modulate = Color( 0.55, 0.55, 0.55, 1.0 ) func snap_position_for_editor( position: Vector2 ) -> Vector2: return _snap_world_position( position ) func world_to_screen( world_position: Vector2 ) -> Vector2: return ( world_position * pixels_per_meter * zoom + view_offset ) func screen_to_world( screen_position: Vector2 ) -> Vector2: return ( screen_position - view_offset ) / ( pixels_per_meter * zoom ) func _meters_to_feet_inches( meters: float ) -> String: var total_inches := roundi( absf(meters) / 0.0254 ) var feet := ( total_inches / 12 ) var inches := ( total_inches % 12 ) if inches == 0: return "%d'" % feet if feet == 0: return "%d\"" % inches return "%d' %d\"" % [ feet, inches ] func _draw_dimension_label( screen_position: Vector2, text: String ) -> void: if text.is_empty(): return var font := ( get_theme_default_font() ) var font_size := 14 var text_size := ( font.get_string_size( text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size ) ) var padding := Vector2( 6.0, 3.0 ) var background_rect := Rect2( screen_position - text_size * 0.5 - padding, text_size + padding * 2.0 ) draw_rect( background_rect, Color( 0.08, 0.08, 0.08, 0.90 ), true ) draw_string( font, screen_position - Vector2( text_size.x * 0.5, -text_size.y * 0.25 ), text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color( 0.95, 0.95, 0.95, 1.0 ) ) func snap_item_position_for_editor( item_id: StringName, position: Vector2 ) -> Vector2: if not snap_enabled or room == null: return position var placement := room.get_cabinet_placement(item_id) var cabinet := _get_cabinet_profile(item_id) if placement == null or cabinet == null: return _snap_world_position(position) # Positions remain centers in meters. Snap the rotated footprint's # minimum corner, then restore its center offset. var angle := deg_to_rad(placement.rotation_degrees) var half_size := Vector2( cabinet.footprint_width_m, cabinet.footprint_depth_m ) * 0.5 var half_bounds := Vector2( abs(cos(angle)) * half_size.x + abs(sin(angle)) * half_size.y, abs(sin(angle)) * half_size.x + abs(cos(angle)) * half_size.y ) return _snap_world_position(position - half_bounds) + half_bounds func _snap_world_position( position: Vector2 ) -> Vector2: if not snap_enabled: return position return Vector2( round( position.x / grid_size_meters ) * grid_size_meters, round( position.y / grid_size_meters ) * grid_size_meters ) func _draw_room_boundary() -> void: if room == null: return if room.boundary_points.size() < 2: return # # Approximate visual center. We use this only to decide # which side of each wall is "outside" for the dimension. # var boundary_center := Vector2.ZERO for point in room.boundary_points: boundary_center += world_to_screen( point ) boundary_center /= float( room.boundary_points.size() ) for index in range( room.boundary_points.size() ): var next_index := ( (index + 1) % room.boundary_points.size() ) var start_world := ( room.boundary_points[index] ) var end_world := ( room.boundary_points[next_index] ) var start := world_to_screen( start_world ) var end := world_to_screen( end_world ) var line_color := Color( 0.85, 0.85, 0.85, 1.0 ) var line_width := 3.0 if ( edit_mode == EditMode.ADD_BOUNDARY_POINT and index == hovered_boundary_edge ): line_color = Color( 0.35, 0.7, 1.0, 1.0 ) line_width = 6.0 draw_line( start, end, line_color, line_width, true ) # # Boundary dimension. # var edge_length_m := ( start_world.distance_to( end_world ) ) var midpoint := ( (start + end) * 0.5 ) var direction := ( end - start ) if direction.length() > 0.001: var normal := Vector2( -direction.y, direction.x ).normalized() var offset_distance := 28.0 var candidate_a := ( midpoint + normal * offset_distance ) var candidate_b := ( midpoint - normal * offset_distance ) var label_position := candidate_a # # Choose whichever candidate is farther # from the room's center. # if ( candidate_b.distance_to( boundary_center ) > candidate_a.distance_to( boundary_center ) ): label_position = candidate_b _draw_dimension_label( label_position, _meters_to_feet_inches( edge_length_m ) ) # # Existing boundary editing handles. # if ( edit_mode != EditMode.EDIT_BOUNDARY and edit_mode != EditMode.ADD_BOUNDARY_POINT and edit_mode != EditMode.DELETE_BOUNDARY_POINT ): return for index in range( room.boundary_points.size() ): var screen_position := world_to_screen( room.boundary_points[index] ) var radius := 6.0 var color := Color( 0.85, 0.85, 0.85, 1.0 ) if index == hovered_boundary_vertex: radius = 9.0 color = Color( 0.35, 0.7, 1.0, 1.0 ) if index == active_boundary_vertex: radius = 10.0 color = Color( 1.0, 0.7, 0.2, 1.0 ) draw_circle( screen_position, radius, color ) func _draw_grid() -> void: if grid_size_meters <= 0.0: return var spacing := ( grid_size_meters * pixels_per_meter * zoom ) if spacing < 8.0: return var viewport_size := size var start_x := fmod( view_offset.x, spacing ) var start_y := fmod( view_offset.y, spacing ) var grid_color := Color( 0.25, 0.25, 0.25, 0.35 ) var x := start_x while x < viewport_size.x: draw_line( Vector2( x, 0.0 ), Vector2( x, viewport_size.y ), grid_color, 1.0 ) x += spacing var y := start_y while y < viewport_size.y: draw_line( Vector2( 0.0, y ), Vector2( viewport_size.x, y ), grid_color, 1.0 ) y += spacing func _draw_walls() -> void: if room == null: return for wall in room.walls: if wall == null: continue var start := world_to_screen( wall.start ) var end := world_to_screen( wall.end ) var width: float = maxf( wall.thickness * pixels_per_meter * zoom, 2.0 ) var wall_color := Color( 0.75, 0.75, 0.75, 1.0 ) if wall.wall_id == selected_wall_id: wall_color = Color( 1.0, 0.65, 0.15, 1.0 ) width += 3.0 draw_line( start, end, wall_color, width, true ) # # Wall dimension. # var wall_length_m := ( wall.start.distance_to( wall.end ) ) var midpoint := ( (start + end) * 0.5 ) var direction := ( end - start ) var label_position := ( midpoint + Vector2( 0.0, -18.0 ) ) if direction.length() > 0.001: var normal := Vector2( -direction.y, direction.x ).normalized() # # Keep the label beside the wall instead # of directly over the line. # if normal.y > 0.0: normal = -normal label_position = ( midpoint + normal * 18.0 ) _draw_dimension_label( label_position, _meters_to_feet_inches( wall_length_m ) ) if wall.wall_id == selected_wall_id: var start_radius: float = 7.0 var end_radius: float = 7.0 if hovered_wall_handle == WallHandle.START: start_radius = 10.0 if hovered_wall_handle == WallHandle.END: end_radius = 10.0 if active_wall_handle == WallHandle.START: start_radius = 11.0 if active_wall_handle == WallHandle.END: end_radius = 11.0 draw_circle( start, start_radius, Color.WHITE ) draw_circle( end, end_radius, Color.WHITE ) func set_edit_mode( mode: EditMode ) -> void: edit_mode = mode hovered_boundary_vertex = -1 hovered_boundary_edge = -1 if active_boundary_vertex >= 0: _cancel_boundary_vertex_drag() _update_boundary_add_cursor() queue_redraw() signal wall_creation_requested( start: Vector2, end: Vector2 ) func _gui_input( event: InputEvent ) -> void: if ( event is InputEventMouseButton and event.pressed ): grab_focus() if ( event is InputEventKey and event.pressed and not event.echo ): if event.keycode == KEY_F: focus_room() accept_event() return if event.keycode == KEY_0: reset_view() accept_event() return if event.keycode == KEY_R: refresh_requested.emit() accept_event() return if ( event.keycode == KEY_DELETE or event.keycode == KEY_BACKSPACE ): if ( edit_mode == EditMode.SELECT and selected_binding_route_point >= 0 ): _request_selected_binding_route_point_delete() accept_event() return # # Middle-mouse pan. # if ( event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_MIDDLE ): if event.pressed: _begin_pan( event.position ) else: _end_pan() accept_event() return if ( event is InputEventMouseMotion and is_panning ): _update_pan( event.position ) accept_event() return # # Mouse-wheel zoom. # if event is InputEventMouseButton: if ( event.button_index == MOUSE_BUTTON_WHEEL_UP or event.button_index == MOUSE_BUTTON_WHEEL_DOWN ): _handle_zoom( event ) accept_event() return # # SELECT mode # if edit_mode == EditMode.SELECT: if ( event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT ): if event.pressed: if event.double_click: var double_click_binding := ( _find_binding_at_screen_position( event.position ) ) if double_click_binding != null: _request_binding_route_point_insert( double_click_binding, event.position ) accept_event() return var route_point_index := ( _find_binding_route_point_at_screen_position( event.position ) ) if route_point_index >= 0: _begin_binding_route_point_drag( route_point_index ) accept_event() return var binding := ( _find_binding_at_screen_position( event.position ) ) if binding != null: _select_binding( binding ) accept_event() return var handle := ( _find_wall_handle_at_screen_position( event.position ) ) if handle != WallHandle.NONE: _begin_wall_handle_drag( handle ) accept_event() return var wall := ( _find_wall_at_screen_position( event.position ) ) if wall != null: _select_wall( wall.wall_id ) accept_event() return else: if active_binding_route_point >= 0: _finish_binding_route_point_drag() accept_event() return if ( active_wall_handle != WallHandle.NONE ): _finish_wall_handle_drag() accept_event() return elif event is InputEventMouseMotion: if active_binding_route_point >= 0: _update_binding_route_point_drag( event.position ) accept_event() return if active_wall_handle != WallHandle.NONE: _update_wall_handle_drag( event.position ) accept_event() return var new_route_hover := ( _find_binding_route_point_at_screen_position( event.position ) ) if ( new_route_hover != hovered_binding_route_point ): hovered_binding_route_point = ( new_route_hover ) queue_redraw() var new_hover := ( _find_wall_handle_at_screen_position( event.position ) ) if new_hover != hovered_wall_handle: hovered_wall_handle = new_hover queue_redraw() return # # ADD BOUNDARY POINT mode # if edit_mode == EditMode.ADD_BOUNDARY_POINT: if ( event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed ): var edge_index := ( _find_boundary_edge_at_screen_position( event.position ) ) if edge_index >= 0: _request_boundary_point_insert( edge_index, event.position ) accept_event() elif event is InputEventMouseMotion: var new_hover_edge := ( _find_boundary_edge_at_screen_position( event.position ) ) var new_hover_vertex := ( _find_boundary_vertex_at_screen_position( event.position ) ) if ( new_hover_edge != hovered_boundary_edge or new_hover_vertex != hovered_boundary_vertex ): hovered_boundary_edge = ( new_hover_edge ) hovered_boundary_vertex = ( new_hover_vertex ) _update_boundary_add_cursor() queue_redraw() return # # EDIT BOUNDARY mode # if edit_mode == EditMode.EDIT_BOUNDARY: if ( event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT ): if event.pressed: var vertex_index := ( _find_boundary_vertex_at_screen_position( event.position ) ) if vertex_index >= 0: _begin_boundary_vertex_drag( vertex_index ) accept_event() return else: if active_boundary_vertex >= 0: _finish_boundary_vertex_drag() accept_event() return elif event is InputEventMouseMotion: if active_boundary_vertex >= 0: _update_boundary_vertex_drag( event.position ) accept_event() return var new_hover := ( _find_boundary_vertex_at_screen_position( event.position ) ) if ( new_hover != hovered_boundary_vertex ): hovered_boundary_vertex = ( new_hover ) queue_redraw() return # # DELETE BOUNDARY POINT mode # if edit_mode == EditMode.DELETE_BOUNDARY_POINT: if ( event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed ): var vertex_index := ( _find_boundary_vertex_at_screen_position( event.position ) ) if vertex_index >= 0: _request_boundary_point_delete( vertex_index ) accept_event() elif event is InputEventMouseMotion: var new_hover := ( _find_boundary_vertex_at_screen_position( event.position ) ) if new_hover != hovered_boundary_vertex: hovered_boundary_vertex = new_hover queue_redraw() return # # DRAW WALL mode # if edit_mode != EditMode.DRAW_WALL: return if ( event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT ): if event.pressed: var world_position := screen_to_world( event.position ) wall_draw_start = ( _snap_world_position( world_position ) ) wall_draw_current = wall_draw_start is_drawing_wall = true queue_redraw() accept_event() else: if not is_drawing_wall: return var world_position := screen_to_world( event.position ) var wall_end := ( _snap_world_position( world_position ) ) is_drawing_wall = false if ( wall_draw_start.distance_to( wall_end ) > 0.01 ): wall_creation_requested.emit( wall_draw_start, wall_end ) queue_redraw() accept_event() elif ( event is InputEventMouseMotion and is_drawing_wall ): var world_position := screen_to_world( event.position ) wall_draw_current = ( _snap_world_position( world_position ) ) queue_redraw() accept_event() func _draw_wall_preview() -> void: draw_line( world_to_screen( wall_draw_start ), world_to_screen( wall_draw_current ), Color( 1.0, 0.7, 0.2, 1.0 ), 4.0, true ) func _handle_zoom( event: InputEventMouseButton ) -> void: var old_world_position := ( screen_to_world( event.position ) ) if event.button_index == MOUSE_BUTTON_WHEEL_UP: zoom *= 1.1 elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: zoom /= 1.1 zoom = clamp( zoom, 0.25, 4.0 ) var new_screen_position := ( world_to_screen( old_world_position ) ) view_offset += ( event.position - new_screen_position ) _reposition_items() zoom_changed.emit( zoom ) queue_redraw() func _reposition_items() -> void: if room == null: return # # Cabinets. # for placement in room.cabinet_placements: if placement == null: continue if not item_nodes.has( placement.cabinet_id ): continue var item: RoomLayoutItem = ( item_nodes[ placement.cabinet_id ] ) var cabinet := ( _get_cabinet_profile( placement.cabinet_id ) ) if cabinet != null: item.configure_cabinet_footprint( cabinet.footprint_width_m * pixels_per_meter * zoom, cabinet.footprint_depth_m * pixels_per_meter * zoom ) item.configure_editor_texture( cabinet.editor_texture, cabinet.editor_texture_scale ) var center_screen := ( world_to_screen( placement.position ) ) var center_offset := ( item.get_physical_center_offset() ) item.position = ( center_screen - center_offset ) item.pivot_offset = ( center_offset ) item.rotation_degrees = ( placement.rotation_degrees ) # # Devices. # for device in room.devices: if device == null: continue if not item_nodes.has( device.device_id ): continue var item: RoomLayoutItem = ( item_nodes[ device.device_id ] ) item.configure_device_footprint( device.physical_size_m.x * pixels_per_meter * zoom, device.physical_size_m.y * pixels_per_meter * zoom ) item.position = ( world_to_screen( device.position ) ) _update_binding_labels() func _find_wall_at_screen_position( screen_position: Vector2 ) -> RoomWallDefinition: if room == null: return null var hit_radius_pixels: float = 10.0 for wall in room.walls: if wall == null: continue var start_screen := world_to_screen( wall.start ) var end_screen := world_to_screen( wall.end ) var closest := Geometry2D.get_closest_point_to_segment( screen_position, start_screen, end_screen ) if ( closest.distance_to(screen_position) <= hit_radius_pixels ): return wall return null func _find_binding_segment_at_screen_position( binding: RoomDeviceBinding, screen_position: Vector2 ) -> int: if binding == null: return -1 var route_points := ( _get_binding_route_points( binding ) ) if route_points.size() < 2: return -1 var best_segment: int = -1 var best_distance: float = 12.0 for index in range( route_points.size() - 1 ): var closest := ( Geometry2D.get_closest_point_to_segment( screen_position, route_points[index], route_points[index + 1] ) ) var distance := ( closest.distance_to( screen_position ) ) if distance <= best_distance: best_distance = distance best_segment = index return best_segment func _request_binding_route_point_insert( binding: RoomDeviceBinding, screen_position: Vector2 ) -> void: if binding == null: return var segment_index := ( _find_binding_segment_at_screen_position( binding, screen_position ) ) if segment_index < 0: return var old_points := ( binding.route_points.duplicate() ) var new_points := ( binding.route_points.duplicate() ) var world_position := ( screen_to_world( screen_position ) ) var snapped_position := ( _snap_world_position( world_position ) ) # # The complete polyline is: # # cabinet endpoint # route_points[0] # route_points[1] # ... # device endpoint # # Therefore the polyline segment index is also # the correct insertion index in route_points. # var insertion_index := clampi( segment_index, 0, new_points.size() ) new_points.insert( insertion_index, snapped_position ) selected_binding_key = ( _make_binding_key( binding.cabinet_id, binding.hardware_action_id, binding.device_id ) ) hovered_binding_route_point = ( insertion_index ) binding_route_edit_requested.emit( binding.cabinet_id, binding.hardware_action_id, binding.device_id, old_points, new_points ) queue_redraw() func _select_wall( wall_id: StringName ) -> void: selected_binding_key = "" hovered_binding_route_point = -1 active_binding_route_point = -1 selected_binding_route_point = -1 selected_item_id = &"" selected_wall_id = wall_id for id in item_nodes: var item: RoomLayoutItem = ( item_nodes[id] ) item.set_selected(false) wall_selected.emit( wall_id ) queue_redraw() func get_selected_wall() -> RoomWallDefinition: if room == null: return null if selected_wall_id == &"": return null return room.get_wall( selected_wall_id ) func _find_wall_handle_at_screen_position( screen_position: Vector2 ) -> WallHandle: var wall := get_selected_wall() if wall == null: return WallHandle.NONE var start_screen := world_to_screen( wall.start ) var end_screen := world_to_screen( wall.end ) var hit_radius: float = 12.0 if ( start_screen.distance_to( screen_position ) <= hit_radius ): return WallHandle.START if ( end_screen.distance_to( screen_position ) <= hit_radius ): return WallHandle.END return WallHandle.NONE func _begin_wall_handle_drag( handle: WallHandle ) -> void: var wall := get_selected_wall() if wall == null: return active_wall_handle = handle wall_handle_drag_wall_id = wall.wall_id wall_handle_drag_original_start = wall.start wall_handle_drag_original_end = wall.end func _update_wall_handle_drag( screen_position: Vector2 ) -> void: if active_wall_handle == WallHandle.NONE: return if room == null: return var wall := room.get_wall( wall_handle_drag_wall_id ) if wall == null: return var world_position := screen_to_world( screen_position ) var snapped_position := ( _snap_world_position( world_position ) ) match active_wall_handle: WallHandle.START: wall.start = snapped_position WallHandle.END: wall.end = snapped_position _: return wall_selected.emit( wall.wall_id ) queue_redraw() func _finish_wall_handle_drag() -> void: if active_wall_handle == WallHandle.NONE: return if room == null: _cancel_wall_handle_drag() return var wall := room.get_wall( wall_handle_drag_wall_id ) if wall == null: _cancel_wall_handle_drag() return var new_start := wall.start var new_end := wall.end # # Restore the original state before handing the command # to the Workspace. UndoRedo will apply the final state. # wall.start = wall_handle_drag_original_start wall.end = wall_handle_drag_original_end wall_geometry_edit_requested.emit( wall.wall_id, wall_handle_drag_original_start, wall_handle_drag_original_end, new_start, new_end ) active_wall_handle = WallHandle.NONE wall_handle_drag_wall_id = &"" queue_redraw() func _cancel_wall_handle_drag() -> void: if room != null: var wall := room.get_wall( wall_handle_drag_wall_id ) if wall != null: wall.start = ( wall_handle_drag_original_start ) wall.end = ( wall_handle_drag_original_end ) active_wall_handle = WallHandle.NONE wall_handle_drag_wall_id = &"" queue_redraw() func _begin_pan( mouse_position: Vector2 ) -> void: is_panning = true pan_last_mouse_position = mouse_position func _update_pan( mouse_position: Vector2 ) -> void: if not is_panning: return var delta := ( mouse_position - pan_last_mouse_position ) view_offset += delta pan_last_mouse_position = mouse_position _reposition_items() queue_redraw() func _end_pan() -> void: is_panning = false func apply_editor_selection( selection: EscapeRoomEditorSelection ) -> void: if selection == null: return clear_editor_highlight() if ( selection.cabinet_id != &"" and selection.action_id != &"" ): selected_wall_id = &"" selected_item_id = selection.cabinet_id for id in item_nodes: var item: RoomLayoutItem = ( item_nodes[id] ) item.set_selected( id == selection.cabinet_id ) highlight_logic_path( selection.cabinet_id, selection.action_id, selection.device_id ) return if selection.wall_id != &"": selected_item_id = &"" selected_wall_id = selection.wall_id queue_redraw() return selected_wall_id = &"" if selection.cabinet_id != &"": selected_item_id = selection.cabinet_id elif selection.device_id != &"": selected_item_id = selection.device_id else: selected_item_id = &"" for id in item_nodes: var item: RoomLayoutItem = ( item_nodes[id] ) item.set_selected( id == selected_item_id ) _update_relationship_highlight() queue_redraw() func highlight_logic_path( cabinet_id: StringName, action_id: StringName, device_id: StringName ) -> void: for id in item_nodes: var item: RoomLayoutItem = ( item_nodes[id] ) item.modulate = Color( 0.45, 0.45, 0.45, 1.0 ) if item_nodes.has(cabinet_id): var cabinet_item: RoomLayoutItem = ( item_nodes[cabinet_id] ) cabinet_item.modulate = Color.WHITE if ( device_id != &"" and item_nodes.has(device_id) ): var device_item: RoomLayoutItem = ( item_nodes[device_id] ) device_item.modulate = Color.WHITE for binding in room.bindings: if binding == null: continue if ( binding.cabinet_id == cabinet_id and binding.hardware_action_id == action_id ): if ( device_id == &"" or binding.device_id == device_id ): set_binding_highlighted( binding.cabinet_id, binding.hardware_action_id, binding.device_id, true ) func clear_editor_highlight() -> void: for id in item_nodes: var item: RoomLayoutItem = ( item_nodes[id] ) item.modulate = Color.WHITE item.set_selected(false) selected_binding_key = "" hovered_binding_route_point = -1 active_binding_route_point = -1 selected_binding_route_point = -1 selected_item_id = &"" selected_wall_id = &"" clear_binding_highlights() queue_redraw() func set_binding_highlighted( cabinet_id: StringName, action_id: StringName, device_id: StringName, highlighted: bool ) -> void: var key := _make_binding_key( cabinet_id, action_id, device_id ) if highlighted: highlighted_bindings[ key ] = true else: highlighted_bindings.erase( key ) queue_redraw() func clear_binding_highlights() -> void: highlighted_bindings.clear() queue_redraw() func _find_boundary_vertex_at_screen_position( screen_position: Vector2 ) -> int: if room == null: return -1 var best_index := -1 var best_distance := 12.0 for index in range( room.boundary_points.size() ): var vertex_screen_position := ( world_to_screen( room.boundary_points[index] ) ) var distance := ( vertex_screen_position.distance_to( screen_position ) ) if distance <= best_distance: best_distance = distance best_index = index return best_index func _begin_boundary_vertex_drag( vertex_index: int ) -> void: if room == null: return if ( vertex_index < 0 or vertex_index >= room.boundary_points.size() ): return active_boundary_vertex = vertex_index boundary_drag_original_points = ( room.boundary_points.duplicate() ) queue_redraw() func _update_boundary_vertex_drag( screen_position: Vector2 ) -> void: if room == null: return if active_boundary_vertex < 0: return if ( active_boundary_vertex >= room.boundary_points.size() ): return var world_position := screen_to_world( screen_position ) var snapped_position := ( _snap_world_position( world_position ) ) var points := ( room.boundary_points.duplicate() ) points[ active_boundary_vertex ] = snapped_position room.boundary_points = points queue_redraw() func _finish_boundary_vertex_drag() -> void: if room == null: _cancel_boundary_vertex_drag() return if active_boundary_vertex < 0: return var new_points := ( room.boundary_points.duplicate() ) # # Restore the original before handing the change # to Workspace UndoRedo. # room.boundary_points = ( boundary_drag_original_points.duplicate() ) boundary_geometry_edit_requested.emit( boundary_drag_original_points.duplicate(), new_points ) active_boundary_vertex = -1 boundary_drag_original_points = ( PackedVector2Array() ) queue_redraw() func _cancel_boundary_vertex_drag() -> void: if room != null: room.boundary_points = ( boundary_drag_original_points.duplicate() ) active_boundary_vertex = -1 boundary_drag_original_points = ( PackedVector2Array() ) queue_redraw() func focus_room() -> void: if room == null: return var points: Array[Vector2] = [] # # Room boundary. # for point in room.boundary_points: points.append( point ) # # Walls. # for wall in room.walls: if wall == null: continue points.append( wall.start ) points.append( wall.end ) # # Cabinet placements. # for placement in room.cabinet_placements: if placement == null: continue points.append( placement.position ) # # Room devices. # for device in room.devices: if device == null: continue points.append( device.position ) if points.is_empty(): return var min_point := points[0] var max_point := points[0] for point in points: min_point.x = minf( min_point.x, point.x ) min_point.y = minf( min_point.y, point.y ) max_point.x = maxf( max_point.x, point.x ) max_point.y = maxf( max_point.y, point.y ) var world_size := ( max_point - min_point ) # # Prevent divide-by-zero for extremely # small or single-point layouts. # world_size.x = maxf( world_size.x, 0.5 ) world_size.y = maxf( world_size.y, 0.5 ) var margin_pixels := 80.0 var available_size := Vector2( maxf( size.x - margin_pixels * 2.0, 1.0 ), maxf( size.y - margin_pixels * 2.0, 1.0 ) ) var zoom_x := ( available_size.x / ( world_size.x * pixels_per_meter ) ) var zoom_y := ( available_size.y / ( world_size.y * pixels_per_meter ) ) zoom = clampf( minf( zoom_x, zoom_y ), 0.25, 4.0 ) var world_center := ( min_point + max_point ) * 0.5 view_offset = ( size * 0.5 - world_center * pixels_per_meter * zoom ) _reposition_items() zoom_changed.emit( zoom ) queue_redraw() func _closest_point_on_segment( point: Vector2, start: Vector2, end: Vector2 ) -> Vector2: var segment := ( end - start ) var length_squared := ( segment.length_squared() ) if length_squared <= 0.000001: return start var t := ( (point - start).dot( segment ) / length_squared ) t = clampf( t, 0.0, 1.0 ) return ( start + segment * t ) func _find_boundary_edge_at_screen_position( screen_position: Vector2 ) -> int: if room == null: return -1 if room.boundary_points.size() < 2: return -1 var best_index := -1 var best_distance := 12.0 for index in range( room.boundary_points.size() ): var next_index := ( (index + 1) % room.boundary_points.size() ) var start_screen := world_to_screen( room.boundary_points[index] ) var end_screen := world_to_screen( room.boundary_points[next_index] ) var closest := ( _closest_point_on_segment( screen_position, start_screen, end_screen ) ) var distance := ( screen_position.distance_to( closest ) ) if distance <= best_distance: best_distance = distance best_index = index return best_index func _request_boundary_point_insert( edge_index: int, screen_position: Vector2 ) -> void: if room == null: return if ( edge_index < 0 or edge_index >= room.boundary_points.size() ): return var next_index := ( (edge_index + 1) % room.boundary_points.size() ) var start_screen := world_to_screen( room.boundary_points[ edge_index ] ) var end_screen := world_to_screen( room.boundary_points[ next_index ] ) var closest_screen := ( _closest_point_on_segment( screen_position, start_screen, end_screen ) ) var new_world_point := ( screen_to_world( closest_screen ) ) new_world_point = ( _snap_world_position( new_world_point ) ) var old_points := ( room.boundary_points.duplicate() ) var new_points := PackedVector2Array() for index in range( old_points.size() ): new_points.append( old_points[index] ) if index == edge_index: new_points.append( new_world_point ) boundary_geometry_edit_requested.emit( old_points, new_points ) hovered_boundary_vertex = -1 hovered_boundary_edge = -1 _update_boundary_add_cursor() queue_redraw() func _request_boundary_point_delete( vertex_index: int ) -> void: if room == null: return if room.boundary_points.size() <= 3: edit_rejected.emit( "A room boundary must contain at least 3 points." ) return if ( vertex_index < 0 or vertex_index >= room.boundary_points.size() ): return var old_points := ( room.boundary_points.duplicate() ) var new_points := PackedVector2Array() for index in range( old_points.size() ): if index == vertex_index: continue new_points.append( old_points[index] ) boundary_geometry_edit_requested.emit( old_points, new_points ) hovered_boundary_vertex = -1 queue_redraw() func _update_boundary_add_cursor() -> void: if ( edit_mode == EditMode.ADD_BOUNDARY_POINT and hovered_boundary_edge >= 0 ): mouse_default_cursor_shape = ( Control.CURSOR_POINTING_HAND ) else: mouse_default_cursor_shape = ( Control.CURSOR_ARROW ) func reset_view() -> void: zoom = 1.0 view_offset = Vector2( 80.0, 80.0 ) _reposition_items() zoom_changed.emit( zoom ) queue_redraw() func refresh_item_positions() -> void: _reposition_items() _update_binding_labels() queue_redraw() func refresh_from_model() -> void: _rebuild() func _get_cabinet_profile( cabinet_id: StringName ) -> CabinetProfile: if scenario == null: return null for assignment in scenario.assignments: if assignment == null: continue if assignment.cabinet == null: continue if ( assignment.cabinet.cabinet_id == cabinet_id ): return assignment.cabinet return null