@tool extends EditorPlugin enum StrokeMode { NONE, PAINT, ERASE, } enum HeightOffsetMode { WORLD_Y, SURFACE_NORMAL, } const DOCK_SCENE: PackedScene = preload( "res://addons/world_brush/ui/world_brush_dock.tscn" ) const LOD_INSTANCE_SCRIPT: Script = preload( "res://addons/world_brush/world_brush_lod_instance.gd" ) const AUTO_LOD_CONTROLLER_SCRIPT: Script = preload( "res://addons/world_brush/world_brush_auto_lod_controller.gd" ) const RAY_LENGTH: float = 100000.0 const MAX_RAY_SKIPS: int = 64 const SAMPLE_VERTICAL_RANGE: float = 100000.0 const BRUSH_SEGMENTS: int = 64 const BRUSH_HEIGHT_OFFSET: float = 0.08 const PAINT_PREVIEW_COLOR := Color( 0.10, 0.80, 1.00, 1.00 ) const ERASE_PREVIEW_COLOR := Color( 1.00, 0.12, 0.08, 1.00 ) const STAMP_STEP_RADIUS_FACTOR: float = 0.35 const MINIMUM_STAMP_STEP: float = 0.5 const PLACEMENT_ATTEMPTS_PER_INSTANCE: int = 30 const MINIMUM_PLACEMENT_ATTEMPTS: int = 80 const MAX_TOTAL_PLACEMENT_ATTEMPTS: int = 2000 const TERRAIN_SCAN_INTERVAL_MS: int = 1000 const INVALID_TERRAIN_LIMIT: float = 3.0e38 const CONTAINER_NAME: StringName = &"WorldBrushInstances" const CONTAINER_META: StringName = &"world_brush_container" const INSTANCE_META: StringName = &"world_brush_instance" const SOURCE_SCENE_META: StringName = &"world_brush_source_scene_path" const LOD_GROUP_META: StringName = &"world_brush_lod_group" const LOD_NODE_GROUP: StringName = &"world_brush_distance_lod" const AUTO_LOD_CONTROLLER_NAME: StringName = &"WorldBrushAutoLOD" const AUTO_LOD_IMPORT_EXTENSIONS := [ "glb", "gltf", "fbx", "obj", "blend" ] var world_brush_dock: EditorDock var dock_content: Control var selected_scene: PackedScene var brush_enabled: bool = false var brush_radius: float = 5.0 var instances_per_click: int = 5 var minimum_spacing: float = 1.0 var random_y_rotation: bool = true var align_to_surface: bool = false var maximum_slope_degrees: float = 85.0 var height_offset: float = 0.0 var height_offset_mode: int = HeightOffsetMode.SURFACE_NORMAL var random_scale_enabled: bool = true var minimum_scale: float = 0.8 var maximum_scale: float = 1.3 var lod_enabled: bool = false var lod1_scene: PackedScene var lod2_scene: PackedScene var lod1_distance: float = 25.0 var lod2_distance: float = 60.0 var lod_cull_distance: float = 0.0 var editor_lod_preview_elapsed: float = 0.0 var auto_lod_enabled: bool = false var auto_lod_bias: float = 1.0 var auto_lod_cull_distance: float = 0.0 # ========================================================= # السطح الحالي # ========================================================= var has_surface_hit: bool = false var surface_position: Vector3 = Vector3.ZERO var surface_normal: Vector3 = Vector3.UP var surface_type: String = "" # ========================================================= # حالة السحبة # ========================================================= var stroke_mode: int = StrokeMode.NONE var has_last_stamp_position: bool = false var last_stamp_position: Vector3 = Vector3.ZERO var stroke_scene_root: Node3D var stroke_container: Node3D var stroke_created_container: bool = false # ========================================================= # عناصر سحبة الرسم والحذف # ========================================================= var painted_instances: Array[Node3D] = [] var painted_transforms: Array[Transform3D] = [] var erased_instances: Array[Node3D] = [] var erased_transforms: Array[Transform3D] = [] # ========================================================= # معاينة الفرشاة # ========================================================= var brush_preview: MeshInstance3D var brush_preview_mesh: ImmediateMesh var brush_preview_material: StandardMaterial3D var erase_preview_active: bool = false # ========================================================= # Terrain3D Cache # ========================================================= var cached_scene_root: Node var cached_terrain_nodes: Array[Node] = [] var last_terrain_scan_time: int = 0 var random_generator: RandomNumberGenerator # ========================================================= # تشغيل وإيقاف الإضافة # ========================================================= func _enter_tree() -> void: random_generator = RandomNumberGenerator.new() random_generator.randomize() set_process(true) dock_content = DOCK_SCENE.instantiate() as Control if dock_content == null: push_error( "World Brush: Failed to instantiate dock content." ) return _create_editor_dock() _connect_dock_signals() if not scene_changed.is_connected( _on_editor_scene_changed ): scene_changed.connect( _on_editor_scene_changed ) set_input_event_forwarding_always_enabled() call_deferred( "_show_world_brush_dock" ) call_deferred( "_upgrade_existing_lod_groups" ) call_deferred( "_apply_lod_settings_to_existing_groups" ) call_deferred( "_apply_auto_lod_settings_to_existing_instances" ) print( "World Brush: Plugin enabled successfully." ) func _exit_tree() -> void: _end_active_stroke() if scene_changed.is_connected( _on_editor_scene_changed ): scene_changed.disconnect( _on_editor_scene_changed ) set_process(false) brush_enabled = false selected_scene = null has_surface_hit = false surface_type = "" _clear_terrain_cache() _remove_brush_preview() if is_instance_valid(world_brush_dock): remove_dock( world_brush_dock ) world_brush_dock.queue_free() world_brush_dock = null dock_content = null random_generator = null print( "World Brush: Plugin disabled successfully." ) func _process( delta: float ) -> void: _update_editor_lod_preview(delta) if stroke_mode == StrokeMode.NONE: return if stroke_mode == StrokeMode.PAINT: if not Input.is_mouse_button_pressed( MOUSE_BUTTON_LEFT ): _end_active_stroke() elif stroke_mode == StrokeMode.ERASE: if not Input.is_mouse_button_pressed( MOUSE_BUTTON_RIGHT ): _end_active_stroke() func _handles( object: Object ) -> bool: if object == null: var edited_root := ( get_editor_interface() .get_edited_scene_root() ) return edited_root is Node3D return object is Node3D # ========================================================= # نافذة الإضافة # ========================================================= func _create_editor_dock() -> void: world_brush_dock = EditorDock.new() world_brush_dock.name = ( "WorldBrushEditorDock" ) world_brush_dock.title = "World Brush" world_brush_dock.layout_key = ( "world_brush_editor_dock" ) world_brush_dock.default_slot = ( EditorDock.DOCK_SLOT_RIGHT_UL ) world_brush_dock.global = true world_brush_dock.add_child( dock_content ) add_dock( world_brush_dock ) func _connect_dock_signals() -> void: if not _connect_required_signal( &"selected_scene_changed", &"_on_selected_scene_changed" ): return if not _connect_required_signal( &"brush_enabled_changed", &"_on_brush_enabled_changed" ): return if not _connect_required_signal( &"brush_radius_changed", &"_on_brush_radius_changed" ): return if not _connect_required_signal( &"instances_per_click_changed", &"_on_instances_per_click_changed" ): return if not _connect_required_signal( &"minimum_spacing_changed", &"_on_minimum_spacing_changed" ): return if not _connect_required_signal( &"random_y_rotation_changed", &"_on_random_y_rotation_changed" ): return if not _connect_required_signal( &"align_to_surface_changed", &"_on_align_to_surface_changed" ): return if not _connect_required_signal( &"maximum_slope_changed", &"_on_maximum_slope_changed" ): return if not _connect_required_signal( &"height_offset_changed", &"_on_height_offset_changed" ): return if not _connect_required_signal( &"height_offset_mode_changed", &"_on_height_offset_mode_changed" ): return if not _connect_required_signal( &"random_scale_enabled_changed", &"_on_random_scale_enabled_changed" ): return if not _connect_required_signal( &"minimum_scale_changed", &"_on_minimum_scale_changed" ): return if not _connect_required_signal( &"maximum_scale_changed", &"_on_maximum_scale_changed" ): return if not _connect_required_signal( &"lod_settings_changed", &"_on_lod_settings_changed" ): return if not _connect_required_signal( &"auto_lod_settings_changed", &"_on_auto_lod_settings_changed" ): return if not _connect_required_signal( &"auto_lod_import_requested", &"_on_auto_lod_import_requested" ): return func _connect_required_signal( signal_name: StringName, method_name: StringName ) -> bool: if not dock_content.has_signal( signal_name ): push_error( "World Brush: Missing signal -> %s" % signal_name ) return false var signal_callable := Callable( self, method_name ) if not dock_content.is_connected( signal_name, signal_callable ): dock_content.connect( signal_name, signal_callable ) return true func _show_world_brush_dock() -> void: if not is_instance_valid( world_brush_dock ): return world_brush_dock.open() world_brush_dock.make_visible() # ========================================================= # إدخال نافذة 3D # ========================================================= func _forward_3d_gui_input( camera: Camera3D, event: InputEvent ) -> int: if event is InputEventKey: _handle_keyboard_event( event as InputEventKey ) return EditorPlugin.AFTER_GUI_INPUT_PASS if not brush_enabled: _end_active_stroke() _hide_brush_preview() return EditorPlugin.AFTER_GUI_INPUT_PASS if event is InputEventMouseMotion: return _handle_mouse_motion( camera, event as InputEventMouseMotion ) if event is InputEventMouseButton: return _handle_mouse_button( camera, event as InputEventMouseButton ) return EditorPlugin.AFTER_GUI_INPUT_PASS func _handle_keyboard_event( key_event: InputEventKey ) -> void: if not key_event.pressed: return if key_event.echo: return var undo_shortcut := ( ( key_event.ctrl_pressed or key_event.meta_pressed ) and key_event.keycode == KEY_Z ) if undo_shortcut: _end_active_stroke() func _handle_mouse_motion( camera: Camera3D, mouse_motion: InputEventMouseMotion ) -> int: _update_surface_hit( camera, mouse_motion.position ) if _event_uses_modifier( mouse_motion ): _end_active_stroke() return EditorPlugin.AFTER_GUI_INPUT_PASS var left_button_down := ( mouse_motion.button_mask & MOUSE_BUTTON_MASK_LEFT ) != 0 var right_button_down := ( mouse_motion.button_mask & MOUSE_BUTTON_MASK_RIGHT ) != 0 if right_button_down: if stroke_mode != StrokeMode.ERASE: _end_active_stroke() _begin_erase_stroke() else: _continue_active_stroke() return EditorPlugin.AFTER_GUI_INPUT_STOP if left_button_down: if stroke_mode != StrokeMode.PAINT: _end_active_stroke() _begin_paint_stroke() else: _continue_active_stroke() return EditorPlugin.AFTER_GUI_INPUT_STOP if stroke_mode != StrokeMode.NONE: _end_active_stroke() return EditorPlugin.AFTER_GUI_INPUT_PASS func _handle_mouse_button( camera: Camera3D, mouse_button: InputEventMouseButton ) -> int: _update_surface_hit( camera, mouse_button.position ) var is_left_button := ( mouse_button.button_index == MOUSE_BUTTON_LEFT ) var is_right_button := ( mouse_button.button_index == MOUSE_BUTTON_RIGHT ) if not is_left_button and not is_right_button: return EditorPlugin.AFTER_GUI_INPUT_PASS if _event_uses_modifier( mouse_button ): _end_active_stroke() return EditorPlugin.AFTER_GUI_INPUT_PASS if mouse_button.pressed: if is_left_button: _end_active_stroke() _begin_paint_stroke() elif is_right_button: _end_active_stroke() _begin_erase_stroke() else: if ( is_left_button and stroke_mode == StrokeMode.PAINT ): _end_active_stroke() elif ( is_right_button and stroke_mode == StrokeMode.ERASE ): _end_active_stroke() return EditorPlugin.AFTER_GUI_INPUT_STOP func _event_uses_modifier( event: InputEventWithModifiers ) -> bool: return ( event.alt_pressed or event.ctrl_pressed or event.shift_pressed or event.meta_pressed ) # ========================================================= # إدارة السحبة # ========================================================= func _begin_paint_stroke() -> void: if selected_scene == null: _set_dock_status( "Select An Active Scene First" ) return if not has_surface_hit: _set_dock_status( "No Valid Surface" ) return _reset_stroke_data() stroke_mode = StrokeMode.PAINT has_last_stamp_position = false last_stamp_position = Vector3.ZERO _set_erase_preview_active( false ) _stamp_brush( true ) func _begin_erase_stroke() -> void: if not has_surface_hit: _set_dock_status( "No Valid Surface" ) return _reset_stroke_data() stroke_mode = StrokeMode.ERASE has_last_stamp_position = false last_stamp_position = Vector3.ZERO _set_erase_preview_active( true ) _stamp_brush( true ) func _continue_active_stroke() -> void: if stroke_mode == StrokeMode.NONE: return _stamp_brush( false ) func _end_active_stroke() -> void: var finished_mode := stroke_mode if finished_mode == StrokeMode.NONE: return stroke_mode = StrokeMode.NONE has_last_stamp_position = false last_stamp_position = Vector3.ZERO _set_erase_preview_active( false ) if finished_mode == StrokeMode.PAINT: _commit_paint_stroke() elif finished_mode == StrokeMode.ERASE: _commit_erase_stroke() _reset_stroke_data() func _reset_stroke_data() -> void: stroke_scene_root = null stroke_container = null stroke_created_container = false painted_instances.clear() painted_transforms.clear() erased_instances.clear() erased_transforms.clear() func _stamp_brush( force_stamp: bool ) -> void: if not has_surface_hit: return if ( not force_stamp and has_last_stamp_position ): var required_step := ( _get_stamp_step() ) var distance_squared := ( _horizontal_distance_squared( surface_position, last_stamp_position ) ) if ( distance_squared < required_step * required_step ): return var affected_count: int = 0 if stroke_mode == StrokeMode.PAINT: affected_count = ( _paint_scene_instances_live() ) elif stroke_mode == StrokeMode.ERASE: affected_count = ( _erase_scene_instances_live() ) last_stamp_position = surface_position has_last_stamp_position = true if stroke_mode == StrokeMode.PAINT: if affected_count > 0: _set_dock_status( "Painted In Stroke: %d" % painted_instances.size() ) elif stroke_mode == StrokeMode.ERASE: if affected_count > 0: _set_dock_status( "Erased In Stroke: %d" % erased_instances.size() ) else: _set_dock_status( "Erase Brush — No Instances Here" ) func _get_stamp_step() -> float: var radius_step := ( brush_radius * STAMP_STEP_RADIUS_FACTOR ) return maxf( MINIMUM_STAMP_STEP, maxf( minimum_spacing, radius_step ) ) # ========================================================= # الرسم المباشر # ========================================================= func _paint_scene_instances_live() -> int: if selected_scene == null: return 0 if not selected_scene.can_instantiate(): _set_dock_status( "Selected Scene Cannot Be Instantiated" ) return 0 var edited_root := ( get_editor_interface() .get_edited_scene_root() ) if not edited_root is Node3D: _set_dock_status( "Scene Root Must Be Node3D" ) return 0 var scene_root := edited_root as Node3D var world_3d := scene_root.get_world_3d() if world_3d == null: _set_dock_status( "3D World Not Available" ) return 0 if stroke_scene_root == null: stroke_scene_root = scene_root if stroke_scene_root != scene_root: _set_dock_status( "Edited Scene Changed During Stroke" ) return 0 if not is_instance_valid( stroke_container ): stroke_container = ( _find_world_brush_container( scene_root ) ) if stroke_container == null: stroke_container = Node3D.new() stroke_container.name = CONTAINER_NAME stroke_container.set_meta( CONTAINER_META, true ) scene_root.add_child( stroke_container, true ) stroke_container.owner = scene_root stroke_created_container = true if not is_instance_valid( stroke_container ): _set_dock_status( "Failed To Create Container" ) return 0 var placement_samples := ( _generate_brush_samples( scene_root, world_3d, stroke_container ) ) if placement_samples.is_empty(): _set_dock_status( "No Valid Space Inside Brush" ) return 0 var placed_count: int = 0 for sample in placement_samples: var created_node := ( _instantiate_selected_scene() ) if created_node == null: continue if not created_node is Node3D: created_node.free() _set_dock_status( "Selected Scene Root Must Inherit Node3D" ) break var instance := created_node as Node3D instance.set_meta( INSTANCE_META, true ) instance.set_meta( SOURCE_SCENE_META, selected_scene.resource_path ) var authored_basis := ( instance.transform.basis ) var placement_position: Vector3 = ( sample.get( "position", Vector3.ZERO ) ) var placement_normal: Vector3 = ( sample.get( "normal", Vector3.UP ) ) var placement_basis: Basis = ( sample.get( "placement_basis", Basis.IDENTITY ) ) placement_position = _apply_height_offset( placement_position, placement_normal ) var scale_factor := ( _get_random_scale_factor() ) var final_basis := ( placement_basis * authored_basis ) final_basis = final_basis.scaled_local( Vector3.ONE * scale_factor ) var final_transform := Transform3D( final_basis, placement_position ) stroke_container.add_child( instance, true ) instance.owner = scene_root _apply_auto_lod_to_instance(instance, scene_root) _assign_lod_child_owners( instance, scene_root ) instance.global_transform = final_transform painted_instances.append( instance ) painted_transforms.append( final_transform ) placed_count += 1 return placed_count func _instantiate_selected_scene() -> Node3D: if not lod_enabled: return _instantiate_scene_root( selected_scene ) var levels: Array[Dictionary] = [ { "scene": selected_scene, "begin": 0.0, "name": "Near", }, ] if lod1_scene != null: levels.append({ "scene": lod1_scene, "begin": lod1_distance, "name": "Medium", }) if lod2_scene != null: levels.append({ "scene": lod2_scene, "begin": lod2_distance, "name": "Distant", }) if levels.size() == 1 and lod_cull_distance <= 0.0: return _instantiate_scene_root( selected_scene ) var group := Node3D.new() group.name = "%s_LOD" % selected_scene.resource_path.get_file().get_basename() group.set_script(LOD_INSTANCE_SCRIPT) group.add_to_group(LOD_NODE_GROUP, true) group.set_meta(LOD_GROUP_META, true) group.set_meta( &"lod_source_scene_path", selected_scene.resource_path ) group.set_meta(&"lod_enabled", lod_enabled) group.set_meta( &"lod1_scene_path", lod1_scene.resource_path if lod1_scene != null else "" ) group.set_meta( &"lod2_scene_path", lod2_scene.resource_path if lod2_scene != null else "" ) group.set_meta(&"lod1_distance", lod1_distance) group.set_meta(&"lod2_distance", lod2_distance) group.set_meta(&"lod_cull_distance", lod_cull_distance) var authored_basis := Basis.IDENTITY for index in range(levels.size()): var level: Dictionary = levels[index] var level_scene := level.get("scene") as PackedScene var level_root := _instantiate_scene_root(level_scene) if level_root == null: group.free() return null if index == 0: authored_basis = level_root.transform.basis level_root.name = str(level.get("name", "LOD")) level_root.transform = Transform3D.IDENTITY group.add_child(level_root, true) level_root.visible = index == 0 _disable_native_lod_ranges( level_root ) group.transform = Transform3D( authored_basis, Vector3.ZERO ) group.call( &"configure", lod1_distance, lod2_distance, lod_cull_distance ) return group func _instantiate_scene_root( scene: PackedScene ) -> Node3D: if scene == null or not scene.can_instantiate(): _set_dock_status( "LOD Scene Cannot Be Instantiated" ) return null var created_node := scene.instantiate( PackedScene.GEN_EDIT_STATE_INSTANCE ) if created_node is Node3D: return created_node as Node3D if created_node != null: created_node.free() _set_dock_status( "LOD Scene Root Must Inherit Node3D" ) return null func _disable_native_lod_ranges( root: Node ) -> bool: var geometry_instances: Array[GeometryInstance3D] = [] _collect_geometry_instances( root, geometry_instances ) var changed := false for geometry in geometry_instances: if ( not is_zero_approx(geometry.visibility_range_begin) or not is_zero_approx(geometry.visibility_range_end) or not is_zero_approx(geometry.visibility_range_begin_margin) or not is_zero_approx(geometry.visibility_range_end_margin) ): changed = true geometry.visibility_range_begin = 0.0 geometry.visibility_range_end = 0.0 geometry.visibility_range_begin_margin = 0.0 geometry.visibility_range_end_margin = 0.0 geometry.visibility_range_fade_mode = ( GeometryInstance3D.VISIBILITY_RANGE_FADE_DISABLED ) return changed func _collect_geometry_instances( node: Node, result: Array[GeometryInstance3D] ) -> void: if node is GeometryInstance3D: result.append(node as GeometryInstance3D) for child in node.get_children(): _collect_geometry_instances( child, result ) func _assign_lod_child_owners( instance: Node3D, scene_root: Node3D ) -> void: if not bool(instance.get_meta(LOD_GROUP_META, false)): return for child in instance.get_children(): if child is Node3D: (child as Node3D).owner = scene_root func _on_editor_scene_changed( _scene_root: Node ) -> void: call_deferred( "_upgrade_existing_lod_groups" ) call_deferred( "_apply_lod_settings_to_existing_groups" ) call_deferred( "_apply_auto_lod_settings_to_existing_instances" ) func _upgrade_existing_lod_groups() -> void: var scene_root := ( get_editor_interface() .get_edited_scene_root() ) if scene_root == null: return var upgraded := _upgrade_lod_groups_recursive( scene_root ) if upgraded <= 0: return var editor_interface := get_editor_interface() if editor_interface.has_method( &"mark_scene_as_unsaved" ): editor_interface.call( &"mark_scene_as_unsaved" ) print( "World Brush: Upgraded existing LOD groups -> ", upgraded ) func _upgrade_lod_groups_recursive( node: Node ) -> int: var upgraded := 0 if ( node is Node3D and bool(node.get_meta(LOD_GROUP_META, false)) ): var lod_group := node as Node3D var group_changed := false if not lod_group.is_in_group(LOD_NODE_GROUP): lod_group.add_to_group(LOD_NODE_GROUP, true) group_changed = true if lod_group.get_script() != LOD_INSTANCE_SCRIPT: lod_group.set_script( LOD_INSTANCE_SCRIPT ) group_changed = true lod_group.call( &"configure", float(lod_group.get_meta(&"lod1_distance", 25.0)), float(lod_group.get_meta(&"lod2_distance", 60.0)), float(lod_group.get_meta(&"lod_cull_distance", 0.0)) ) for level in lod_group.get_children(): if _disable_native_lod_ranges(level): group_changed = true if group_changed: upgraded += 1 for child in node.get_children(): upgraded += _upgrade_lod_groups_recursive( child ) return upgraded func _update_editor_lod_preview( delta: float ) -> void: editor_lod_preview_elapsed += delta if editor_lod_preview_elapsed < 0.15: return editor_lod_preview_elapsed = 0.0 var scene_root := ( get_editor_interface() .get_edited_scene_root() ) if scene_root == null: return var editor_viewport := ( get_editor_interface() .get_editor_viewport_3d(0) ) if editor_viewport == null: return var camera := editor_viewport.get_camera_3d() if camera == null: return for candidate in get_tree().get_nodes_in_group( LOD_NODE_GROUP ): if not candidate is Node3D: continue var lod_group := candidate as Node3D if ( lod_group != scene_root and not scene_root.is_ancestor_of(lod_group) ): continue if lod_group.has_method(&"update_for_distance"): lod_group.call( &"update_for_distance", camera.global_position.distance_to( lod_group.global_position ) ) func _apply_height_offset( position: Vector3, normal: Vector3 ) -> Vector3: if is_zero_approx( height_offset ): return position if ( height_offset_mode == HeightOffsetMode.WORLD_Y ): return ( position + Vector3.UP * height_offset ) var safe_normal := normal if not _is_valid_normal( safe_normal ): safe_normal = Vector3.UP else: safe_normal = safe_normal.normalized() return ( position + safe_normal * height_offset ) func _get_random_scale_factor() -> float: if not random_scale_enabled: return 1.0 var safe_minimum := maxf( minimum_scale, 0.01 ) var safe_maximum := maxf( maximum_scale, safe_minimum ) if is_equal_approx( safe_minimum, safe_maximum ): return safe_minimum return random_generator.randf_range( safe_minimum, safe_maximum ) # ========================================================= # الحذف المباشر # ========================================================= func _erase_scene_instances_live() -> int: var edited_root := ( get_editor_interface() .get_edited_scene_root() ) if not edited_root is Node3D: _set_dock_status( "Scene Root Must Be Node3D" ) return 0 var scene_root := edited_root as Node3D if stroke_scene_root == null: stroke_scene_root = scene_root if stroke_scene_root != scene_root: _set_dock_status( "Edited Scene Changed During Stroke" ) return 0 if not is_instance_valid( stroke_container ): stroke_container = ( _find_world_brush_container( scene_root ) ) if not is_instance_valid( stroke_container ): _set_dock_status( "No World Brush Instances" ) return 0 var radius_squared := ( brush_radius * brush_radius ) var erased_count: int = 0 for child in stroke_container.get_children(): if not child is Node3D: continue var instance := child as Node3D if not bool( instance.get_meta( INSTANCE_META, false ) ): continue var distance_squared := ( _horizontal_distance_squared( instance.global_position, surface_position ) ) if distance_squared > radius_squared: continue erased_instances.append( instance ) erased_transforms.append( instance.global_transform ) instance.owner = null stroke_container.remove_child( instance ) erased_count += 1 return erased_count # ========================================================= # Undo لسحبة الرسم # ========================================================= func _commit_paint_stroke() -> void: if painted_instances.is_empty(): _remove_empty_created_container() return if not is_instance_valid( stroke_scene_root ): return if not is_instance_valid( stroke_container ): return var instances_snapshot: Array = ( painted_instances.duplicate() ) var transforms_snapshot: Array = ( painted_transforms.duplicate() ) var saved_scene_root := stroke_scene_root var saved_container := stroke_container var saved_created_container := ( stroke_created_container ) var undo_redo := get_undo_redo() undo_redo.create_action( "World Brush: Paint Stroke (%d)" % instances_snapshot.size(), UndoRedo.MERGE_DISABLE, saved_scene_root ) undo_redo.add_do_method( self, &"_redo_paint_batch", saved_scene_root, saved_container, instances_snapshot, transforms_snapshot, saved_created_container ) undo_redo.add_undo_method( self, &"_undo_paint_batch", saved_scene_root, saved_container, instances_snapshot, saved_created_container ) if saved_created_container: undo_redo.add_do_reference( saved_container ) for instance_value in instances_snapshot: if instance_value is Node3D: var instance := ( instance_value as Node3D ) if is_instance_valid(instance): undo_redo.add_do_reference( instance ) undo_redo.commit_action( false ) _set_dock_status( "Paint Stroke Saved: %d" % instances_snapshot.size() ) func _redo_paint_batch( scene_root: Node3D, container: Node3D, instances: Array, transforms: Array, created_container: bool ) -> void: if not is_instance_valid(scene_root): return if not is_instance_valid(container): return if ( created_container and container.get_parent() == null ): scene_root.add_child( container, true ) container.owner = scene_root for index in range( instances.size() ): var instance_value: Variant = ( instances[index] ) if not instance_value is Node3D: continue var instance := ( instance_value as Node3D ) if not is_instance_valid(instance): continue if instance.get_parent() == null: container.add_child( instance, true ) instance.owner = scene_root _assign_lod_child_owners( instance, scene_root ) if index < transforms.size(): var saved_transform: Variant = ( transforms[index] ) if saved_transform is Transform3D: instance.global_transform = ( saved_transform ) func _undo_paint_batch( scene_root: Node3D, container: Node3D, instances: Array, created_container: bool ) -> void: if not is_instance_valid(container): return for instance_value in instances: if not instance_value is Node3D: continue var instance := ( instance_value as Node3D ) if not is_instance_valid(instance): continue if instance.get_parent() == container: instance.owner = null container.remove_child( instance ) if ( created_container and is_instance_valid(scene_root) and container.get_parent() == scene_root and container.get_child_count() == 0 ): container.owner = null scene_root.remove_child( container ) # ========================================================= # Undo لسحبة الحذف # ========================================================= func _commit_erase_stroke() -> void: if erased_instances.is_empty(): return if not is_instance_valid( stroke_scene_root ): return if not is_instance_valid( stroke_container ): return var instances_snapshot: Array = ( erased_instances.duplicate() ) var transforms_snapshot: Array = ( erased_transforms.duplicate() ) var saved_scene_root := stroke_scene_root var saved_container := stroke_container var undo_redo := get_undo_redo() undo_redo.create_action( "World Brush: Erase Stroke (%d)" % instances_snapshot.size(), UndoRedo.MERGE_DISABLE, saved_scene_root ) undo_redo.add_do_method( self, &"_redo_erase_batch", saved_container, instances_snapshot ) undo_redo.add_undo_method( self, &"_undo_erase_batch", saved_scene_root, saved_container, instances_snapshot, transforms_snapshot ) for instance_value in instances_snapshot: if instance_value is Node3D: var instance := ( instance_value as Node3D ) if is_instance_valid(instance): undo_redo.add_undo_reference( instance ) undo_redo.commit_action( false ) _set_dock_status( "Erase Stroke Saved: %d" % instances_snapshot.size() ) func _redo_erase_batch( container: Node3D, instances: Array ) -> void: if not is_instance_valid(container): return for instance_value in instances: if not instance_value is Node3D: continue var instance := ( instance_value as Node3D ) if not is_instance_valid(instance): continue if instance.get_parent() == container: instance.owner = null container.remove_child( instance ) func _undo_erase_batch( scene_root: Node3D, container: Node3D, instances: Array, transforms: Array ) -> void: if not is_instance_valid(scene_root): return if not is_instance_valid(container): return if container.get_parent() == null: scene_root.add_child( container, true ) container.owner = scene_root for index in range( instances.size() ): var instance_value: Variant = ( instances[index] ) if not instance_value is Node3D: continue var instance := ( instance_value as Node3D ) if not is_instance_valid(instance): continue if instance.get_parent() == null: container.add_child( instance, true ) instance.owner = scene_root _assign_lod_child_owners( instance, scene_root ) if index < transforms.size(): var saved_transform: Variant = ( transforms[index] ) if saved_transform is Transform3D: instance.global_transform = ( saved_transform ) func _remove_empty_created_container() -> void: if not stroke_created_container: return if not is_instance_valid( stroke_container ): return if stroke_container.get_child_count() > 0: return var parent := stroke_container.get_parent() if parent != null: stroke_container.owner = null parent.remove_child( stroke_container ) stroke_container.free() # ========================================================= # العثور على حاوية العناصر # ========================================================= func _find_world_brush_container( scene_root: Node3D ) -> Node3D: for child in scene_root.get_children(): if not child is Node3D: continue if bool( child.get_meta( CONTAINER_META, false ) ): return child as Node3D return null # ========================================================= # اكتشاف السطح # ========================================================= func _update_surface_hit( camera: Camera3D, mouse_position: Vector2 ) -> void: var edited_root := ( get_editor_interface() .get_edited_scene_root() ) if not edited_root is Node3D: _clear_surface_hit( "Open A 3D Scene First" ) return var scene_root := edited_root as Node3D var world_3d := scene_root.get_world_3d() if world_3d == null: _clear_surface_hit( "3D World Not Available" ) return var ray_origin := ( camera.project_ray_origin( mouse_position ) ) var ray_direction := ( camera.project_ray_normal( mouse_position ).normalized() ) var ray_end := ( ray_origin + ray_direction * RAY_LENGTH ) var result := _find_cursor_surface( scene_root, world_3d, ray_origin, ray_direction, ray_end ) if result.is_empty(): _clear_surface_hit( "No Surface Under Cursor" ) return var hit_position: Vector3 = ( result.get( "position", Vector3.ZERO ) ) var hit_normal: Vector3 = ( result.get( "normal", Vector3.UP ) ) if not hit_position.is_finite(): _clear_surface_hit( "Invalid Surface Position" ) return if not _is_valid_normal( hit_normal ): hit_normal = Vector3.UP has_surface_hit = true surface_position = hit_position surface_normal = hit_normal.normalized() surface_type = str( result.get( "surface_type", "Surface" ) ) _show_brush_preview( scene_root ) if stroke_mode == StrokeMode.NONE: _set_dock_status( "%s | X %.2f | Y %.2f | Z %.2f" % [ surface_type, surface_position.x, surface_position.y, surface_position.z, ] ) func _find_cursor_surface( scene_root: Node3D, world_3d: World3D, ray_origin: Vector3, ray_direction: Vector3, ray_end: Vector3 ) -> Dictionary: var physics_hit := ( _intersect_physics_surface( world_3d, ray_origin, ray_end ) ) var terrain_hit := ( _intersect_terrain_cursor( scene_root, ray_origin, ray_direction ) ) if physics_hit.is_empty(): return terrain_hit if terrain_hit.is_empty(): return physics_hit var physics_position: Vector3 = ( physics_hit.get( "position", Vector3.ZERO ) ) var terrain_position: Vector3 = ( terrain_hit.get( "position", Vector3.ZERO ) ) var physics_distance := ( ray_origin.distance_squared_to( physics_position ) ) var terrain_distance := ( ray_origin.distance_squared_to( terrain_position ) ) if physics_distance <= terrain_distance: return physics_hit return terrain_hit func _intersect_terrain_cursor( scene_root: Node3D, ray_origin: Vector3, ray_direction: Vector3 ) -> Dictionary: var terrain_nodes := ( _get_terrain3d_nodes( scene_root ) ) var closest_result: Dictionary = {} var closest_distance: float = INF for terrain_node in terrain_nodes: if not is_instance_valid( terrain_node ): continue if not terrain_node.has_method( &"get_intersection" ): continue var point_value: Variant = ( terrain_node.call( &"get_intersection", ray_origin, ray_direction, false ) ) if typeof(point_value) != TYPE_VECTOR3: continue var terrain_point: Vector3 = point_value if not _is_valid_terrain_point( terrain_point ): continue var direction_to_point := ( terrain_point - ray_origin ) if ( direction_to_point.dot( ray_direction ) <= 0.0 ): continue var distance_squared := ( ray_origin.distance_squared_to( terrain_point ) ) if distance_squared >= closest_distance: continue closest_distance = distance_squared closest_result = { "position": terrain_point, "normal": _get_terrain_normal( terrain_node, terrain_point ), "collider": terrain_node, "surface_type": "Terrain3D", } return closest_result # ========================================================= # عينات السطح # ========================================================= func _sample_surface_at_xz( scene_root: Node3D, world_3d: World3D, x_position: float, z_position: float, reference_height: float ) -> Dictionary: var ray_origin := Vector3( x_position, reference_height + SAMPLE_VERTICAL_RANGE, z_position ) var ray_end := Vector3( x_position, reference_height - SAMPLE_VERTICAL_RANGE, z_position ) var physics_hit := ( _intersect_physics_surface( world_3d, ray_origin, ray_end ) ) var terrain_hit := ( _sample_terrain_height( scene_root, x_position, z_position, reference_height ) ) if physics_hit.is_empty(): return terrain_hit if terrain_hit.is_empty(): return physics_hit var physics_position: Vector3 = ( physics_hit.get( "position", Vector3.ZERO ) ) var terrain_position: Vector3 = ( terrain_hit.get( "position", Vector3.ZERO ) ) if physics_position.y >= terrain_position.y: return physics_hit return terrain_hit func _sample_terrain_height( scene_root: Node3D, x_position: float, z_position: float, reference_height: float ) -> Dictionary: var terrain_nodes := ( _get_terrain3d_nodes( scene_root ) ) var best_result: Dictionary = {} var highest_height: float = -INF var query_position := Vector3( x_position, reference_height, z_position ) for terrain_node in terrain_nodes: if not is_instance_valid( terrain_node ): continue var terrain_data := ( _get_terrain_data( terrain_node ) ) if terrain_data == null: continue if not terrain_data.has_method( &"get_height" ): continue var height_value: Variant = ( terrain_data.call( &"get_height", query_position ) ) if ( typeof(height_value) != TYPE_FLOAT and typeof(height_value) != TYPE_INT ): continue var terrain_height := float( height_value ) if ( is_nan(terrain_height) or is_inf(terrain_height) ): continue if terrain_height <= highest_height: continue var terrain_position := Vector3( x_position, terrain_height, z_position ) highest_height = terrain_height best_result = { "position": terrain_position, "normal": _get_terrain_normal( terrain_node, terrain_position ), "collider": terrain_node, "surface_type": "Terrain3D", } return best_result func _get_terrain_data( terrain_node: Node ) -> Object: if terrain_node == null: return null if terrain_node.has_method( &"get_data" ): var data_value: Variant = ( terrain_node.call( &"get_data" ) ) if data_value is Object: return data_value as Object var property_value: Variant = ( terrain_node.get( "data" ) ) if property_value is Object: return property_value as Object return null func _get_terrain_normal( terrain_node: Node, global_position: Vector3 ) -> Vector3: var terrain_data := ( _get_terrain_data( terrain_node ) ) if terrain_data == null: return Vector3.UP if not terrain_data.has_method( &"get_normal" ): return Vector3.UP var normal_value: Variant = ( terrain_data.call( &"get_normal", global_position ) ) if typeof(normal_value) != TYPE_VECTOR3: return Vector3.UP var terrain_normal: Vector3 = normal_value if not _is_valid_normal( terrain_normal ): return Vector3.UP return terrain_normal.normalized() # ========================================================= # RayCast للأسطح العادية # ========================================================= func _intersect_physics_surface( world_3d: World3D, ray_origin: Vector3, ray_end: Vector3 ) -> Dictionary: var excluded_objects: Array[RID] = [] for _attempt in range( MAX_RAY_SKIPS ): var query := ( PhysicsRayQueryParameters3D.create( ray_origin, ray_end ) ) query.collide_with_bodies = true query.collide_with_areas = true query.exclude = excluded_objects var result := ( world_3d .direct_space_state .intersect_ray( query ) ) if result.is_empty(): return {} var collider: Object = ( result.get( "collider" ) ) if not _belongs_to_world_brush_instance( collider ): result["surface_type"] = "Physics" return result if collider is CollisionObject3D: var collision_object := ( collider as CollisionObject3D ) excluded_objects.append( collision_object.get_rid() ) continue return {} return {} func _belongs_to_world_brush_instance( collider: Object ) -> bool: if not collider is Node: return false var current_node := collider as Node while current_node != null: if bool( current_node.get_meta( INSTANCE_META, false ) ): return true current_node = ( current_node.get_parent() ) return false # ========================================================= # توليد نقاط الرسم # ========================================================= func _generate_brush_samples( scene_root: Node3D, world_3d: World3D, container: Node3D ) -> Array[Dictionary]: var generated_samples: Array[Dictionary] = [] var occupied_positions := ( _get_existing_positions( container ) ) var spacing_squared := ( minimum_spacing * minimum_spacing ) var maximum_attempts := maxi( MINIMUM_PLACEMENT_ATTEMPTS, instances_per_click * PLACEMENT_ATTEMPTS_PER_INSTANCE ) maximum_attempts = mini( maximum_attempts, MAX_TOTAL_PLACEMENT_ATTEMPTS ) var attempts: int = 0 while ( generated_samples.size() < instances_per_click and attempts < maximum_attempts ): attempts += 1 var candidate_x := surface_position.x var candidate_z := surface_position.z if instances_per_click > 1: var angle := ( random_generator.randf_range( 0.0, TAU ) ) var distance_from_center := ( sqrt( random_generator.randf() ) * brush_radius ) candidate_x += ( cos(angle) * distance_from_center ) candidate_z += ( sin(angle) * distance_from_center ) var surface_sample := ( _sample_surface_at_xz( scene_root, world_3d, candidate_x, candidate_z, surface_position.y ) ) if surface_sample.is_empty(): continue var candidate_position: Vector3 = ( surface_sample.get( "position", Vector3.ZERO ) ) var candidate_normal: Vector3 = ( surface_sample.get( "normal", Vector3.UP ) ) if not candidate_position.is_finite(): continue if not _is_valid_normal( candidate_normal ): continue candidate_normal = ( candidate_normal.normalized() ) if not _is_slope_allowed( candidate_normal ): continue if not _has_required_spacing( candidate_position, occupied_positions, generated_samples, spacing_squared ): continue var rotation_angle: float = 0.0 if random_y_rotation: rotation_angle = ( random_generator.randf_range( 0.0, TAU ) ) var placement_basis := ( _create_placement_basis( candidate_normal, rotation_angle, align_to_surface ) ) generated_samples.append( { "position": candidate_position, "normal": candidate_normal, "placement_basis": placement_basis, "surface_type": surface_sample.get( "surface_type", "Surface" ), } ) return generated_samples func _create_placement_basis( normal: Vector3, rotation_angle: float, should_align_to_surface: bool ) -> Basis: if not should_align_to_surface: if is_zero_approx( rotation_angle ): return Basis.IDENTITY return Basis( Vector3.UP, rotation_angle ).orthonormalized() var up_vector := normal.normalized() if not _is_valid_normal( up_vector ): up_vector = Vector3.UP var surface_basis := ( _create_surface_basis( up_vector ) ) if is_zero_approx( rotation_angle ): return surface_basis var rotation_basis := Basis( up_vector, rotation_angle ) return ( rotation_basis * surface_basis ).orthonormalized() func _create_surface_basis( up_vector: Vector3 ) -> Basis: var up := up_vector.normalized() if not _is_valid_normal(up): up = Vector3.UP var forward := ( Vector3.FORWARD - up * Vector3.FORWARD.dot(up) ) if forward.length_squared() < 0.000001: forward = ( Vector3.RIGHT - up * Vector3.RIGHT.dot(up) ) if forward.length_squared() < 0.000001: return Basis.IDENTITY forward = forward.normalized() var right := ( forward.cross(up).normalized() ) if not _is_valid_normal(right): return Basis.IDENTITY var backward := ( right.cross(up).normalized() ) if not _is_valid_normal(backward): return Basis.IDENTITY return Basis( right, up, backward ).orthonormalized() func _is_slope_allowed( normal: Vector3 ) -> bool: if not _is_valid_normal( normal ): return false var safe_normal := normal.normalized() var up_alignment := clampf( safe_normal.dot( Vector3.UP ), -1.0, 1.0 ) var slope_radians := acos( up_alignment ) var slope_degrees := rad_to_deg( slope_radians ) return ( slope_degrees <= maximum_slope_degrees ) func _get_existing_positions( container: Node3D ) -> Array[Vector3]: var positions: Array[Vector3] = [] if not is_instance_valid(container): return positions if not container.is_inside_tree(): return positions for child in container.get_children(): if child is Node3D: positions.append( (child as Node3D).global_position ) return positions func _has_required_spacing( candidate: Vector3, existing_positions: Array[Vector3], new_samples: Array[Dictionary], spacing_squared: float ) -> bool: if spacing_squared <= 0.0: return true for existing_position in existing_positions: if ( _horizontal_distance_squared( candidate, existing_position ) < spacing_squared ): return false for sample in new_samples: var new_position: Vector3 = ( sample.get( "position", Vector3.ZERO ) ) if ( _horizontal_distance_squared( candidate, new_position ) < spacing_squared ): return false return true func _horizontal_distance_squared( first: Vector3, second: Vector3 ) -> float: var difference := Vector2( first.x - second.x, first.z - second.z ) return difference.length_squared() # ========================================================= # Terrain3D Cache # ========================================================= func _get_terrain3d_nodes( scene_root: Node ) -> Array[Node]: var current_time := ( Time.get_ticks_msec() ) var root_changed := ( not is_instance_valid( cached_scene_root ) or cached_scene_root != scene_root ) var scan_expired := ( current_time - last_terrain_scan_time >= TERRAIN_SCAN_INTERVAL_MS ) var cache_invalid := false for terrain_node in cached_terrain_nodes: if not is_instance_valid( terrain_node ): cache_invalid = true break if ( root_changed or scan_expired or cache_invalid ): cached_scene_root = scene_root cached_terrain_nodes.clear() _collect_terrain3d_nodes( scene_root, cached_terrain_nodes ) last_terrain_scan_time = current_time return cached_terrain_nodes func _collect_terrain3d_nodes( current_node: Node, result: Array[Node] ) -> void: if _is_terrain3d_node( current_node ): result.append( current_node ) for child in current_node.get_children( true ): if child is Node: _collect_terrain3d_nodes( child as Node, result ) func _is_terrain3d_node( node: Node ) -> bool: if node == null: return false if node.get_class() == "Terrain3D": return true return node.is_class( "Terrain3D" ) func _clear_terrain_cache() -> void: cached_scene_root = null cached_terrain_nodes.clear() last_terrain_scan_time = 0 # ========================================================= # دائرة الفرشاة # ========================================================= func _show_brush_preview( scene_root: Node3D ) -> void: _ensure_brush_preview( scene_root ) if not is_instance_valid( brush_preview ): return brush_preview.global_transform = Transform3D( Basis.IDENTITY, surface_position + Vector3.UP * BRUSH_HEIGHT_OFFSET ) brush_preview.visible = true func _ensure_brush_preview( scene_root: Node3D ) -> void: if is_instance_valid( brush_preview ): if brush_preview.get_parent() == scene_root: return _remove_brush_preview() brush_preview = MeshInstance3D.new() brush_preview.name = "_WorldBrushPreview" brush_preview.top_level = true brush_preview.visible = false brush_preview.cast_shadow = ( GeometryInstance3D .SHADOW_CASTING_SETTING_OFF ) brush_preview_mesh = ImmediateMesh.new() brush_preview.mesh = brush_preview_mesh brush_preview_material = StandardMaterial3D.new() brush_preview_material.shading_mode = ( BaseMaterial3D.SHADING_MODE_UNSHADED ) brush_preview_material.no_depth_test = true _apply_preview_color() scene_root.add_child( brush_preview, false, Node.INTERNAL_MODE_BACK ) _rebuild_brush_preview_mesh() func _rebuild_brush_preview_mesh() -> void: if not is_instance_valid( brush_preview_mesh ): return brush_preview_mesh.clear_surfaces() brush_preview_mesh.surface_begin( Mesh.PRIMITIVE_LINE_STRIP, brush_preview_material ) for index in range( BRUSH_SEGMENTS + 1 ): var percentage := ( float(index) / float(BRUSH_SEGMENTS) ) var angle := percentage * TAU var point := Vector3( cos(angle) * brush_radius, 0.0, sin(angle) * brush_radius ) brush_preview_mesh.surface_add_vertex( point ) brush_preview_mesh.surface_end() func _set_erase_preview_active( enabled: bool ) -> void: erase_preview_active = enabled _apply_preview_color() func _apply_preview_color() -> void: if not is_instance_valid( brush_preview_material ): return if erase_preview_active: brush_preview_material.albedo_color = ( ERASE_PREVIEW_COLOR ) else: brush_preview_material.albedo_color = ( PAINT_PREVIEW_COLOR ) func _hide_brush_preview() -> void: if is_instance_valid( brush_preview ): brush_preview.visible = false func _remove_brush_preview() -> void: if is_instance_valid( brush_preview ): brush_preview.queue_free() brush_preview = null brush_preview_mesh = null brush_preview_material = null erase_preview_active = false # ========================================================= # التحقق والحالة # ========================================================= func _is_valid_terrain_point( point: Vector3 ) -> bool: if not point.is_finite(): return false if absf(point.x) >= INVALID_TERRAIN_LIMIT: return false if absf(point.y) >= INVALID_TERRAIN_LIMIT: return false if absf(point.z) >= INVALID_TERRAIN_LIMIT: return false return true func _is_valid_normal( normal: Vector3 ) -> bool: if not normal.is_finite(): return false return ( normal.length_squared() >= 0.000001 ) func _clear_surface_hit( message: String ) -> void: has_surface_hit = false surface_position = Vector3.ZERO surface_normal = Vector3.UP surface_type = "" _hide_brush_preview() _set_dock_status( message ) func _set_dock_status( message: String ) -> void: if not is_instance_valid( dock_content ): return var status_label := ( dock_content.get_node_or_null( "StatusLabel" ) as Label ) if status_label != null: status_label.text = message # ========================================================= # إشارات الواجهة # ========================================================= func _on_selected_scene_changed( scene: PackedScene ) -> void: _end_active_stroke() selected_scene = scene if selected_scene == null: has_surface_hit = false surface_type = "" _hide_brush_preview() print( "World Brush: Scene selection cleared." ) return print( "World Brush: Selected scene -> ", selected_scene.resource_path ) func _on_brush_enabled_changed( enabled: bool ) -> void: if not enabled: _end_active_stroke() brush_enabled = enabled if not brush_enabled: has_surface_hit = false surface_type = "" _hide_brush_preview() print( "World Brush: Brush enabled -> ", brush_enabled ) func _on_brush_radius_changed( radius: float ) -> void: brush_radius = maxf( radius, 0.5 ) _rebuild_brush_preview_mesh() func _on_instances_per_click_changed( count: int ) -> void: instances_per_click = maxi( count, 1 ) func _on_minimum_spacing_changed( spacing: float ) -> void: minimum_spacing = maxf( spacing, 0.0 ) func _on_random_y_rotation_changed( enabled: bool ) -> void: random_y_rotation = enabled func _on_align_to_surface_changed( enabled: bool ) -> void: align_to_surface = enabled func _on_maximum_slope_changed( value: float ) -> void: maximum_slope_degrees = clampf( value, 0.0, 90.0 ) print( "World Brush: Maximum slope -> ", maximum_slope_degrees ) func _on_height_offset_changed( value: float ) -> void: height_offset = value print( "World Brush: Height offset -> ", height_offset ) func _on_height_offset_mode_changed( mode: int ) -> void: height_offset_mode = clampi( mode, HeightOffsetMode.WORLD_Y, HeightOffsetMode.SURFACE_NORMAL ) print( "World Brush: Height offset mode -> ", height_offset_mode ) func _on_random_scale_enabled_changed( enabled: bool ) -> void: random_scale_enabled = enabled print( "World Brush: Random scale -> ", random_scale_enabled ) func _on_minimum_scale_changed( value: float ) -> void: minimum_scale = maxf( value, 0.01 ) if minimum_scale > maximum_scale: maximum_scale = minimum_scale print( "World Brush: Minimum scale -> ", minimum_scale ) func _on_maximum_scale_changed( value: float ) -> void: maximum_scale = maxf( value, 0.01 ) if maximum_scale < minimum_scale: minimum_scale = maximum_scale print( "World Brush: Maximum scale -> ", maximum_scale ) func _on_auto_lod_settings_changed( enabled: bool, detail_bias: float, cull_distance: float ) -> void: auto_lod_enabled = enabled auto_lod_bias = clampf(detail_bias, 0.0, 4.0) auto_lod_cull_distance = maxf(cull_distance, 0.0) _apply_auto_lod_settings_to_existing_instances() print( "World Brush: Auto LOD -> ", auto_lod_enabled, " | bias ", auto_lod_bias, " | cull ", auto_lod_cull_distance ) func _on_auto_lod_import_requested( scene_path: String, enabled: bool ) -> void: call_deferred( "_apply_auto_lod_import_setting", scene_path, enabled ) func _apply_auto_lod_to_instance( instance: Node3D, scene_owner: Node = null ) -> bool: var controller := instance.get_node_or_null( NodePath(AUTO_LOD_CONTROLLER_NAME) ) var effective_cull := auto_lod_cull_distance if bool(instance.get_meta(LOD_GROUP_META, false)): effective_cull = 0.0 if auto_lod_enabled: var created := false if controller == null: controller = Node.new() controller.name = AUTO_LOD_CONTROLLER_NAME controller.set_script(AUTO_LOD_CONTROLLER_SCRIPT) instance.add_child(controller, true) created = true if scene_owner != null: controller.owner = scene_owner controller.call( "configure", true, auto_lod_bias, effective_cull ) return created or true if controller == null: return false if controller.has_method("restore_original_settings"): controller.call("restore_original_settings") instance.remove_child(controller) controller.free() return true func _apply_auto_lod_settings_to_existing_instances() -> void: if selected_scene == null or selected_scene.resource_path.is_empty(): return var scene_root := get_editor_interface().get_edited_scene_root() if scene_root == null: return var roots: Array[Node3D] = [] _collect_painted_instance_roots(scene_root, roots) var updated := 0 for instance in roots: if _get_painted_source_path(instance) != selected_scene.resource_path: continue if _apply_auto_lod_to_instance(instance, scene_root): updated += 1 if updated <= 0: return var editor_interface := get_editor_interface() if editor_interface.has_method(&"mark_scene_as_unsaved"): editor_interface.call(&"mark_scene_as_unsaved") print("World Brush: Updated Auto LOD instances -> ", updated) func _collect_painted_instance_roots( node: Node, result: Array[Node3D] ) -> void: if node is Node3D and ( bool(node.get_meta(INSTANCE_META, false)) or bool(node.get_meta(LOD_GROUP_META, false)) ): result.append(node as Node3D) return for child in node.get_children(): _collect_painted_instance_roots(child, result) func _get_painted_source_path(instance: Node3D) -> String: var source_path := str(instance.get_meta(SOURCE_SCENE_META, "")) if source_path.is_empty(): source_path = str(instance.get_meta(&"lod_source_scene_path", "")) if source_path.is_empty(): source_path = instance.scene_file_path if source_path.is_empty() and bool(instance.get_meta(LOD_GROUP_META, false)): var near_level := instance.get_node_or_null(^"Near") as Node3D if near_level != null: source_path = near_level.scene_file_path return source_path func _apply_auto_lod_import_setting( scene_path: String, enabled: bool ) -> void: if scene_path.is_empty() or not ResourceLoader.exists(scene_path, "PackedScene"): _set_dock_status("Auto LOD: Active scene is missing or unsaved") return var import_paths := PackedStringArray() _collect_import_sources_from_path(scene_path, import_paths) if import_paths.is_empty(): _set_dock_status("Auto LOD: No supported imported model found in active scene") return var changed_paths := PackedStringArray() for source_path in import_paths: var sidecar_path := source_path + ".import" var config := ConfigFile.new() if config.load(sidecar_path) != OK: continue var current_value := bool(config.get_value("params", "meshes/generate_lods", true)) if current_value == enabled: changed_paths.append(source_path) continue config.set_value("params", "meshes/generate_lods", enabled) if config.save(sidecar_path) == OK: changed_paths.append(source_path) if changed_paths.is_empty(): _set_dock_status("Auto LOD: Import settings could not be updated") return _set_dock_status("Auto LOD: Reimporting active model only...") get_editor_interface().get_resource_filesystem().reimport_files(changed_paths) _set_dock_status( "Auto LOD enabled for active model" if enabled else "Auto LOD disabled for active model" ) print("World Brush: Reimported Auto LOD sources -> ", changed_paths) func _collect_import_sources_from_path( scene_path: String, result: PackedStringArray ) -> void: _add_import_source_if_supported(scene_path, result) var packed_scene := load(scene_path) as PackedScene if packed_scene == null or not packed_scene.can_instantiate(): return var instance := packed_scene.instantiate() _collect_import_sources_from_node(instance, result) instance.free() func _collect_import_sources_from_node( node: Node, result: PackedStringArray ) -> void: if node is Node3D: _add_import_source_if_supported((node as Node3D).scene_file_path, result) if node is MeshInstance3D: var mesh := (node as MeshInstance3D).mesh if mesh != null: _add_import_source_if_supported(mesh.resource_path, result) for child in node.get_children(): _collect_import_sources_from_node(child, result) func _add_import_source_if_supported( resource_path: String, result: PackedStringArray ) -> void: if resource_path.is_empty(): return var source_path := resource_path.get_slice("::", 0) if not AUTO_LOD_IMPORT_EXTENSIONS.has(source_path.get_extension().to_lower()): return if not FileAccess.file_exists(source_path + ".import"): return if not result.has(source_path): result.append(source_path) func _on_lod_settings_changed( enabled: bool, medium_scene: PackedScene, distant_scene: PackedScene, medium_distance: float, distant_distance: float, cull_distance: float ) -> void: lod_enabled = enabled lod1_scene = medium_scene lod2_scene = distant_scene lod1_distance = maxf(medium_distance, 1.0) lod2_distance = maxf( distant_distance, lod1_distance + 1.0 ) lod_cull_distance = maxf(cull_distance, 0.0) _apply_lod_settings_to_existing_groups() _apply_auto_lod_settings_to_existing_instances() print( "World Brush: Distance LOD -> ", lod_enabled, " | ", lod1_distance, " | ", lod2_distance, " | ", lod_cull_distance ) func _apply_lod_settings_to_existing_groups() -> void: if selected_scene == null: return var source_path := selected_scene.resource_path if source_path.is_empty(): return var scene_root := ( get_editor_interface() .get_edited_scene_root() ) if scene_root == null: return var lod1_path := ( lod1_scene.resource_path if lod1_scene != null else "" ) var lod2_path := ( lod2_scene.resource_path if lod2_scene != null else "" ) var updated := 0 for candidate in get_tree().get_nodes_in_group( LOD_NODE_GROUP ): if not candidate is Node3D: continue var lod_group := candidate as Node3D var group_source_path := str( lod_group.get_meta( &"lod_source_scene_path", "" ) ) if group_source_path.is_empty(): var near_level := lod_group.get_node_or_null( ^"Near" ) as Node3D if near_level != null: group_source_path = near_level.scene_file_path if group_source_path != source_path: continue var settings_changed := ( bool(lod_group.get_meta(&"lod_enabled", true)) != lod_enabled or str(lod_group.get_meta(&"lod1_scene_path", "")) != lod1_path or str(lod_group.get_meta(&"lod2_scene_path", "")) != lod2_path or not is_equal_approx( float(lod_group.get_meta(&"lod1_distance", -1.0)), lod1_distance ) or not is_equal_approx( float(lod_group.get_meta(&"lod2_distance", -1.0)), lod2_distance ) or not is_equal_approx( float(lod_group.get_meta(&"lod_cull_distance", -1.0)), lod_cull_distance ) ) if not settings_changed: continue lod_group.set_meta(&"lod_source_scene_path", source_path) lod_group.set_meta(&"lod_enabled", lod_enabled) lod_group.set_meta(&"lod1_scene_path", lod1_path) lod_group.set_meta(&"lod2_scene_path", lod2_path) lod_group.set_meta(&"lod1_distance", lod1_distance) lod_group.set_meta(&"lod2_distance", lod2_distance) lod_group.set_meta(&"lod_cull_distance", lod_cull_distance) _sync_existing_lod_level( lod_group, &"Medium", lod1_scene if lod_enabled else null, scene_root ) _sync_existing_lod_level( lod_group, &"Distant", lod2_scene if lod_enabled else null, scene_root ) if lod_group.has_method(&"configure"): lod_group.call( &"configure", lod1_distance, lod2_distance, lod_cull_distance ) updated += 1 if updated <= 0: return var editor_interface := get_editor_interface() if editor_interface.has_method(&"mark_scene_as_unsaved"): editor_interface.call(&"mark_scene_as_unsaved") print( "World Brush: Updated painted LOD distances -> ", updated ) func _sync_existing_lod_level( lod_group: Node3D, level_name: StringName, desired_scene: PackedScene, scene_root: Node3D ) -> void: var existing := lod_group.get_node_or_null( NodePath(level_name) ) as Node3D if desired_scene == null: if existing != null: lod_group.remove_child(existing) existing.free() return if ( existing != null and existing.scene_file_path == desired_scene.resource_path ): return var replacement := _instantiate_scene_root(desired_scene) if replacement == null: return if existing != null: lod_group.remove_child(existing) existing.free() replacement.name = str(level_name) replacement.transform = Transform3D.IDENTITY replacement.visible = false lod_group.add_child(replacement, true) replacement.owner = scene_root _disable_native_lod_ranges(replacement)