diff --git a/blender_manifest.toml b/blender_manifest.toml index dafa574a..b8b91a4e 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,14 +1,16 @@ schema_version = "1.0.0" id = "CAD_Sketcher" -version = "0.27.5" +version = "0.28.0" name = "CAD Sketcher" tagline = "Parametric, constraint-based geometry sketcher" maintainer = "hlorus " type = "add-on" website = "https://www.cadsketcher.com/" tags = ["3D View", "Modeling", "Mesh", "Object"] -blender_version_min = "3.3.0" + +blender_version_min = "4.3.0" + license = ["SPDX:GPL-3.0-or-later"] wheels = [ diff --git a/converters.py b/converters.py index 64c92c82..3885aa73 100644 --- a/converters.py +++ b/converters.py @@ -1,80 +1,155 @@ import logging import math -from typing import Union +from typing import Union, Any, List import bpy import bmesh -from bpy.types import Mesh, Scene, Object +from bpy.types import Mesh, Scene, Object, Operator -from .utilities.bezier import set_handles -from .utilities.walker import EntityWalker +from .assets_manager import load_asset +from . import global_data logger = logging.getLogger(__name__) -class BezierConverter(EntityWalker): +class BezierHandleType: + FREE = 0 + AUTO = 1 + VECTOR = 2 + ALIGN = 3 + +def _ensure_attrribute(attributes, name, type, domain): + """Ensure an attribute exists or create it if missing""" + attr = attributes.get(name) + if not attr: + attributes.new(name, type, domain) + attr = attributes.get(name) + return attr + +def set_attribute(attributes, name: str, value: Any, index:int=None): + """Set an attribute value either for given index or for all""" + + attribute = attributes.get(name) + + if index is None: + attribute.data.foreach_set("value", (value,) * len(attribute.data)) + else: + attribute.data[index].value = value + + +class DirectConverter: + """Converts entities directly to splines without entity walking""" + def __init__(self, scene, sketch): - super().__init__(scene, sketch) - - def to_bezier(self, curve_data): - curve_data.fill_mode = "FRONT" if self.sketch.fill_shape else "NONE" - - for spline_path in self.paths: - path_segments = spline_path[0] - s = curve_data.splines.new("BEZIER") - - is_cyclic = self.is_cyclic_path(path_segments) - if is_cyclic: - s.use_cyclic_u = True - - segment_count = [ - seg.bezier_segment_count() - if hasattr(seg, "bezier_segment_count") - else 1 - for seg in path_segments - ] - amount = sum(segment_count) - - if not is_cyclic: - amount += 1 - # NOTE: There's already one point in a new spline - s.bezier_points.add(amount - 1) - - startpoint = s.bezier_points[0] - set_handles(startpoint) - previous_point = startpoint - - last_index = len(path_segments) - 1 - index = 0 - for i, segment in enumerate(path_segments): - invert_direction = spline_path[1][i] - - # TODO: rename to seg_count and segment_counts - sub_segment_count = segment_count[i] - - if i == last_index and is_cyclic: - end = s.bezier_points[0] - else: - end = s.bezier_points[index + sub_segment_count] - - midpoints = ( - [ - s.bezier_points[index + i + 1] - for i in range(sub_segment_count - 1) - ] - if sub_segment_count - else [] - ) - kwargs = {} - if i == 0: - kwargs["set_startpoint"] = True - if sub_segment_count > 1: - kwargs["midpoints"] = midpoints - - previous_point = segment.to_bezier( - s, previous_point, end, invert_direction, **kwargs - ) - index += sub_segment_count + self.scene = scene + self.sketch = sketch + self.entities = self._get_entities() + + def _get_entities(self) -> List: + """Get all drawable entities from the sketch""" + sketch_index = self.sketch.slvs_index + entities = [] + + for entity in self.scene.sketcher.entities.all: + if not hasattr(entity, "sketch") or entity.sketch_i != sketch_index: + continue + if not entity.is_path(): + continue + entities.append(entity) + + return entities + + def to_bezier(self, curve_data: bpy.types.Curve): + """Convert entities to bezier curves, with one spline per entity""" + + # Calculate point counts for each entity + point_counts = [] + for entity in self.entities: + if hasattr(entity, "bezier_point_count"): + point_counts.append(entity.bezier_point_count()) + elif hasattr(entity, "bezier_segment_count"): + # For entities that only define segment count, add 1 for non-cyclic + is_cyclic = entity.is_closed() if hasattr(entity, "is_closed") else False + point_counts.append(entity.bezier_segment_count() + (0 if is_cyclic else 1)) + else: + # Default to 2 points for simple entities like lines + point_counts.append(2) + + # Add all curve slices + curve_data.add_curves(point_counts) + curve_data.set_types(type="BEZIER") + self._ensure_attributes(curve_data) + + # Set default handle types (individual entities will override these as needed) + set_attribute(curve_data.attributes, "handle_type_right", BezierHandleType.FREE) + set_attribute(curve_data.attributes, "handle_type_left", BezierHandleType.FREE) + + # Process each entity + for entity_index, entity in enumerate(self.entities): + curve_slice = curve_data.curves[entity_index] + is_cyclic = entity.is_closed() if hasattr(entity, "is_closed") else False + + # Set curve attributes + set_attribute(curve_data.attributes, "resolution", self.sketch.curve_resolution, entity_index) + set_attribute(curve_data.attributes, "cyclic", is_cyclic, entity_index) + set_attribute(curve_data.attributes, "construction", entity.construction, entity_index) + + # Setup points for the to_bezier call + start_point = curve_slice.points[0] + end_point = curve_slice.points[-1] if not is_cyclic else curve_slice.points[0] + + # For entities with multiple segments + midpoints = [] + if len(curve_slice.points) > 2: + midpoints = [curve_slice.points[i] for i in range(1, len(curve_slice.points))] + + # Setup kwargs for to_bezier call + kwargs = { + "set_startpoint": True, # Always set startpoint for direct conversion + } + if midpoints: + kwargs["midpoints"] = midpoints + + # Store entity slvs_index as attribute on points + entity_index_attr = curve_data.attributes.get("entity_index") + if entity_index_attr: + for point_idx in range(len(curve_slice.points)): + if point_idx < len(entity_index_attr.data): + entity_index_attr.data[point_idx].value = entity.slvs_index + + # Store entity slvs_index as attribute on segments/edges + segment_entity_index_attr = curve_data.attributes.get("segment_entity_index") + if segment_entity_index_attr: + edge_count = len(curve_slice.points) - (0 if is_cyclic else 1) + for edge_idx in range(edge_count): + if edge_idx < len(segment_entity_index_attr.data): + segment_entity_index_attr.data[edge_idx].value = entity.slvs_index + + # Call the entity's to_bezier method + entity.to_bezier( + curve_slice, + start_point, + end_point, + False, # No invert_direction needed with direct conversion + **kwargs + ) + + @classmethod + def _ensure_attributes(cls, curve_data): + """Ensure all required attributes are present""" + # Note: Each entity type can override the handle types as needed + + attributes = curve_data.attributes + _ensure_attrribute(attributes, "cyclic", "BOOLEAN", "CURVE") + _ensure_attrribute(attributes, "curve_type", "INT8", "CURVE") + _ensure_attrribute(attributes, "handle_type_left", "INT8", "POINT") + _ensure_attrribute(attributes, "handle_type_right", "INT8", "POINT") + _ensure_attrribute(attributes, "handle_left", "FLOAT_VECTOR", "POINT") + _ensure_attrribute(attributes, "handle_right", "FLOAT_VECTOR", "POINT") + _ensure_attrribute(attributes, "resolution", "INT", "CURVE") + _ensure_attrribute(attributes, "entity_index", "INT", "POINT") + _ensure_attrribute(attributes, "segment_entity_index", "INT", "CURVE") + _ensure_attrribute(attributes, "construction", "BOOLEAN", "CURVE") def mesh_from_temporary(mesh: Mesh, name: str, existing_mesh: Union[bool, None] = None): @@ -95,17 +170,6 @@ def mesh_from_temporary(mesh: Mesh, name: str, existing_mesh: Union[bool, None] return new_mesh -def _cleanup_data(sketch, mode: str): - if sketch.target_object and mode != "MESH": - sketch.target_object.sketch_index = -1 - bpy.data.objects.remove(sketch.target_object, do_unlink=True) - sketch.target_object = None - if sketch.target_curve_object and mode != "BEZIER": - sketch.target_curve_object.sketch_index = -1 - bpy.data.objects.remove(sketch.target_curve_object, do_unlink=True) - sketch.target_curve_object = None - - def _link_unlink_object(scene: Scene, ob: Object, keep: bool): objects = scene.collection.objects exists = ob.name in objects @@ -117,70 +181,50 @@ def _link_unlink_object(scene: Scene, ob: Object, keep: bool): objects.link(ob) -def update_convertor_geometry(scene: Scene, sketch=None): +CONVERT_MODIFIER_NAME = "CAD Sketcher Convert" + +def _ensure_convert_modifier(ob): + """Get or create the convert modifier""" + modifier = ob.modifiers.get(CONVERT_MODIFIER_NAME) + if not modifier: + modifier = ob.modifiers.new(CONVERT_MODIFIER_NAME, "NODES") + return modifier + +def update_geometry(scene: Scene, operator: Operator, sketch=None): coll = (sketch,) if sketch else scene.sketcher.entities.sketches for sketch in coll: - mode = sketch.convert_type - if sketch.convert_type == "NONE": - _cleanup_data(sketch, mode) - continue - data = bpy.data name = sketch.name - # Create curve object - if not sketch.target_curve_object: - curve = bpy.data.objects.data.curves.new(name, "CURVE") - object = bpy.data.objects.new(name, curve) - sketch.target_curve_object = object + # Create object + if not sketch.target_object: + curve = data.hair_curves.new(name) + sketch.target_object = data.objects.new(name, curve) else: - # Clear curve data - sketch.target_curve_object.data.splines.clear() - - # Convert geometry to curve data - conv = BezierConverter(scene, sketch) - - # TODO: Avoid re-converting sketches where nothing has changed! - logger.info("Convert sketch {} to {}: ".format(sketch, mode.lower())) - curve_data = sketch.target_curve_object.data - conv.to_bezier(curve_data) - data = curve_data - - # Link / unlink curve object - _link_unlink_object(scene, sketch.target_curve_object, mode == "BEZIER") - - if mode == "MESH": - # Set curve resolution - for spline in sketch.target_curve_object.data.splines: - spline.resolution_u = sketch.curve_resolution - - # Create mesh data - temp_mesh = sketch.target_curve_object.to_mesh() - mesh = mesh_from_temporary( - temp_mesh, - name, - existing_mesh=( - sketch.target_object.data if sketch.target_object else None - ), - ) - sketch.target_curve_object.to_mesh_clear() + sketch.target_object.data.remove_curves() - # Create mesh object - if not sketch.target_object: - mesh_object = bpy.data.objects.new(name, mesh) - scene.collection.objects.link(mesh_object) - sketch.target_object = mesh_object - else: - sketch.target_object.data = mesh + # Update object properties + sketch.target_object.matrix_world = sketch.wp.matrix_basis + sketch.target_object.sketch_index = sketch.slvs_index + sketch.target_object.name = sketch.name + + # Link object + _link_unlink_object(scene, sketch.target_object, True) - _cleanup_data(sketch, mode) + # Add GN modifier + modifier = _ensure_convert_modifier(sketch.target_object) + if not modifier: + operator.report({"ERROR"}, "Cannot add modifier to object") + return {"CANCELLED"} - target_ob = ( - sketch.target_object if mode == "MESH" else sketch.target_curve_object - ) - target_ob.matrix_world = sketch.wp.matrix_basis + # Ensure the convertor nodegroup is loaded + if not load_asset(global_data.LIB_NAME, "node_groups", "CAD Sketcher Convert"): + operator.report({"ERROR"}, "Cannot load asset 'CAD Sketcher Convert' from library") + return {"CANCELLED"} - target_ob.sketch_index = sketch.slvs_index + # Set the nodegroup + modifier.node_group = data.node_groups["CAD Sketcher Convert"] - # Update object name - target_ob.name = sketch.name + # Convert geometry to curve data using direct conversion + conv = DirectConverter(scene, sketch) + conv.to_bezier(sketch.target_object.data) diff --git a/interfaces.py b/interfaces.py new file mode 100644 index 00000000..26a0f946 --- /dev/null +++ b/interfaces.py @@ -0,0 +1,10 @@ +from abc import ABC, abstractmethod + +import bpy + + +class BezierConversionInterface(ABC): + @abstractmethod + def to_bezier(self, curve_data: bpy.types.Curve, startpoint: bpy.types.CurvePoint, endpoint: bpy.types.CurvePoint, invert_direction: bool, **kwargs): + pass + diff --git a/model/arc.py b/model/arc.py index 19be3060..52987c13 100644 --- a/model/arc.py +++ b/model/arc.py @@ -247,13 +247,16 @@ def to_bezier( locations.reverse() if set_startpoint: - startpoint.co = locations[0].to_3d() + startpoint.position = locations[0].to_3d() + # Calculate handle size for smooth arc approximation n = FULL_TURN / angle if angle != 0.0 else 0 q = (4 / 3) * math.tan(HALF_TURN / (2 * n)) base_offset = Vector((radius, q * radius)) + # Create curve with proper bezier handles create_bezier_curve( + spline, segment_count, bezier_points, locations, diff --git a/model/circle.py b/model/circle.py index 21e268f4..6ee0f9aa 100644 --- a/model/circle.py +++ b/model/circle.py @@ -144,11 +144,14 @@ def to_bezier( ) angle = FULL_TURN / segment_count + # Calculate handle size for smooth circle approximation n = FULL_TURN / angle q = (4 / 3) * math.tan(HALF_TURN / (2 * n)) base_offset = Vector((radius, q * radius)) + # Create curve with proper bezier handles create_bezier_curve( + spline, segment_count, bezier_points, locations, diff --git a/model/line_2d.py b/model/line_2d.py index 8e02370a..9f35e74a 100644 --- a/model/line_2d.py +++ b/model/line_2d.py @@ -133,11 +133,17 @@ def to_bezier( locations.reverse() if set_startpoint: - startpoint.co = locations[0] - endpoint.co = locations[1] + startpoint.position = locations[0] + endpoint.position = locations[1] - startpoint.handle_right = locations[0] - endpoint.handle_left = locations[1] + # For lines, set handles to be exactly at the points + attributes = spline.id_data.attributes + + attributes["handle_right"].data[startpoint.index].vector = locations[0] + attributes["handle_left"].data[startpoint.index].vector = locations[0] + + attributes["handle_right"].data[endpoint.index].vector = locations[1] + attributes["handle_left"].data[endpoint.index].vector = locations[1] return endpoint diff --git a/model/sketch.py b/model/sketch.py index f40fef77..ae705ad1 100644 --- a/model/sketch.py +++ b/model/sketch.py @@ -16,8 +16,7 @@ convert_items = [ ("NONE", "None", "", 1), - ("BEZIER", "Bezier", "", 2), - ("MESH", "Mesh", "", 3), + ("CURVE", "Curve", "Converts the sketch to the native curve type", 2), ] diff --git a/model/utilities.py b/model/utilities.py index 99234dc4..4a0244f3 100644 --- a/model/utilities.py +++ b/model/utilities.py @@ -69,6 +69,7 @@ def get_bezier_curve_midpoint_positions( def create_bezier_curve( + spline: bpy.types.CurveSlice, segment_count, bezier_points, locations, @@ -81,6 +82,8 @@ def create_bezier_curve( bezier_points.append(bezier_points[0]) locations.append(locations[0]) + attributes = spline.id_data.attributes + for index in range(segment_count): loc1, loc2 = locations[index], locations[index + 1] b1, b2 = bezier_points[index], bezier_points[index + 1] @@ -97,9 +100,44 @@ def create_bezier_curve( offset.rotate(Matrix.Rotation(angle, 2)) coords.append((center + offset).to_3d()) - b1.handle_right = coords[0] - b2.handle_left = coords[1] - b2.co = loc2.to_3d() + attributes["handle_right"].data[b1.index].vector = coords[0] + attributes["handle_left"].data[b2.index].vector = coords[1] + b2.position = loc2.to_3d() + + # For non-cyclic curves, set both handles for endpoints + if not cyclic: + # Set handle_left for the first point (only for the first segment) + if index == 0: + pos = loc1 - center + angle = math.atan2(pos[1], pos[0]) + offset = base_offset.copy() + + # Use opposite direction compared to handle_right + if not invert: + offset[1] *= -1 + + offset.rotate(Matrix.Rotation(angle, 2)) + attributes["handle_left"].data[b1.index].vector = (center + offset).to_3d() + + # Set handle_right for the last point (only for the last segment) + if index == segment_count - 1: + pos = loc2 - center + angle = math.atan2(pos[1], pos[0]) + offset = base_offset.copy() + + # Use opposite direction compared to handle_left + if invert: + offset[1] *= -1 + + offset.rotate(Matrix.Rotation(angle, 2)) + attributes["handle_right"].data[b2.index].vector = (center + offset).to_3d() + + +def create_bezier_curve_attributes( + spline, + segment_count, + point_indices): + pass # NOTE: When tweaking, it's necessary to constrain a point that is only temporary available @@ -134,4 +172,4 @@ def update_pointers(scene, index_old, index_new): continue o.update_pointers(index_old, index_new) - scene.sketcher.purge_stale_data() + scene.sketcher.purge_stale_data() \ No newline at end of file diff --git a/operators/update.py b/operators/update.py index 9fc8be67..e76c7bbd 100644 --- a/operators/update.py +++ b/operators/update.py @@ -1,19 +1,27 @@ -import bpy -from bpy.types import Operator +from bpy.types import Operator, Context +from bpy.props import BoolProperty from bpy.utils import register_classes_factory +from ..declarations import Operators from ..solver import Solver -from ..converters import update_convertor_geometry +from ..converters import update_geometry class VIEW3D_OT_update(Operator): bl_idname = "view3d.slvs_update" bl_label = "Update" - def execute(self, context): - solver = Solver(context, None, all=True) - solver.solve() - update_convertor_geometry(context.scene) + bl_idname = Operators.Update + bl_label = "Force Update" + + solve: BoolProperty(name="Solve", default=True, description="Solve the sketches before converting the geometry") + + def execute(self, context: Context): + if self.solve: + solver = Solver(context, None, all=True) + solver.solve() + + update_geometry(context.scene, self) return {"FINISHED"} diff --git a/operators/utilities.py b/operators/utilities.py index af76f861..263aa521 100644 --- a/operators/utilities.py +++ b/operators/utilities.py @@ -5,7 +5,7 @@ from .. import global_data from ..declarations import GizmoGroups, WorkSpaceTools -from ..converters import update_convertor_geometry +from ..converters import update_geometry from ..utilities.preferences import get_prefs from ..utilities.data_handling import entities_3d @@ -145,7 +145,7 @@ def activate_sketch(context: Context, index: int, operator: Operator): if context.mode != "OBJECT": return {"FINISHED"} - update_convertor_geometry(context.scene, sketch=last_sketch) + update_geometry(context.scene, operator, sketch=last_sketch) select_target_ob(context, last_sketch) @@ -153,8 +153,7 @@ def activate_sketch(context: Context, index: int, operator: Operator): def select_target_ob(context, sketch): - mode = sketch.convert_type - target_ob = sketch.target_object if mode == "MESH" else sketch.target_curve_object + target_ob = sketch.target_object bpy.ops.object.select_all(action="DESELECT") if not target_ob: @@ -163,3 +162,5 @@ def select_target_ob(context, sketch): if target_ob.name in context.view_layer.objects: target_ob.select_set(True) context.view_layer.objects.active = target_ob + + diff --git a/resources/assets.blend b/resources/assets.blend index 6a2c1f5b..9f39183b 100644 Binary files a/resources/assets.blend and b/resources/assets.blend differ diff --git a/resources/assets.blend1 b/resources/assets.blend1 index 6abbbc8e..62f848aa 100644 Binary files a/resources/assets.blend1 and b/resources/assets.blend1 differ diff --git a/ui/panels/sketch_select.py b/ui/panels/sketch_select.py index 267fce1b..9b6fe4cd 100644 --- a/ui/panels/sketch_select.py +++ b/ui/panels/sketch_select.py @@ -75,11 +75,6 @@ def draw(self, context: Context): row.prop(sketch, "name") layout.prop(sketch, "convert_type") - if sketch.convert_type == "MESH": - layout.prop(sketch, "curve_resolution") - if sketch.convert_type != "NONE": - layout.prop(sketch, "fill_shape") - layout.operator( declarations.Operators.DeleteEntity, text="Delete Sketch", diff --git a/utilities/bezier.py b/utilities/bezier.py deleted file mode 100644 index dc974682..00000000 --- a/utilities/bezier.py +++ /dev/null @@ -1,3 +0,0 @@ -def set_handles(point): - point.handle_left_type = "FREE" - point.handle_right_type = "FREE" diff --git a/versioning.py b/versioning.py index 9bceec01..fd3567c5 100644 --- a/versioning.py +++ b/versioning.py @@ -37,6 +37,30 @@ def recalc_pointers(scene): logger.debug("Update entity indices:" + msg) +def copy_modifiers(source_obj, target_obj): + """Copy modifiers from source object to target object""" + if not source_obj or not target_obj: + return + + # Clear existing modifiers on target + while target_obj.modifiers: + target_obj.modifiers.remove(target_obj.modifiers[0]) + + # Copy modifiers from source to target + for mod in source_obj.modifiers: + new_mod = target_obj.modifiers.new(name=mod.name, type=mod.type) + # Copy attributes that are common to all modifier types + for attr in dir(mod): + if attr.startswith('__') or attr in {'rna_type', 'type', 'name', 'bl_rna'}: + continue + try: + if hasattr(new_mod, attr): + setattr(new_mod, attr, getattr(mod, attr)) + except (AttributeError, TypeError): + # Skip attributes that can't be copied + pass + + def do_versioning(self): logger.debug("Check versioning") @@ -125,4 +149,77 @@ def do_versioning(self): setattr(c, "entity1", line_dependencies[0]) setattr(c, "entity2", line_dependencies[1]) - logger.debug(msg) + if version < (0, 28, 0): + # Handle old 'MESH' and 'BEZIER' convertion types + msg += "\n Update sketch conversion type to 'CURVE' for sketches:" + + # Dictionary to temporarily store objects and their modifiers + old_objects = {} + + # First pass: store old objects and change conversion type + for sketch in context.scene.sketcher.entities.sketches: + if sketch.convert_type == 'NONE': + continue + + # Store references to the old objects + sketch_id = str(sketch.slvs_index) + old_objects[sketch_id] = { + 'mesh_obj': sketch.target_object, + 'curve_obj': sketch.target_curve_object, + 'sketch': sketch + } + + # Clear links to objects but don't delete them yet + if sketch.target_object: + sketch.target_object.sketch_index = -1 + sketch.target_object = None + + if sketch.target_curve_object: + sketch.target_curve_object.sketch_index = -1 + sketch.target_curve_object = None + + # Change the conversion type + sketch.convert_type = 'CURVE' + + msg += " {}".format(str(sketch)) + + # Second pass: process each sketch individually + for sketch_id, objects in old_objects.items(): + sketch = objects['sketch'] + + # Force creation of converted object for this sketch + bpy.ops.view3d.slvs_update(solve=False) + + # Ensure the new object is created + if sketch.target_curve_object: + # Try to copy from mesh object first, then curve object + if objects['mesh_obj']: + copy_modifiers(objects['mesh_obj'], sketch.target_curve_object) + elif objects['curve_obj']: + copy_modifiers(objects['curve_obj'], sketch.target_curve_object) + + logger.info(f"Copied modifiers to new object for sketch {sketch_id}") + else: + logger.warning(f"Failed to create new object for sketch {sketch_id}") + + # Unlink and rename old objects instead of deleting them + for sketch_id, objects in old_objects.items(): + # Process mesh object + if objects['mesh_obj']: + old_obj = objects['mesh_obj'] + # Unlink from all collections + for collection in old_obj.users_collection: + collection.objects.unlink(old_obj) + # Rename to indicate it's an old version + old_obj.name = f"OLD_{old_obj.name}_{sketch_id}" + + # Process curve object + if objects['curve_obj']: + old_obj = objects['curve_obj'] + # Unlink from all collections + for collection in old_obj.users_collection: + collection.objects.unlink(old_obj) + # Rename to indicate it's an old version + old_obj.name = f"OLD_{old_obj.name}_{sketch_id}" + + logger.warning(msg)