Files
Jeff Luke c1b2195834 Expand Glitch operations hub, cabinet authoring, deployment, and RFID support
Operator hub
- Introduce Escape Gallery branding and Glitch: the Dark Machine room views
- Add cabinet illustrations, Pac-Man marquee/gameplay artwork, and waiting states
- Add device feedback panels with RFID readings, input/output states, and stale status
- Add logged operator hardware interventions with request deduplication

Room and cabinet authoring
- Add a visual custom-PCB connection editor with per-cabinet device inventory
- Support device IDs, bindings, wiring notes, input polarity, and validation
- Add physical-room label/dimension toggles and editable doorway annotations
- Preserve doorway changes through undo, save, and revert
- Expand cabinet profiles, room layout, and Glitch scenario resources

Scenarios and gameplay
- Add per-assignment game configuration and runtime feature validation
- Add timed test wins and Frogger individual-home objectives
- Add the three-cabinet timed/score/Frogger test scenario
- Add Cabinet 1 Pac-Man RFID credits, credit sound, and CREDIT 0/1 display
- Require physical Start after an accepted scan and prevent duplicate credits

Pi hardware and deployment
- Add GPIO feedback sampling and a separate multiplexed RFID helper
- Validate reader pin ownership and input/output conflicts
- Add SSH cabinet startup and Pi desktop-session service support
- Add master packaging, site setup, SSH enrollment, and staged cabinet releases
- Verify deployment manifests and support explicit activation/status/log commands

Documentation and verification
- Consolidate the root README around the current architecture and setup workflow
- Add the complete master/Pi/RFID export and deployment walkthrough
- Document network isolation, credentials, card identification, and rollback
- Add PCB, room-editor, feedback, credit, deployment, and controller tests
- Isolate protocol tests from designer-edited cabinet configurations

Known limitations
- RFID helper compilation and physical Pi commissioning remain unverified
- Fleet provisioning does not yet configure RFID-only installations completely
- Broader Pac-Man tests retain a ghost-capture assertion failure
- Staged exports must be rebuilt and checked before deploying runtime 2.3
2026-10-17 14:53:54 -07:00

4058 lines
60 KiB
GDScript

@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 show_labels: bool = true
var show_dimensions: 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()
set_labels_visible(show_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 and show_labels
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 not show_dimensions or 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 _ordered_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_wall_or_door(wall, start, end, wall_color, width)
#
# 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
var ordered := _ordered_walls()
ordered.reverse()
for wall in ordered:
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
func set_labels_visible(value: bool) -> void:
show_labels = value
for item in item_nodes.values():
item.title_label.visible = value
item.subtitle_label.visible = value
item.tooltip_text = item.base_title + "\n" + item.base_subtitle
for label in binding_labels:
label.visible = value and show_connections
queue_redraw()
func set_dimensions_visible(value: bool) -> void:
show_dimensions = value
queue_redraw()
func _ordered_walls() -> Array[RoomWallDefinition]:
var ordered: Array[RoomWallDefinition] = []
for wall in room.walls:
if wall != null and not wall.is_doorway:
ordered.append(wall)
for wall in room.walls:
if wall != null and wall.is_doorway:
ordered.append(wall)
return ordered
# Openings are projected onto collinear wall segments without destroying geometry.
func _wall_solid_ranges(wall: RoomWallDefinition) -> Array[Vector2]:
var ranges: Array[Vector2] = [Vector2(0, 1)]
var delta := wall.end - wall.start
if delta.length_squared() < 0.000001:
return ranges
for door in room.walls:
if door == null or not door.is_doorway:
continue
var tolerance := maxf(wall.thickness * 0.5, 0.02)
if absf(delta.cross(door.start - wall.start)) / delta.length() > tolerance or absf(delta.cross(door.end - wall.start)) / delta.length() > tolerance:
continue
var a := delta.dot(door.start - wall.start) / delta.length_squared()
var b := delta.dot(door.end - wall.start) / delta.length_squared()
var low := minf(a, b)
var high := maxf(a, b)
var next: Array[Vector2] = []
for segment in ranges:
if high <= segment.x or low >= segment.y:
next.append(segment)
else:
if low > segment.x:
next.append(Vector2(segment.x, low))
if high < segment.y:
next.append(Vector2(high, segment.y))
ranges = next
return ranges
func _draw_wall_or_door(wall: RoomWallDefinition, start: Vector2, end: Vector2, color: Color, width: float) -> void:
if not wall.is_doorway:
for segment in _wall_solid_ranges(wall):
draw_line(start.lerp(end, segment.x), start.lerp(end, segment.y), color, width, true)
return
var direction := end - start
if direction.length() < 1.0:
return
var angle := direction.angle()
var swing := -PI / 2.0 if wall.swing_reversed else PI / 2.0
var door_color := Color("67d5c5") if wall.wall_id != selected_wall_id else color
draw_dashed_line(start, end, door_color, 1.5, 5.0)
draw_line(start, start + direction.rotated(swing), door_color, 2.0, true)
draw_arc(start, direction.length(), minf(angle, angle + swing), maxf(angle, angle + swing), 32, door_color, 1.5, true)
draw_circle(start, 4.0, door_color)