bl_info = { "name": "Acceleration — Indent Stamp Cylinder (Stable Booleans + Constant Wall + Manifold Open Ends)", "author": "ChatGPT + Michael", "version": (2, 7, 0), "blender": (3, 0, 0), "location": "View3D > Sidebar > Fekla > Acceleration", "description": ( "Stage 1: Generate indented closed cylinder + spheres (booleans use non-triangulated source).\n" "Stage 2 debug (4 steps):\n" " 1) Prep copies\n" " 2) Outer UNION spheres (batched)\n" " 3) Constant wall thickness via inner normal-offset copy + DIFFERENCE\n" " 4) Remove BOTH caps + bridge rims (manifold open ends) + optional triangulate final + hide others\n" "No Solidify; no triangulated operands for booleans." ), "category": "Add Mesh", } import bpy import bmesh from bpy.props import ( FloatProperty, IntProperty, BoolProperty, EnumProperty, PointerProperty, StringProperty, ) from mathutils import Vector, Matrix from math import cos, sin, atan2, pi, exp, sqrt, log, ceil # ----------------------------- # Helpers # ----------------------------- def ensure_collection(name: str, parent: bpy.types.Collection | None = None) -> bpy.types.Collection: col = bpy.data.collections.get(name) if col is None: col = bpy.data.collections.new(name) if parent is None: bpy.context.scene.collection.children.link(col) else: parent.children.link(col) return col def clear_collection_objects(col: bpy.types.Collection): for obj in list(col.objects): bpy.data.objects.remove(obj, do_unlink=True) def ensure_object_mode(): if bpy.context.mode != "OBJECT": try: bpy.ops.object.mode_set(mode="OBJECT") except Exception: pass def deselect_all(): for o in bpy.context.selected_objects: o.select_set(False) def set_active(obj: bpy.types.Object): deselect_all() obj.select_set(True) bpy.context.view_layer.objects.active = obj def move_object_to_collection(obj: bpy.types.Object, target: bpy.types.Collection): for c in list(obj.users_collection): c.objects.unlink(obj) target.objects.link(obj) def wrap_angle_pi(a: float) -> float: while a > pi: a -= 2.0 * pi while a < -pi: a += 2.0 * pi return a def clamp(x: float, a: float, b: float) -> float: return a if x < a else b if x > b else x def smooth_max(a: float, b: float, k: float) -> float: k = max(1e-6, k) return (1.0 / k) * log(exp(k * a) + exp(k * b)) def duplicate_object(obj: bpy.types.Object, new_name: str, link_collection: bpy.types.Collection) -> bpy.types.Object: new_obj = obj.copy() new_obj.data = obj.data.copy() new_obj.name = new_name link_collection.objects.link(new_obj) return new_obj def apply_modifier(obj: bpy.types.Object, mod_name: str) -> bool: ensure_object_mode() set_active(obj) try: bpy.ops.object.modifier_apply(modifier=mod_name) return True except Exception: return False def triangulate_object(obj: bpy.types.Object, quad_method: str, ngon_method: str) -> bool: ensure_object_mode() set_active(obj) try: mod = obj.modifiers.new(name="ACCEL_Triangulate", type="TRIANGULATE") mod.quad_method = quad_method mod.ngon_method = ngon_method return apply_modifier(obj, mod.name) except Exception: return False def batch_list(items, batch_size: int): batch_size = max(1, int(batch_size)) for i in range(0, len(items), batch_size): yield items[i:i + batch_size] def boolean_collection_apply(target_obj: bpy.types.Object, collection: bpy.types.Collection, operation: str, solver: str) -> bool: ensure_object_mode() set_active(target_obj) mod = target_obj.modifiers.new(name=f"ACCEL_BOOL_{operation}", type="BOOLEAN") mod.operation = operation mod.operand_type = "COLLECTION" mod.collection = collection mod.solver = solver mod.use_self = True return apply_modifier(target_obj, mod.name) def boolean_object_apply(target_obj: bpy.types.Object, operand_obj: bpy.types.Object, operation: str, solver: str) -> bool: ensure_object_mode() set_active(target_obj) mod = target_obj.modifiers.new(name=f"ACCEL_BOOL_{operation}", type="BOOLEAN") mod.operation = operation mod.operand_type = "OBJECT" mod.object = operand_obj mod.solver = solver mod.use_self = True return apply_modifier(target_obj, mod.name) def hide_all_except(final_obj: bpy.types.Object): ensure_object_mode() for o in bpy.context.scene.objects: if o == final_obj: continue try: o.hide_set(True) o.hide_render = True except Exception: pass if final_obj is not None: final_obj.hide_set(False) final_obj.hide_render = False # ----------------------------- # Manifold open ends (delete BOTH caps + bridge rim) # ----------------------------- def _avg_radius(verts): if not verts: return 0.0 s = 0.0 for v in verts: s += sqrt(v.co.x * v.co.x + v.co.y * v.co.y) return s / max(1, len(verts)) def open_ends_make_manifold_rims(obj: bpy.types.Object, tol: float, normal_thresh: float) -> bool: """ Deletes cap faces at top and bottom (both inner and outer), then bridges the two boundary loops at each end to create a rim (annulus). This makes ends open BUT still manifold. """ if obj is None or obj.type != "MESH": return False ensure_object_mode() me = obj.data bm = bmesh.new() bm.from_mesh(me) bm.faces.ensure_lookup_table() bm.verts.ensure_lookup_table() bm.edges.ensure_lookup_table() if not bm.verts: bm.free() return False zmin = min(v.co.z for v in bm.verts) zmax = max(v.co.z for v in bm.verts) height = max(1e-12, zmax - zmin) # adaptive tolerance (booleans introduce drift) tol_eff = max(float(tol), height * 0.0005) nt = clamp(float(normal_thresh), 0.0, 1.0) # 1) delete cap-ish faces at top/bottom (both inner and outer) cap_faces = [] for f in bm.faces: n = f.normal if abs(n.z) < nt: continue cz = f.calc_center_median().z if abs(cz - zmin) <= tol_eff or abs(cz - zmax) <= tol_eff: cap_faces.append(f) if cap_faces: bmesh.ops.delete(bm, geom=cap_faces, context="FACES") bm.faces.ensure_lookup_table() bm.edges.ensure_lookup_table() bm.verts.ensure_lookup_table() # Helper: get boundary edges near a z plane def boundary_edges_near(z_target): out = [] for e in bm.edges: if not e.is_boundary: continue z1 = e.verts[0].co.z z2 = e.verts[1].co.z if abs(z1 - z_target) <= tol_eff and abs(z2 - z_target) <= tol_eff: out.append(e) return out # Helper: group boundary edges into loops (as sets of verts) def loops_from_edges(edges): # Build vert adjacency on boundary edges adj = {} for e in edges: a, b = e.verts adj.setdefault(a, set()).add(b) adj.setdefault(b, set()).add(a) loops = [] visited = set() for v_start in adj.keys(): if v_start in visited: continue # walk a loop loop = [] v = v_start prev = None while True: visited.add(v) loop.append(v) nbrs = list(adj.get(v, [])) if not nbrs: break # pick next not equal prev if possible if prev is None: v_next = nbrs[0] else: if len(nbrs) == 1: v_next = nbrs[0] else: v_next = nbrs[0] if nbrs[0] != prev else nbrs[1] prev, v = v, v_next if v == v_start: break if len(loop) >= 6: loops.append(loop) return loops # 2) For each end (top/bottom), find TWO loops (outer + inner) and bridge them def bridge_end(z_target): edges = boundary_edges_near(z_target) if not edges: return False loops = loops_from_edges(edges) if len(loops) < 2: return False # choose two biggest by avg radius loops_sorted = sorted(loops, key=_avg_radius, reverse=True) outer_loop = loops_sorted[0] inner_loop = loops_sorted[1] # Convert loop verts to an edge loop geometry for bridge_loops: # create edge lists for bmesh.ops.bridge_loops by selecting boundary edges whose verts are in the loop. outer_set = set(outer_loop) inner_set = set(inner_loop) outer_edges = [e for e in edges if (e.verts[0] in outer_set and e.verts[1] in outer_set)] inner_edges = [e for e in edges if (e.verts[0] in inner_set and e.verts[1] in inner_set)] if not outer_edges or not inner_edges: return False # bridge_loops expects a combined geom list geom = outer_edges + inner_edges try: bmesh.ops.bridge_loops(bm, edges=geom) return True except Exception: return False bridge_end(zmax) bridge_end(zmin) bm.normal_update() bm.to_mesh(me) me.update() bm.free() return True # ----------------------------- # Constant thickness: inner normal-offset copy # ----------------------------- def make_inner_offset_copy(obj: bpy.types.Object, offset: float) -> bool: """ In-place: offsets vertices inward along vertex normals by offset. Assumes normals are outward (closed volume). """ if obj is None or obj.type != "MESH": return False if offset <= 0.0: return True ensure_object_mode() me = obj.data bm = bmesh.new() bm.from_mesh(me) bm.verts.ensure_lookup_table() bm.normal_update() # vertex normals (bmesh) for v in bm.verts: n = v.normal if n.length > 1e-12: v.co = v.co - (n.normalized() * offset) bm.normal_update() bm.to_mesh(me) me.update() bm.free() return True # ----------------------------- # Names / Collections # ----------------------------- SOURCE_BOOL_CYL = "ACCEL_SourceCylinder_BOOL" SOURCE_PREVIEW_CYL = "ACCEL_SourceCylinder_PREVIEW" SPHERES_COL_SUFFIX = "__Spheres" WORK_COL_SUFFIX = "__Work" OUTER_BASE = "ACCEL_Outer_Base" OUTER_UNION = "ACCEL_Outer_Union" SHELL_OBJ = "ACCEL_Shell" # after step 3 INNER_OFFSET_OBJ = "ACCEL_InnerOffset" # debug copy used in step 3 FINAL_OPEN = "ACCEL_Final_Open" OUTER_SPHERES_COL = "ACCEL_OuterSpheres" BATCH_COL = "ACCEL_BatchOps" # ----------------------------- # Properties # ----------------------------- class FEKLA_IndentStampProps(bpy.types.PropertyGroup): collection_name: StringProperty(name="Collection", default="FEKLA_IndentStamp") clear_previous: BoolProperty(name="Clear Previous (Stage 1)", default=True) sphere_diameter: FloatProperty(name="Sphere Diameter", default=20.0, min=0.0001, subtype="DISTANCE") sphere_segments: IntProperty(name="Sphere Segments", default=128, min=3, max=256) sphere_rings: IntProperty(name="Sphere Rings", default=64, min=3, max=256) sphere_center_inset: FloatProperty(name="Sphere Center Inset", default=11.0, min=0.0, subtype="DISTANCE") show_spheres: BoolProperty(name="Show Spheres (Stage 1)", default=True) cyl_radius_mult: FloatProperty(name="Cylinder Radius × Diameter", default=2.7, min=0.01) cyl_height_mult: FloatProperty(name="Cylinder Height × Diameter", default=26.0, min=0.01) cyl_verts: IntProperty(name="Cylinder Circumference Segments", default=58, min=16, max=4096) cyl_z_segs: IntProperty(name="Cylinder Height Segments", default=90, min=1, max=20000) angle_step_deg: FloatProperty(name="Angle Step (deg)", default=60.0, min=0.1, max=180.0) pattern_mode: EnumProperty( name="Pattern", items=[ ("RING_GRID", "Ring Grid", ""), ("STAGGERED", "Staggered", ""), ("HELIX", "Helix", ""), ], default="STAGGERED", ) row_twist_deg: FloatProperty(name="Row Twist (deg)", default=10.0) z_spacing_mult: FloatProperty(name="Row Spacing × Diameter", default=2.2, min=0.05) z_offset: FloatProperty(name="Z Offset", default=0.0, subtype="DISTANCE") rows_override: IntProperty(name="Rows Override (0=auto)", default=0, min=0) wave_amp: FloatProperty(name="Indent Amplitude", default=14.0, min=0.0, subtype="DISTANCE") wave_sigma: FloatProperty(name="Indent Sigma", default=14.0, min=0.0001, subtype="DISTANCE") wave_front_bias: FloatProperty(name="Front Bias (0..1)", default=0.0, min=0.0, max=1.0) wave_cutoff_sigma: FloatProperty(name="Wave Cutoff (× sigma)", default=4.0, min=0.5, max=40.0) combine_mode: EnumProperty( name="Overlap Combine", items=[ ("SUM", "SUM", ""), ("MAX", "MAX", ""), ("SMOOTHMAX", "SMOOTHMAX", ""), ("CLAMPED_SUM", "CLAMPED_SUM", ""), ], default="SMOOTHMAX", ) smoothmax_k: FloatProperty(name="SmoothMAX k", default=10.0, min=0.1) max_indent: FloatProperty(name="Max Indent Clamp", default=0.0, min=0.0, subtype="DISTANCE") # Stage 2 wall_thickness: FloatProperty(name="Wall Thickness", default=0.8, min=0.0001, subtype="DISTANCE") boolean_solver: EnumProperty( name="Boolean Solver", items=[ ("EXACT", "Exact", ""), ("FAST", "Fast", ""), ], default="FAST", ) boolean_batch_size: IntProperty(name="Boolean Batch Size", default=3, min=1, max=500) # Open ends (manifold) cap_remove_tol: FloatProperty(name="Cap Remove Tolerance", default=0.2, min=0.0, subtype="DISTANCE") cap_normal_threshold: FloatProperty(name="Cap Normal Threshold", default=0.85, min=0.0, max=1.0) # Optional final triangulation (visual only) triangulate_final: BoolProperty(name="Triangulate Final", default=True) tri_quad_method: EnumProperty( name="Tri Quad Method", items=[ ("BEAUTY", "BEAUTY", ""), ("FIXED", "FIXED", ""), ("FIXED_ALTERNATE", "FIXED_ALTERNATE", ""), ("SHORTEST_DIAGONAL", "SHORTEST_DIAGONAL", ""), ], default="BEAUTY", ) tri_ngon_method: EnumProperty( name="Tri Ngon Method", items=[("BEAUTY", "BEAUTY", ""), ("CLIP", "CLIP", "")], default="BEAUTY", ) hide_everything_except_final: BoolProperty(name="Hide Everything Except Final", default=True) keep_work_objects: BoolProperty(name="Keep Work Objects", default=True) # ----------------------------- # Stage 1: Generate # ----------------------------- class FEKLA_OT_generate_stage1(bpy.types.Operator): bl_idname = "fekla.accel_stage1_generate" bl_label = "Stage 1: Generate" bl_options = {"REGISTER", "UNDO"} def execute(self, context): p = context.scene.fekla_indent_stamp_props root_col = ensure_collection(p.collection_name) spheres_col = ensure_collection(f"{p.collection_name}{SPHERES_COL_SUFFIX}", parent=root_col) if p.clear_previous: clear_collection_objects(root_col) spheres_col = ensure_collection(f"{p.collection_name}{SPHERES_COL_SUFFIX}", parent=root_col) d = float(p.sphere_diameter) r_sphere = d * 0.5 cyl_r = p.cyl_radius_mult * d cyl_h = p.cyl_height_mult * d inset = float(p.sphere_center_inset) amp = float(p.wave_amp) sigma = float(p.wave_sigma) cutoff = max(0.5, float(p.wave_cutoff_sigma)) max_dist = cutoff * sigma front_bias = clamp(float(p.wave_front_bias), 0.0, 1.0) z_offset = float(p.z_offset) # Cylinder (quads wall + tri caps) u_segs = max(3, int(p.cyl_verts)) v_segs = max(1, int(p.cyl_z_segs)) verts, faces = [], [] for vv in range(v_segs + 1): z = (-cyl_h / 2.0) + (cyl_h * (vv / v_segs)) for uu in range(u_segs): a = (2.0 * pi) * (uu / u_segs) verts.append((cyl_r * cos(a), cyl_r * sin(a), z)) def wall_vid(u, v): return v * u_segs + (u % u_segs) for vv in range(v_segs): for uu in range(u_segs): faces.append((wall_vid(uu, vv), wall_vid(uu + 1, vv), wall_vid(uu + 1, vv + 1), wall_vid(uu, vv + 1))) bottom_center = len(verts) verts.append((0.0, 0.0, -cyl_h / 2.0)) top_center = len(verts) verts.append((0.0, 0.0, cyl_h / 2.0)) for uu in range(u_segs): curr = wall_vid(uu, 0) nxt = wall_vid(uu + 1, 0) faces.append((bottom_center, nxt, curr)) for uu in range(u_segs): curr = wall_vid(uu, v_segs) nxt = wall_vid(uu + 1, v_segs) faces.append((top_center, curr, nxt)) mesh = bpy.data.meshes.new(f"{SOURCE_BOOL_CYL}_Mesh") mesh.from_pydata(verts, [], faces) mesh.update() cyl_obj = bpy.data.objects.new(SOURCE_BOOL_CYL, mesh) root_col.objects.link(cyl_obj) # Pattern centers angle_step = max(0.1, float(p.angle_step_deg)) * (pi / 180.0) cols = max(1, int(round((2.0 * pi) / angle_step))) angle_step = (2.0 * pi) / cols z_step = max(1e-9, float(p.z_spacing_mult)) * d if p.rows_override > 0: rows = int(p.rows_override) else: usable = max(0.0, cyl_h - z_step) rows = max(1, int(usable // z_step) + 1) z0 = (-cyl_h / 2.0) + (0.5 * (cyl_h - (rows - 1) * z_step)) + z_offset row_twist = (float(p.row_twist_deg) * pi) / 180.0 centers_by_row = [] for r in range(rows): if p.pattern_mode == "RING_GRID": offset = 0.0 elif p.pattern_mode == "STAGGERED": offset = 0.5 * angle_step if (r % 2 == 1) else 0.0 else: offset = r * row_twist zc = z0 + r * z_step thetas = [wrap_angle_pi(c * angle_step + offset) for c in range(cols)] centers_by_row.append((zc, thetas)) # Spheres ensure_object_mode() for r in range(rows): zc, thetas = centers_by_row[r] for c, th in enumerate(thetas): n = Vector((cos(th), sin(th), 0.0)) if n.length > 1e-12: n.normalize() surf = Vector((cyl_r * cos(th), cyl_r * sin(th), zc)) center = surf - (n * inset) bpy.ops.mesh.primitive_uv_sphere_add( segments=p.sphere_segments, ring_count=p.sphere_rings, radius=r_sphere, location=center, ) s = context.active_object s.name = f"ACCEL_Sphere_r{r:03d}_c{c:03d}" move_object_to_collection(s, spheres_col) s.hide_set(not p.show_spheres) s.hide_render = not p.show_spheres # Indent apply to wall verts only mode = p.combine_mode max_indent = float(p.max_indent) k = float(p.smoothmax_k) def combine(acc: float, val: float) -> float: if val <= 0.0: return acc if mode == "SUM": return acc + val if mode == "MAX": return val if val > acc else acc if mode == "SMOOTHMAX": return smooth_max(acc, val, k) if acc > 0.0 else val ssum = acc + val if max_indent > 0.0: return min(ssum, max_indent) return ssum half_h = cyl_h * 0.5 if cyl_h > 0 else 1.0 row_span = int(ceil(max_dist / max(1e-9, z_step))) + 1 def indent_depth_at(a: float, z: float, theta0: float, z0c: float) -> float: if sigma <= 0.0 or amp <= 0.0: return 0.0 dtheta = wrap_angle_pi(a - theta0) dz = z - z0c ds = sqrt((cyl_r * dtheta) ** 2 + dz * dz) if ds > max_dist: return 0.0 gauss = exp(-(ds * ds) / (2.0 * sigma * sigma)) front = max(0.0, dz / half_h) front = clamp(front, 0.0, 1.0) bias = (1.0 - front_bias) + (front_bias * front) return amp * gauss * bias wall_vert_count = (v_segs + 1) * u_segs for i in range(wall_vert_count): vtx = mesh.vertices[i] base = Vector(vtx.co) x, y, z = base.x, base.y, base.z n = Vector((x, y, 0.0)) if n.length <= 1e-12: continue n.normalize() a = atan2(y, x) r_float = (z - z0) / z_step r_center = int(round(r_float)) depth = 0.0 r_start = max(0, r_center - row_span) r_end = min(rows - 1, r_center + row_span) for rr in range(r_start, r_end + 1): zc, thetas = centers_by_row[rr] if abs(z - zc) > max_dist: continue for th in thetas: depth = combine(depth, indent_depth_at(a, z, th, zc)) vtx.co = base - (n * depth) mesh.update() set_active(cyl_obj) return {"FINISHED"} # ----------------------------- # Stage 2 Step 1: Prep # ----------------------------- class FEKLA_OT_bool_step1_prep(bpy.types.Operator): bl_idname = "fekla.accel_bool_step1_prep" bl_label = "Stage 2 Step 1: Prep Copies" bl_options = {"REGISTER", "UNDO"} def execute(self, context): p = context.scene.fekla_indent_stamp_props root_col = ensure_collection(p.collection_name) spheres_col = bpy.data.collections.get(f"{p.collection_name}{SPHERES_COL_SUFFIX}") src = bpy.data.objects.get(SOURCE_BOOL_CYL) if spheres_col is None or src is None: self.report({"ERROR"}, "Run Stage 1 first.") return {"CANCELLED"} work_col = ensure_collection(f"{p.collection_name}{WORK_COL_SUFFIX}", parent=root_col) outer_spheres_col = ensure_collection(OUTER_SPHERES_COL, parent=work_col) batch_col = ensure_collection(BATCH_COL, parent=work_col) if not p.keep_work_objects: clear_collection_objects(work_col) outer_spheres_col = ensure_collection(OUTER_SPHERES_COL, parent=work_col) batch_col = ensure_collection(BATCH_COL, parent=work_col) # Remove previous step objects for name in [OUTER_BASE, OUTER_UNION, SHELL_OBJ, INNER_OFFSET_OBJ, FINAL_OPEN]: o = bpy.data.objects.get(name) if o is not None: bpy.data.objects.remove(o, do_unlink=True) # Outer base outer_base = duplicate_object(src, OUTER_BASE, work_col) # Duplicate spheres as operands (not triangulated) clear_collection_objects(outer_spheres_col) sphere_sources = [o for o in spheres_col.objects if o.type == "MESH"] for s in sphere_sources: ds = s.copy() ds.data = s.data.copy() ds.name = f"ACCEL_OuterOp_{s.name}" outer_spheres_col.objects.link(ds) clear_collection_objects(batch_col) set_active(outer_base) return {"FINISHED"} # ----------------------------- # Stage 2 Step 2: UNION spheres # ----------------------------- class FEKLA_OT_bool_step2_union(bpy.types.Operator): bl_idname = "fekla.accel_bool_step2_union" bl_label = "Stage 2 Step 2: Outer UNION" bl_options = {"REGISTER", "UNDO"} def execute(self, context): p = context.scene.fekla_indent_stamp_props outer_base = bpy.data.objects.get(OUTER_BASE) outer_spheres_col = bpy.data.collections.get(OUTER_SPHERES_COL) batch_col = bpy.data.collections.get(BATCH_COL) if outer_base is None or outer_spheres_col is None or batch_col is None: self.report({"ERROR"}, "Run Step 1 first.") return {"CANCELLED"} root_col = ensure_collection(p.collection_name) work_col = ensure_collection(f"{p.collection_name}{WORK_COL_SUFFIX}", parent=root_col) existing = bpy.data.objects.get(OUTER_UNION) if existing is not None: bpy.data.objects.remove(existing, do_unlink=True) union_obj = duplicate_object(outer_base, OUTER_UNION, work_col) ops = [o for o in outer_spheres_col.objects if o.type == "MESH"] solver = p.boolean_solver batch_size = int(p.boolean_batch_size) for chunk in batch_list(ops, batch_size): clear_collection_objects(batch_col) for o in chunk: if batch_col not in o.users_collection: batch_col.objects.link(o) if not boolean_collection_apply(union_obj, batch_col, "UNION", solver): self.report({"ERROR"}, "Step 2 failed. Reduce batch size or switch solver.") return {"CANCELLED"} set_active(union_obj) return {"FINISHED"} # ----------------------------- # Stage 2 Step 3: Constant wall via inner normal offset + DIFFERENCE # ----------------------------- class FEKLA_OT_bool_step3_shell(bpy.types.Operator): bl_idname = "fekla.accel_bool_step3_shell" bl_label = "Stage 2 Step 3: Make Shell (Constant Wall)" bl_options = {"REGISTER", "UNDO"} def execute(self, context): p = context.scene.fekla_indent_stamp_props union_obj = bpy.data.objects.get(OUTER_UNION) if union_obj is None: self.report({"ERROR"}, "Run Step 2 first.") return {"CANCELLED"} root_col = ensure_collection(p.collection_name) work_col = ensure_collection(f"{p.collection_name}{WORK_COL_SUFFIX}", parent=root_col) # Fresh inner offset + shell for name in [INNER_OFFSET_OBJ, SHELL_OBJ]: o = bpy.data.objects.get(name) if o is not None: bpy.data.objects.remove(o, do_unlink=True) inner_offset = duplicate_object(union_obj, INNER_OFFSET_OBJ, work_col) if not make_inner_offset_copy(inner_offset, float(p.wall_thickness)): self.report({"ERROR"}, "Inner offset failed.") return {"CANCELLED"} shell_obj = duplicate_object(union_obj, SHELL_OBJ, work_col) if not boolean_object_apply(shell_obj, inner_offset, "DIFFERENCE", p.boolean_solver): self.report({"ERROR"}, "Shell difference failed. Try solver=EXACT or slightly larger wall thickness.") return {"CANCELLED"} set_active(shell_obj) return {"FINISHED"} # ----------------------------- # Stage 2 Step 4: Open ends (manifold) + optional triangulate + hide others # ----------------------------- class FEKLA_OT_bool_step4_open_manifold(bpy.types.Operator): bl_idname = "fekla.accel_bool_step4_open_manifold" bl_label = "Stage 2 Step 4: Open Ends Manifold + Finish" bl_options = {"REGISTER", "UNDO"} def execute(self, context): p = context.scene.fekla_indent_stamp_props shell_obj = bpy.data.objects.get(SHELL_OBJ) if shell_obj is None: self.report({"ERROR"}, "Run Step 3 first.") return {"CANCELLED"} root_col = ensure_collection(p.collection_name) work_col = ensure_collection(f"{p.collection_name}{WORK_COL_SUFFIX}", parent=root_col) existing = bpy.data.objects.get(FINAL_OPEN) if existing is not None: bpy.data.objects.remove(existing, do_unlink=True) final_obj = duplicate_object(shell_obj, FINAL_OPEN, work_col) # Open ends but keep manifold via rim bridging open_ends_make_manifold_rims(final_obj, p.cap_remove_tol, p.cap_normal_threshold) # Optional triangulate final (visual) if p.triangulate_final: triangulate_object(final_obj, p.tri_quad_method, p.tri_ngon_method) if p.hide_everything_except_final: hide_all_except(final_obj) set_active(final_obj) return {"FINISHED"} # ----------------------------- # UI Panel # ----------------------------- class FEKLA_PT_accel_panel(bpy.types.Panel): bl_label = "Acceleration" bl_idname = "FEKLA_PT_accel_panel" bl_space_type = "VIEW_3D" bl_region_type = "UI" bl_category = "Fekla" def draw(self, context): p = context.scene.fekla_indent_stamp_props layout = self.layout b1 = layout.box() b1.label(text="Stage 1") b1.prop(p, "collection_name") b1.prop(p, "clear_previous") b1.prop(p, "sphere_diameter") row = b1.row(align=True) row.prop(p, "sphere_segments") row.prop(p, "sphere_rings") b1.prop(p, "sphere_center_inset") b1.prop(p, "show_spheres") b1.prop(p, "cyl_radius_mult") b1.prop(p, "cyl_height_mult") b1.prop(p, "cyl_verts") b1.prop(p, "cyl_z_segs") b1.prop(p, "angle_step_deg") b1.prop(p, "pattern_mode") if p.pattern_mode == "HELIX": b1.prop(p, "row_twist_deg") b1.prop(p, "z_spacing_mult") b1.prop(p, "z_offset") b1.prop(p, "rows_override") b1.prop(p, "wave_amp") b1.prop(p, "wave_sigma") b1.prop(p, "wave_front_bias") b1.prop(p, "wave_cutoff_sigma") b1.prop(p, "combine_mode") if p.combine_mode == "SMOOTHMAX": b1.prop(p, "smoothmax_k") if p.combine_mode == "CLAMPED_SUM": b1.prop(p, "max_indent") layout.operator("fekla.accel_stage1_generate", text="Stage 1: Generate") layout.separator() b2 = layout.box() b2.label(text="Stage 2 (4 Steps)") b2.prop(p, "wall_thickness") b2.prop(p, "boolean_solver") b2.prop(p, "boolean_batch_size") b2.label(text="Open Ends (Manifold)") b2.prop(p, "cap_remove_tol") b2.prop(p, "cap_normal_threshold") b2.label(text="Final") b2.prop(p, "triangulate_final") if p.triangulate_final: b2.prop(p, "tri_quad_method") b2.prop(p, "tri_ngon_method") b2.prop(p, "hide_everything_except_final") b2.prop(p, "keep_work_objects") b2.operator("fekla.accel_bool_step1_prep", text="Step 1: Prep Copies") b2.operator("fekla.accel_bool_step2_union", text="Step 2: UNION Spheres") b2.operator("fekla.accel_bool_step3_shell", text="Step 3: Make Shell (Constant Wall)") b2.operator("fekla.accel_bool_step4_open_manifold", text="Step 4: Open Ends Manifold + Finish") # ----------------------------- # Registration # ----------------------------- classes = ( FEKLA_IndentStampProps, FEKLA_OT_generate_stage1, FEKLA_OT_bool_step1_prep, FEKLA_OT_bool_step2_union, FEKLA_OT_bool_step3_shell, FEKLA_OT_bool_step4_open_manifold, FEKLA_PT_accel_panel, ) def register(): for c in classes: bpy.utils.register_class(c) bpy.types.Scene.fekla_indent_stamp_props = PointerProperty(type=FEKLA_IndentStampProps) def unregister(): del bpy.types.Scene.fekla_indent_stamp_props for c in reversed(classes): bpy.utils.unregister_class(c) if __name__ == "__main__": register()