""" geometry.py ----------- Procedural torus-knot geometry engine. Key design choice (P2 fix) -------------------------- `_make_torus_knot` no longer reads from `bpy.context`. The three scene-global parameters that change between calls — resolution, bevel_resolution, knot_scale — are passed in as explicit keyword arguments by the caller (either `knot_frame_handler` or `KNOT_OT_BakePreview.execute`). This makes the function independently testable and keeps geometry logic separate from scene-state queries. Torus-knot parametric equations -------------------------------- For each link l of gcd total links: t ∈ [0, 2π) (steps = resolution) rev = (p/gcd)·t + rP + (l·2π/gcd) spin = (q/gcd)·t + sP rad = R + r·cos(spin·v) x = rad·cos(rev·u) y = rad·sin(rev·u) z = r·sin(spin·v)·h """ from __future__ import annotations import math import bpy from .constants import KNOT_OBJ_NAME # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def _make_torus_knot( config: dict, *, resolution: int, bevel_resolution: int, knot_scale: float, scene=None, ) -> bpy.types.Object: """Procedurally build / update the AnimKnot NURBS curve in-place. This avoids ALL crashes associated with bpy.ops, edit-mode switching, and dependency-graph race conditions during playback or rendering. Parameters ---------- config: KnotConfig dict produced by ``KnotItem.to_dict()`` (optionally augmented by the frame handler with ``_scale_override`` / ``_skip_material`` keys). resolution: Number of NURBS control points per spline. bevel_resolution: Curve bevel resolution (controls tube facets). knot_scale: Global scale factor from ``KnotGlobalSettings.knot_scale``. scene: The active Blender scene. Used only when the AnimKnot object does not yet exist and needs to be linked to the scene collection. Falls back to ``bpy.context.scene`` if not provided. """ if scene is None: scene = bpy.context.scene # 1. Find or create the reusable curve object obj = bpy.data.objects.get(KNOT_OBJ_NAME) if not obj: curve_data = bpy.data.curves.new(name=KNOT_OBJ_NAME, type='CURVE') obj = bpy.data.objects.new(KNOT_OBJ_NAME, curve_data) scene.collection.objects.link(obj) else: curve_data = obj.data # NOTE: Do NOT call curve_data.splines.clear() here. # The rendered viewport keeps an evaluated depsgraph copy alive during # frame_change_post. Clearing splines frees the underlying C SplinePoint # arrays while the evaluator's GeomCache still holds a pointer to them. # TBB's allocator then detects the use-after-free on the next malloc and # raises EXCEPTION_ACCESS_VIOLATION inside tbbmalloc.dll. # Instead we reconcile the spline count in-place (add/remove from tail) # and only reallocate point arrays when the count actually changes. # 2. Curve geometry properties curve_data.dimensions = '3D' curve_data.fill_mode = 'FULL' curve_data.extrude = config.get('geo_extrude', 0.0) curve_data.offset = config.get('geo_offset', 0.0) curve_data.bevel_depth = config.get('geo_bDepth', 0.04) curve_data.bevel_resolution = bevel_resolution # ← no bpy.context read # 3. Resolve shape parameters shape_type = config.get('shape_type', 'TORUS_KNOT') if shape_type == 'TORUS_KNOT': p = config.get('torus_p', 2) q = config.get('torus_q', 3) if config.get('flip_p', False): p = -p if config.get('flip_q', False): q = -q mode = config.get('mode', 'MAJOR_MINOR') if mode == 'EXT_INT': eR = config.get('torus_eR', 3.0) iR = config.get('torus_iR', 1.0) R = (eR + iR) / 2.0 r = (eR - iR) / 2.0 else: R = config.get('torus_R', 2.0) r = config.get('torus_r', 1.0) u = config.get('torus_u', 1) v = config.get('torus_v', 1) rP = config.get('torus_rP', 0.0) * math.pi * 2.0 sP = config.get('torus_sP', 0.0) * math.pi * 2.0 h = config.get('torus_h', 1.0) multiple_links = config.get('multiple_links', False) gcd = math.gcd(abs(p), abs(q)) if p and q else 1 if not multiple_links: gcd = 1 is_cyclic = True elif shape_type == 'MOBIUS': twists = config.get('mobius_twists', 1) w = config.get('mobius_width', 1.0) R = config.get('torus_R', 2.0) # Use torus major radius for the ring size gcd = 2 if twists % 2 == 0 else 1 is_cyclic = True elif shape_type == 'LISSAJOUS': kx = config.get('liss_kx', 3) ky = config.get('liss_ky', 2) kz = config.get('liss_kz', 4) amp = config.get('liss_amp', 2.0) gcd = 1 is_cyclic = True elif shape_type == 'SPIRAL': turns = config.get('spiral_turns', 10) R = config.get('spiral_R', 2.0) gcd = 1 is_cyclic = False else: gcd = 1 is_cyclic = True # 4. Reconcile spline count without freeing existing splines. steps = resolution # ← no bpy.context read current_count = len(curve_data.splines) if current_count < gcd: for _ in range(gcd - current_count): sp = curve_data.splines.new('NURBS') sp.use_cyclic_u = is_cyclic sp.use_endpoint_u = not is_cyclic elif current_count > gcd: for _ in range(current_count - gcd): curve_data.splines.remove(curve_data.splines[-1]) # 5. Write point coordinates in-place for each spline. TAU = 2.0 * math.pi for link, spline in enumerate(curve_data.splines): spline.use_cyclic_u = is_cyclic spline.use_endpoint_u = not is_cyclic # Grow or shrink the point array only when the size changes. current_pts = len(spline.points) if current_pts < steps: spline.points.add(steps - current_pts) elif current_pts > steps: curve_data.splines.remove(spline) spline = curve_data.splines.new('NURBS') spline.use_cyclic_u = is_cyclic spline.use_endpoint_u = not is_cyclic spline.points.add(steps - 1) for i in range(steps): if shape_type == 'TORUS_KNOT': t_param = (i / steps) * TAU rev = (p / gcd) * t_param + rP + (link * TAU / gcd) spin = (q / gcd) * t_param + sP rad = R + r * math.cos(spin * v) x = rad * math.cos(rev * u) y = rad * math.sin(rev * u) z = r * math.sin(spin * v) * h elif shape_type == 'MOBIUS': t_max = 2.0 * TAU if gcd == 1 else TAU t_param = (i / steps) * t_max w_eff = w if link == 0 else -w x = (R + w_eff * math.cos(twists * t_param / 2.0)) * math.cos(t_param) y = (R + w_eff * math.cos(twists * t_param / 2.0)) * math.sin(t_param) z = w_eff * math.sin(twists * t_param / 2.0) elif shape_type == 'LISSAJOUS': t_param = (i / steps) * TAU x = amp * math.sin(kx * t_param) y = amp * math.sin(ky * t_param + (TAU / 4.0)) z = amp * math.sin(kz * t_param) elif shape_type == 'SPIRAL': t_param = -math.pi / 2.0 + (i / max(1, steps - 1)) * math.pi theta = t_param phi = turns * 2.0 * t_param x = R * math.cos(theta) * math.cos(phi) y = R * math.cos(theta) * math.sin(phi) z = R * math.sin(theta) else: x, y, z = 0.0, 0.0, 0.0 spline.points[i].co = (x, y, z, 1.0) # 6. Transform and material obj.location = (0.0, 0.0, 0.0) # Apply per-knot scale oscillation override if set by the handler scale_override = config.get("_scale_override", None) effective_scale = knot_scale if scale_override is None else knot_scale * scale_override obj.scale = (effective_scale, effective_scale, effective_scale) # Assign material unless the handler has already set up a cross-shader blend if not config.get("_skip_material", False): if config.get("material_mode") == 'PROJECT': mat = config.get("project_material") else: uid = config.get("uid") mat = bpy.data.materials.get(f"KnotItem_Preset_{uid}") if mat: if len(obj.data.materials) == 0: obj.data.materials.append(mat) else: obj.data.materials[0] = mat return obj def _remove_existing_knot() -> None: """Remove the AnimKnot object and its curve data block from the scene. Called by ``unregister()`` to clean up when the script is reloaded or disabled. ``_make_torus_knot()`` reuses the object in-place during animation playback, so this function is intentionally NOT called there. """ obj = bpy.data.objects.get(KNOT_OBJ_NAME) if obj is not None: curve_data = obj.data bpy.data.objects.remove(obj, do_unlink=True) if curve_data and curve_data.users == 0: bpy.data.curves.remove(curve_data)