275 lines
9.9 KiB
Python
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)
|