Files
fps_project_1/addons/dreadpon.spatial_gardener/gardener/painter.gd

535 lines
20 KiB
GDScript

@tool
extends RefCounted
#-------------------------------------------------------------------------------
# Handles keeping track of brush strokes, brush position and some of the brush settings
# Also notifies others of painting lifecycle updates
#-------------------------------------------------------------------------------
const FunLib = preload("../utility/fun_lib.gd")
const DponDebugDraw = preload("../utility/debug_draw.gd")
const Toolshed_Brush = preload("../toolshed/toolshed_brush.gd")
const Globals = preload("../utility/globals.gd")
enum ModifierKeyboardKey {KEY_SHIFT, KEY_CTRL, KEY_ALT, KEY_TAB}
enum BrushPrimaryKeyboardKey {MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT, MOUSE_BUTTON_MIDDLE, MOUSE_BUTTON_XBUTTON1, MOUSE_BUTTON_XBUTTON2}
enum BrushPropEditFlag {MODIFIER, NONE, SIZE, STRENGTH}
var owned_spatial:Node3D = null
# Used for immediate updates when changes happen to the brush
# This should NOT be used in update() or each frame in general
var _cached_camera: Camera3D = null
const sphere_brush_material = preload("../shaders/shm_sphere_brush.tres")
const circle_brush_material = preload("../shaders/shm_circle_brush.tres")
var paint_brush_node:MeshInstance3D = null
var detached_paint_brush_container:Node = null
# Temporary variables to store current quick prop edit state
var brush_prop_edit_flag = BrushPropEditFlag.NONE
const brush_prop_edit_max_dist:float = 500.0
var brush_prop_edit_max_val:float = 0.0
var brush_prop_edit_cur_val:float = 0.0
var brush_prop_edit_start_pos:Vector2 = Vector2.ZERO
var brush_prop_edit_offset:float = 0.0
var can_draw:bool = false
var is_drawing:bool = false
var pending_movement_update:bool = false
var brush_collision_mask:int : set = set_brush_collision_mask
# Used to pass during stroke-state signals sent to Gardener/Arborist
# Meant to avoid retrieving transform from an actual 3D node
# And more importantly to cache a raycast normal at every given point in time
var active_brush_data:Dictionary = {'brush_pos': Vector3.ZERO, 'brush_normal': Vector3.UP, 'brush_basis': Basis()}
# Variables to sync quick brush property edit with UI and vice-versa
# And also for keeping brush state up-to-date without needing a reference to actual active brush
var active_brush_overlap_mode: int = Toolshed_Brush.OverlapMode.VOLUME
var active_brush_size:float : set = set_active_brush_size
var active_brush_strength:float : set = set_active_brush_strength
var active_brush_max_size:float : set = set_active_brush_max_size
var active_brush_max_strength:float : set = set_active_brush_max_strength
# A queue of methods to be called once _cached_camera becomes available
var when_camera_queue: Array = []
# Ooooh boy
# Go to finish_brush_prop_edit() for explanation
var mouse_move_call_delay: int = 0
signal changed_active_brush_prop(prop, val, final)
signal stroke_started
signal stroke_finished
signal stroke_updated
#-------------------------------------------------------------------------------
# Lifecycle
#-------------------------------------------------------------------------------
# When working with this object, we assume it does not exist outside the editor
func _init(_owned_spatial):
set_meta("class", "Painter")
owned_spatial = _owned_spatial
#
paint_brush_node = MeshInstance3D.new()
paint_brush_node.name = "active_brush"
set_brush_mesh()
#owned_spatial.add_child(paint_brush_node)
detached_paint_brush_container = Node.new()
owned_spatial.add_child(detached_paint_brush_container)
detached_paint_brush_container.add_child(paint_brush_node)
set_can_draw(false)
func update(delta):
if _cached_camera:
# Handle queue of methods that need a _cached_camera
for queue_item in when_camera_queue.duplicate():
callv(queue_item.method_name, queue_item.args)
when_camera_queue.erase(queue_item)
consume_brush_drawing_update(delta)
func set_brush_mesh(is_sphere: bool = false):
if is_sphere:
paint_brush_node.mesh = SphereMesh.new()
paint_brush_node.mesh.radial_segments = 32
paint_brush_node.mesh.rings = 16
paint_brush_node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
paint_brush_node.material_override = sphere_brush_material.duplicate()
else:
paint_brush_node.mesh = QuadMesh.new()
paint_brush_node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
paint_brush_node.material_override = circle_brush_material.duplicate()
# Queue a call to method that needs a _cached_camera to be set
func queue_call_when_camera(method_name: String, args: Array = []):
when_camera_queue.append({'method_name': method_name, 'args': args})
#-------------------------------------------------------------------------------
# Editing lifecycle
#-------------------------------------------------------------------------------
func start_editing():
set_can_draw(true)
func stop_editing():
stop_brush_stroke()
set_can_draw(false)
#-------------------------------------------------------------------------------
# Input
#-------------------------------------------------------------------------------
func forwarded_input(camera:Camera3D, event):
if !can_draw: return
_cached_camera = camera
var handled = false
# If inactive property edit
# And event == mouseMotion
# -> move the brush
if brush_prop_edit_flag <= BrushPropEditFlag.NONE:
if (is_instance_of(event, InputEventMouseMotion)
|| (is_instance_of(event, InputEventMouseButton) && event.button_index == MOUSE_BUTTON_WHEEL_UP)
|| (is_instance_of(event, InputEventMouseButton) && event.button_index == MOUSE_BUTTON_WHEEL_DOWN)):
if mouse_move_call_delay > 0:
mouse_move_call_delay -= 1
else:
move_brush()
pending_movement_update = true
# Don't handle input - moving a brush is not destructive
# If inactive property edit
# And event == overlap mode key
# -> cycle overlap modes
if brush_prop_edit_flag <= BrushPropEditFlag.NONE && is_instance_of(event, InputEventKey) && event.keycode == get_overlap_mode_key():
if event.pressed && !event.is_echo():
cycle_overlap_modes()
handled = true
# If inactive property edit/modifier key pressed
# And event == modifier key pressed
# -> remember/forget the modifier
if brush_prop_edit_flag <= BrushPropEditFlag.NONE && is_instance_of(event, InputEventKey) && event.keycode == get_property_edit_modifier():
if event.pressed:
brush_prop_edit_flag = BrushPropEditFlag.MODIFIER
if !event.pressed:
brush_prop_edit_flag = BrushPropEditFlag.NONE
handled = true
# If inactive property edit or modifier key pressed
# And event == property edit trigger pressed
# -> start property edit
if brush_prop_edit_flag <= BrushPropEditFlag.NONE && is_instance_of(event, InputEventMouseButton) && event.button_index == get_property_edit_button():
if event.pressed:
brush_prop_edit_flag = BrushPropEditFlag.SIZE if brush_prop_edit_flag != BrushPropEditFlag.MODIFIER else BrushPropEditFlag.STRENGTH
start_brush_prop_edit(event.global_position)
handled = true
# If editing property
# And event == property edit trigger released
# -> stop property edit
if brush_prop_edit_flag > BrushPropEditFlag.NONE && is_instance_of(event, InputEventMouseButton) && event.button_index == get_property_edit_button():
if !event.pressed:
finish_brush_prop_edit(camera)
brush_prop_edit_flag = BrushPropEditFlag.NONE
handled = true
# If editing property
# And event == mouseMotion
# -> update property value
if brush_prop_edit_flag > BrushPropEditFlag.NONE && is_instance_of(event, InputEventMouseMotion):
brush_prop_edit_calc_val(event.global_position)
handled = true
# If editing property
# And event == paint trigger pressed/releasedq
# -> start/stop the brush stroke
if brush_prop_edit_flag == BrushPropEditFlag.NONE && is_instance_of(event, InputEventMouseButton) && event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
move_brush()
start_brush_stroke()
else:
stop_brush_stroke()
handled = true
return handled
func get_property_edit_modifier():
# This convolution exists because a project setting with default value is not saved for some reason and load as "null"
# See https://github.com/godotengine/godot/issues/56598
var key = FunLib.get_setting_safe("dreadpons_spatial_gardener/input_and_ui/brush_prop_edit_modifier", Globals.KeyboardKey.KEY_SHIFT)
return Globals.index_to_enum(key, Globals.KeyboardKey)
func get_property_edit_button():
var key = FunLib.get_setting_safe("dreadpons_spatial_gardener/input_and_ui/brush_prop_edit_button", Globals.MouseButton.MOUSE_BUTTON_XBUTTON1)
return Globals.index_to_enum(key, Globals.MouseButton)
func get_overlap_mode_key():
var key = FunLib.get_setting_safe("dreadpons_spatial_gardener/input_and_ui/brush_overlap_mode_button", Globals.KeyboardKey.KEY_QUOTELEFT)
return Globals.index_to_enum(key, Globals.KeyboardKey)
#-------------------------------------------------------------------------------
# Painting lifecycle
#-------------------------------------------------------------------------------
func set_can_draw(state):
can_draw = state
if state:
paint_brush_node.visible = true
else:
paint_brush_node.visible = false
func start_brush_stroke():
if is_drawing: return
is_drawing = true
stroke_started.emit(active_brush_data)
func stop_brush_stroke():
if !is_drawing: return
is_drawing = false
active_brush_data = {'brush_pos': Vector3.ZERO, 'brush_normal': Vector3.UP, 'brush_basis': Basis()}
stroke_finished.emit(active_brush_data)
# Actually update the stroke only if it was preceeded by the input event
func consume_brush_drawing_update(delta):
if !can_draw: return
if !is_drawing: return
if !pending_movement_update: return
pending_movement_update = false
stroke_updated.emit(active_brush_data)
#-------------------------------------------------------------------------------
# Brush movement
#-------------------------------------------------------------------------------
func move_brush():
if !_cached_camera: return
update_active_brush_data()
refresh_brush_transform()
# Update brush data that is passed through signals to Gardener/Arborist
# Raycast overrides exist for compatability with gardener tests
func update_active_brush_data(raycast_overrides: Dictionary = {}):
var space_state = paint_brush_node.get_world_3d().direct_space_state
var start = project_mouse_near() if !raycast_overrides.has('start') else raycast_overrides.start
var end = project_mouse_far() if !raycast_overrides.has('end') else raycast_overrides.end
var params = PhysicsRayQueryParameters3D.create(start, end, brush_collision_mask, [])
var ray_result:Dictionary = space_state.intersect_ray(params)
if !ray_result.is_empty():
active_brush_data.brush_pos = ray_result.position
active_brush_data.brush_normal = ray_result.normal
else:
# If raycast failed - align to camera plane, retaining current distance to camera
var camera_normal = -_cached_camera.global_transform.basis.z
var planar_dist_to_camera = (active_brush_data.brush_pos - _cached_camera.global_transform.origin).dot(camera_normal)
var brush_pos:Vector3 = project_mouse(planar_dist_to_camera)
active_brush_data.brush_pos = brush_pos
# It's possible we don't have _cached_camera defined here since
# Gardener tests might call update_active_brush_data() without setting it
if _cached_camera:
# Cache to use with Projection brush
active_brush_data.brush_basis = _cached_camera.global_transform.basis
# Update transform of a paint brush 3D node
func refresh_brush_transform():
if active_brush_data.is_empty(): return
match active_brush_overlap_mode:
Toolshed_Brush.OverlapMode.VOLUME:
paint_brush_node.global_transform.origin = active_brush_data.brush_pos
paint_brush_node.global_transform.basis = Basis()
Toolshed_Brush.OverlapMode.PROJECTION:
paint_brush_node.global_transform.origin = active_brush_data.brush_pos
paint_brush_node.global_transform.basis = active_brush_data.brush_basis
# Projection brush size is in viewport-space, but it will move forward and backward
# Thus appearing smaller or bigger
# So we need to update it's size to keep it consistent
set_brush_diameter(active_brush_size)
#-------------------------------------------------------------------------------
# Brush quick property edit lifecycle
#-------------------------------------------------------------------------------
# Quickly edit a brush property without using the UI (aka like in Blender)
# The flow here is as follows:
# 1. Respond to mouse events, calculate property value, emit a signal
# 2. Signal is received in the Gardener, passed to an active Toolshed_Brush
# 3. Active brush updates it's values
# 4. Toolshed notifies Painter of a value change
# 5. Painter updates it's helper variables and visual representation
# Switching between Volume/Projection brush is here too, but it's not connected to the whole Blender-like process
# It's just a hotkey handling
# Set the initial value of edited property and mouse offset
func start_brush_prop_edit(mouse_pos):
match brush_prop_edit_flag:
BrushPropEditFlag.SIZE:
brush_prop_edit_cur_val = active_brush_size
brush_prop_edit_max_val = active_brush_max_size
BrushPropEditFlag.STRENGTH:
brush_prop_edit_cur_val = active_brush_strength
brush_prop_edit_max_val = active_brush_max_strength
brush_prop_edit_start_pos = mouse_pos
brush_prop_edit_offset = brush_prop_edit_cur_val / brush_prop_edit_max_val * brush_prop_edit_max_dist
# Calculate edited property value based on mouse offset
func brush_prop_edit_calc_val(mouse_pos):
brush_prop_edit_cur_val = clamp((mouse_pos.x - brush_prop_edit_start_pos.x + brush_prop_edit_offset) / brush_prop_edit_max_dist, 0.0, 1.0) * brush_prop_edit_max_val
match active_brush_overlap_mode:
Toolshed_Brush.OverlapMode.VOLUME:
match brush_prop_edit_flag:
BrushPropEditFlag.SIZE:
changed_active_brush_prop.emit("shape/shape_volume_size", brush_prop_edit_cur_val, false)
BrushPropEditFlag.STRENGTH:
changed_active_brush_prop.emit("behavior/behavior_strength", brush_prop_edit_cur_val, false)
Toolshed_Brush.OverlapMode.PROJECTION:
match brush_prop_edit_flag:
BrushPropEditFlag.SIZE:
changed_active_brush_prop.emit("shape/shape_projection_size", brush_prop_edit_cur_val, false)
# Stop editing brush property and reset helper variables and mouse position
func finish_brush_prop_edit(camera:Camera3D):
match active_brush_overlap_mode:
Toolshed_Brush.OverlapMode.VOLUME:
match brush_prop_edit_flag:
BrushPropEditFlag.SIZE:
changed_active_brush_prop.emit("shape/shape_volume_size", brush_prop_edit_cur_val, true)
BrushPropEditFlag.STRENGTH:
changed_active_brush_prop.emit("behavior/behavior_strength", brush_prop_edit_cur_val, true)
Toolshed_Brush.OverlapMode.PROJECTION:
match brush_prop_edit_flag:
BrushPropEditFlag.SIZE:
changed_active_brush_prop.emit("shape/shape_projection_size", brush_prop_edit_cur_val, true)
Input.warp_mouse(brush_prop_edit_start_pos)
brush_prop_edit_flag = BrushPropEditFlag.NONE
brush_prop_edit_start_pos = Vector2.ZERO
brush_prop_edit_max_val = 0.0
brush_prop_edit_cur_val = 0.0
# Apparently warp_mouse() sometimes takes a few mouse motion events to actually take place
# Sometimes it's instant, sometimes it takes 1, and sometimes 2 events (at least on my machine)
# This leads to brush jumping to position used in prop edit and then back. Like it's on a string
# As an workaround, we delay processing motion input for 2 events (which should be enough for 99% of cases?)
mouse_move_call_delay = 2
# Cycle between brush overlap modes on a button press
func cycle_overlap_modes():
active_brush_overlap_mode += 1
if active_brush_overlap_mode > Toolshed_Brush.OverlapMode.PROJECTION:
active_brush_overlap_mode = Toolshed_Brush.OverlapMode.VOLUME
changed_active_brush_prop.emit("behavior/behavior_overlap_mode", active_brush_overlap_mode, true)
#-------------------------------------------------------------------------------
# Setters for brush parameters meant to be accessed from outside
# In response to UI inputs
#-------------------------------------------------------------------------------
func update_all_props_to_active_brush(brush: Toolshed_Brush):
var max_size = 1.0
var max_strength = 1.0
var curr_size = 1.0
var curr_strength = brush.behavior_strength
match brush.behavior_overlap_mode:
Toolshed_Brush.OverlapMode.VOLUME:
max_size = FunLib.get_setting_safe("dreadpons_spatial_gardener/input_and_ui/brush_volume_size_slider_max_value", 100.0)
curr_size = brush.shape_volume_size
Toolshed_Brush.OverlapMode.PROJECTION:
max_size = FunLib.get_setting_safe("dreadpons_spatial_gardener/input_and_ui/brush_projection_size_slider_max_value", 1000.0)
curr_size = brush.shape_projection_size
set_active_brush_overlap_mode(brush.behavior_overlap_mode)
set_active_brush_max_size(max_size)
set_active_brush_max_strength(max_strength)
set_active_brush_size(curr_size)
set_active_brush_strength(curr_strength)
# Update helper variables and visuals
func set_active_brush_size(val):
active_brush_size = val
paint_brush_node.material_override.set_shader_parameter("proximity_multiplier", active_brush_size * 0.5)
queue_call_when_camera('set_brush_diameter', [active_brush_size])
# Update helper variables and visuals
func set_active_brush_max_size(val):
active_brush_max_size = val
queue_call_when_camera('set_brush_diameter', [active_brush_size])
# Update helper variables
func set_active_brush_strength(val):
active_brush_strength = val
# Update helper variables
func set_active_brush_max_strength(val):
active_brush_max_strength = val
# Update visuals
func set_brush_diameter(diameter: float):
match active_brush_overlap_mode:
Toolshed_Brush.OverlapMode.VOLUME:
paint_brush_node.mesh.radius = diameter * 0.5
paint_brush_node.mesh.height = diameter
Toolshed_Brush.OverlapMode.PROJECTION:
var camera_normal = -_cached_camera.global_transform.basis.z
var planar_dist_to_camera = (active_brush_data.brush_pos - _cached_camera.global_transform.origin).dot(camera_normal)
var circle_center:Vector3 = active_brush_data.brush_pos
var circle_edge:Vector3
# If we're editing props (or just finished it as indicated by 'mouse_move_call_delay')
# Then to prevent size doubling/overflow use out brush position as mouse position
# (Since out mouse WILL be offset due to us dragging it to the side)
if brush_prop_edit_flag > BrushPropEditFlag.NONE || mouse_move_call_delay > 0:
var screen_space_brush_pos = _cached_camera.unproject_position(active_brush_data.brush_pos)
circle_edge = _cached_camera.project_position(screen_space_brush_pos + Vector2(diameter * 0.5, 0), planar_dist_to_camera)
else:
circle_edge = project_mouse(planar_dist_to_camera, Vector2(diameter * 0.5, 0))
var size = (circle_edge - circle_center).length()
paint_brush_node.mesh.size = Vector2(size, size) * 2.0
func set_brush_collision_mask(val):
brush_collision_mask = val
# Update helper variables and visuals
func set_active_brush_overlap_mode(val):
active_brush_overlap_mode = val
match active_brush_overlap_mode:
Toolshed_Brush.OverlapMode.VOLUME:
set_brush_mesh(true)
Toolshed_Brush.OverlapMode.PROJECTION:
set_brush_mesh(false)
# Since we are rebuilding the mesh here
# It means that we need to move it in a proper position as well
move_brush()
#-------------------------------------------------------------------------------
# Camera3D/raycasting methods
#-------------------------------------------------------------------------------
func project_mouse_near() -> Vector3:
return project_mouse(_cached_camera.near)
func project_mouse_far() -> Vector3:
return project_mouse(_cached_camera.far - 0.1)
func project_mouse(distance: float, offset: Vector2 = Vector2.ZERO) -> Vector3:
return _cached_camera.project_position(_cached_camera.get_viewport().get_mouse_position() + offset, distance)