Files
Pr3tz/knot_animation/geometry.py
T
Stefan Cepko 39a4725334 Big update
2026-06-02 05:19:38 -04:00

275 lines
9.9 KiB
Python

"""
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,
turbulence: float = 0.0,
smooth_shading: bool = True,
) -> 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')
rP = config.get('torus_rP', 0.0) * math.pi * 2.0
sP = config.get('torus_sP', 0.0) * math.pi * 2.0
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)
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
t_param_rot = t_param + rP
twist_angle = twists * t_param_rot / 2.0 + sP
x = (R + w_eff * math.cos(twist_angle)) * math.cos(t_param_rot)
y = (R + w_eff * math.cos(twist_angle)) * math.sin(t_param_rot)
z = w_eff * math.sin(twist_angle)
elif shape_type == 'LISSAJOUS':
t_param = (i / steps) * TAU
x = amp * math.sin(kx * t_param + rP)
y = amp * math.sin(ky * t_param + (TAU / 4.0) + sP)
z = amp * math.sin(kz * t_param + rP + sP)
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 + rP + sP
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
if turbulence > 0.0:
nx = math.sin(i * 1.345 + rP) * turbulence
ny = math.cos(i * 0.932 - sP) * turbulence
nz = math.sin(i * 1.777 + rP + sP) * turbulence
x += nx
y += ny
z += nz
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)