This commit is contained in:
Nicolas Predella 2026-05-14 15:28:10 +03:00
parent a6f2e47884
commit ce6c88e994
3 changed files with 136 additions and 54 deletions

View file

@ -1,8 +1,59 @@
import numpy as np import numpy as np
import zlib import zlib
import numpy as np
import zlib
import struct
from typing import Any, Callable
def crc32_array(a: np.ndarray) -> int: def crc32_array(a: np.ndarray) -> int:
if not a.size: return 0 if not a.size: return 0
ac = np.ascontiguousarray(a) ac = np.ascontiguousarray(a)
mv = memoryview(ac.data).cast("B") mv = memoryview(ac.data).cast("B")
return zlib.adler32(mv) return zlib.adler32(mv)
def crc32_list(l: list[np.ndarray]) -> int:
if not l: return 0
h = 0
for a in l:
h = zlib.adler32(struct.pack(">I", crc32_array(a)), h)
return h
def ndarray_lru_cache(maxsize=128):
def decorator(func: Callable):
cache = {}
order = []
def wrapper(*args, **kwargs):
# Create a key by hashing any ndarray arguments
key_parts = []
for arg in args:
if isinstance(arg, np.ndarray):
key_parts.append(crc32_array(arg))
elif isinstance(arg, list) and arg and isinstance(arg[0], np.ndarray):
key_parts.append(crc32_list(arg))
else:
key_parts.append(arg)
for k, v in sorted(kwargs.items()):
if isinstance(v, np.ndarray):
key_parts.append((k, crc32_array(v)))
elif isinstance(v, list) and v and isinstance(v[0], np.ndarray):
key_parts.append((k, crc32_list(v)))
else:
key_parts.append((k, v))
key = tuple(key_parts)
if key in cache:
# Move to end for LRU
if key in order: order.remove(key)
order.append(key)
return cache[key]
result = func(*args, **kwargs)
cache[key] = result
order.append(key)
if len(order) > maxsize:
oldest = order.pop(0)
del cache[oldest]
return result
return wrapper
return decorator

View file

@ -1,8 +1,11 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from numpy import ndarray from numpy import ndarray
import numpy as np import numpy as np
import pyclipper import pyclipper
from .geometry import ndarray_lru_cache
# Scaling factor for pyclipper (it works with integers) # Scaling factor for pyclipper (it works with integers)
SCALE = 1_000_000 SCALE = 1_000_000
@ -163,6 +166,7 @@ def link_segments(segments: np.ndarray, tolerance=1e-6) -> list[np.ndarray]:
paths.append(res) paths.append(res)
return paths return paths
@ndarray_lru_cache(maxsize=128)
def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]: def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]:
linked = link_segments(segments) linked = link_segments(segments)
@ -192,10 +196,11 @@ def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]:
res.append(fpath_3d) res.append(fpath_3d)
return res return res
@ndarray_lru_cache(maxsize=128)
def boolean_slice(subject: list[np.ndarray], object: list[np.ndarray], operation: str = 'UNION') -> list[np.ndarray]: def boolean_slice(subject: list[np.ndarray], object: list[np.ndarray], operation: str = 'UNION') -> list[np.ndarray]:
all_z_keys: set[int] = set() all_z_keys: set[int] = set()
subject_dict: dict[int, list[ndarray]] = {} subject_dict: dict[int, list[np.ndarray]] = {}
object_dict: dict[int, list[ndarray]] = {} object_dict: dict[int, list[np.ndarray]] = {}
for s in subject: for s in subject:
z = int(round(s[0, 2] * 1000)) z = int(round(s[0, 2] * 1000))
@ -214,15 +219,13 @@ def boolean_slice(subject: list[np.ndarray], object: list[np.ndarray], operation
object_slices = object_dict.get(k) object_slices = object_dict.get(k)
if subject_slices is None: if subject_slices is None:
assert object_slices if object_slices and operation == 'UNION':
if operation == 'UNION':
for s in object_slices: results.append(s) for s in object_slices: results.append(s)
continue continue
if object_slices is None: if object_slices is None:
assert subject_slices if operation != 'INTERSECTION':
if operation == 'INTERSECTION': continue for s in subject_slices: results.append(s)
for s in subject_slices: results.append(s)
continue continue
z = subject_slices[0][0, 2] z = subject_slices[0][0, 2]
@ -241,7 +244,7 @@ def boolean_slice(subject: list[np.ndarray], object: list[np.ndarray], operation
scaled = (path_pts * SCALE).astype(np.int64).tolist() scaled = (path_pts * SCALE).astype(np.int64).tolist()
pc.AddPath(scaled, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue] pc.AddPath(scaled, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue]
# Determine operation type. For now only UNION is supported. # Determine operation type
op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue] op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue]
if operation == 'UNION': op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue] if operation == 'UNION': op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue]
elif operation == 'INTERSECTION': op_type = pyclipper.CT_INTERSECTION # pyright: ignore[reportAttributeAccessIssue] elif operation == 'INTERSECTION': op_type = pyclipper.CT_INTERSECTION # pyright: ignore[reportAttributeAccessIssue]
@ -259,6 +262,7 @@ def boolean_slice(subject: list[np.ndarray], object: list[np.ndarray], operation
return results return results
@ndarray_lru_cache(maxsize=64)
def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round', tolerance=.01) -> tuple[list[np.ndarray], list[int]]: def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round', tolerance=.01) -> tuple[list[np.ndarray], list[int]]:
if not slice_paths: return [], [] if not slice_paths: return [], []
@ -319,6 +323,7 @@ def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round',
return paths_list, groups_list return paths_list, groups_list
@ndarray_lru_cache(maxsize=32)
def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float = 0.0) -> tuple[list[np.ndarray], list[int]]: def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float = 0.0) -> tuple[list[np.ndarray], list[int]]:
"""Generates a grid infill clipped by the provided paths. Consistent across layers.""" """Generates a grid infill clipped by the provided paths. Consistent across layers."""
if not slice_paths or density <= 0: return [], [] if not slice_paths or density <= 0: return [], []
@ -339,7 +344,6 @@ def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float
rot_mat = np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]]) rot_mat = np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]])
# Determine grid bounds in the rotated space to ensure full coverage # Determine grid bounds in the rotated space to ensure full coverage
# We rotate the corners of the global bounding box to find the new required extent
corners = np.array([ corners = np.array([
[g_min_x, g_min_y, 0], [g_max_x, g_min_y, 0], [g_min_x, g_min_y, 0], [g_max_x, g_min_y, 0],
[g_min_x, g_max_y, 0], [g_max_x, g_max_y, 0] [g_min_x, g_max_y, 0], [g_max_x, g_max_y, 0]
@ -349,7 +353,6 @@ def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float
rg_max_x, rg_max_y = rotated_corners[:, 0].max(), rotated_corners[:, 1].max() rg_max_x, rg_max_y = rotated_corners[:, 0].max(), rotated_corners[:, 1].max()
# Generate the global static grid lines in de-rotated space # Generate the global static grid lines in de-rotated space
# Add a small buffer to ensure we don't miss edges
rg_min_x -= density rg_min_x -= density
rg_max_x += density rg_max_x += density
rg_min_y -= density rg_min_y -= density
@ -357,7 +360,7 @@ def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float
scaled_grid_paths = [] scaled_grid_paths = []
# Vertical lines (constant X, varies Y) # Vertical lines
x = rg_min_x x = rg_min_x
while x <= rg_max_x: while x <= rg_max_x:
scaled_grid_paths.append([ scaled_grid_paths.append([
@ -366,7 +369,7 @@ def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float
]) ])
x += density x += density
# Horizontal lines (constant Y, varies X) # Horizontal lines
y = rg_min_y y = rg_min_y
while y <= rg_max_y: while y <= rg_max_y:
scaled_grid_paths.append([ scaled_grid_paths.append([
@ -377,8 +380,14 @@ def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float
# Group by Z # Group by Z
groups: dict[int, list[np.ndarray]] = {} groups: dict[int, list[np.ndarray]] = {}
for p, g in zip(slice_paths, slice_paths.groups): if hasattr(slice_paths, 'groups'):
groups.setdefault(g, []).append(p) for p, g in zip(slice_paths, slice_paths.groups):
groups.setdefault(g, []).append(p)
else:
# Fallback if it's just a list
for p in slice_paths:
z_key = int(round(p[0, 2] * 1000))
groups.setdefault(z_key, []).append(p)
output_paths: list[np.ndarray] = [] output_paths: list[np.ndarray] = []
output_groups: list[int] = [] output_groups: list[int] = []

View file

@ -287,14 +287,23 @@ class TriMesh():
self.length_tris = t self.length_tris = t
self.matrix_world = m self.matrix_world = m
def objects_to_tris(objects: list[Object], scale) -> (NDArray[tuple[int, int, int], dtype[float]], NDArray[tuple[int, int], dtype[float]]): # pyright: ignore[reportInvalidTypeForm] from ..core.geometry import ndarray_lru_cache
@ndarray_lru_cache(maxsize=32)
def _cached_objects_to_tris(obj_names: tuple[str, ...], matrices: tuple[tuple[float, ...], ...], scale: float) -> NDArray[float64]:
# Implementation remains largely the same but uses names to fetch objects
tris_count: int = 0 tris_count: int = 0
meshes: list[TriMesh] = [] meshes: list[TriMesh] = []
for obj in objects: for name in obj_names:
obj = bpy.data.objects.get(name)
if not obj: continue
try: try:
depsgraph = bpy.context.evaluated_depsgraph_get() depsgraph = bpy.context.evaluated_depsgraph_get()
mesh: Mesh = obj.to_mesh(depsgraph=depsgraph, preserve_all_data_layers=True) # We must use the evaluated object to get modifiers
obj_eval = obj.evaluated_get(depsgraph)
mesh: Mesh = obj_eval.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
except: continue except: continue
mesh.calc_loop_triangles() mesh.calc_loop_triangles()
@ -310,22 +319,22 @@ def objects_to_tris(objects: list[Object], scale) -> (NDArray[tuple[int, int, in
np.array(obj.matrix_world.transposed()) np.array(obj.matrix_world.transposed())
)] )]
tris_flat: np.ndarray[tuple[int], dtype[np.float64]] = np.empty(tris_count * 4 * 3, dtype=dtype(np.float64)) tris_flat: np.ndarray = np.empty(tris_count * 4 * 3, dtype=np.float64)
tris: np.ndarray[tuple[int, int, int], dtype[np.float64]] = tris_flat.reshape(-1, 4, 3) tris: np.ndarray = tris_flat.reshape(-1, 4, 3)
col_idx = 0 col_idx = 0
for trimesh in meshes: for trimesh in meshes:
tris_v_i_flat: np.ndarray[tuple[int], dtype[np.int32]] = np.empty(trimesh.length_tris * 3, dtype=dtype(np.int32)) tris_v_i_flat: np.ndarray = np.empty(trimesh.length_tris * 3, dtype=np.int32)
tris_v_n_flat: np.ndarray[tuple[int], dtype[np.float64]] = np.empty(trimesh.length_tris * 3, dtype=dtype(np.float64)) tris_v_n_flat: np.ndarray = np.empty(trimesh.length_tris * 3, dtype=np.float64)
tris_verts_flat: np.ndarray[tuple[int], dtype[np.float64]] = np.empty(trimesh.length_verts * 3, dtype=dtype(np.float64)) tris_verts_flat: np.ndarray = np.empty(trimesh.length_verts * 3, dtype=np.float64)
trimesh.mesh.loop_triangles.foreach_get("vertices", tris_v_i_flat) trimesh.mesh.loop_triangles.foreach_get("vertices", tris_v_i_flat)
trimesh.mesh.loop_triangles.foreach_get("normal", tris_v_n_flat) trimesh.mesh.loop_triangles.foreach_get("normal", tris_v_n_flat)
trimesh.mesh.vertices.foreach_get("co", tris_verts_flat) trimesh.mesh.vertices.foreach_get("co", tris_verts_flat)
tris_v_i: np.ndarray[tuple[int, int], dtype[np.int32]] = tris_v_i_flat.reshape((-1, 3)) tris_v_i: np.ndarray = tris_v_i_flat.reshape((-1, 3))
tris_v_n: np.ndarray[tuple[int, int], dtype[np.float64]] = tris_v_n_flat.reshape((-1, 3)) tris_v_n: np.ndarray = tris_v_n_flat.reshape((-1, 3))
tris_verts: np.ndarray[tuple[int, int], dtype[np.float64]] = tris_verts_flat.reshape((-1, 3)) tris_verts: np.ndarray = tris_verts_flat.reshape((-1, 3))
homogeneous_verts: NDArray[float64] = np.hstack((tris_verts, np.ones((tris_verts.shape[0], 1)))) # type: ignore homogeneous_verts: NDArray[float64] = np.hstack((tris_verts, np.ones((tris_verts.shape[0], 1)))) # type: ignore
tx_verts = homogeneous_verts @ trimesh.matrix_world tx_verts = homogeneous_verts @ trimesh.matrix_world
@ -347,12 +356,31 @@ def objects_to_tris(objects: list[Object], scale) -> (NDArray[tuple[int, int, in
return tris return tris
def capture_mesh_edges(obj: Object) -> list[NDArray[float64]]: def objects_to_tris(objects: list[Object], scale) -> NDArray[float64]:
obj_names = tuple(o.name for o in objects)
matrices = tuple(tuple(val for row in o.matrix_world for val in row) for o in objects)
return _cached_objects_to_tris(obj_names, matrices, scale)
from functools import lru_cache
@lru_cache(maxsize=64)
def _cached_capture_mesh_edges(
obj_name: str,
mesh_name: str,
matrix_tuple: tuple[float, ...],
scene_scale: float
) -> list[NDArray[float64]]:
# This helper expects that the object and mesh exist and are accessible via bpy.data
obj = bpy.data.objects.get(obj_name)
if not obj: return []
# We don't use obj_eval here directly as it's not hashable,
# but we assume this is called when we know we need to re-capture.
# The actual capture logic stays the same but is now inside this cached function.
try: try:
depsgraph = bpy.context.evaluated_depsgraph_get() depsgraph = bpy.context.evaluated_depsgraph_get()
obj_eval = obj.evaluated_get(depsgraph) obj_eval = obj.evaluated_get(depsgraph)
mesh = obj_eval.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph) mesh = obj_eval.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
except Exception: except Exception:
return [] return []
@ -360,32 +388,16 @@ def capture_mesh_edges(obj: Object) -> list[NDArray[float64]]:
return [] return []
try: try:
if not mesh.vertices: world_mat = np.array(matrix_tuple).reshape(4, 4)
return []
# 1. Scaling & Matrix Setup
assert bpy.context.scene
assert bpy.context.scene.unit_settings
scale = 1000.0 * bpy.context.scene.unit_settings.scale_length
world_mat = np.array(obj.matrix_world)
# 2. Extract and Transform Coordinates
num_verts = len(mesh.vertices) num_verts = len(mesh.vertices)
verts = np.empty((num_verts, 3), dtype=np.float64) verts = np.empty((num_verts, 3), dtype=np.float64)
mesh.vertices.foreach_get("co", verts.ravel()) mesh.vertices.foreach_get("co", verts.ravel())
# World transform: (V * R.T) + T tx_verts = (verts @ world_mat[:3, :3].T + world_mat[:3, 3]) * scene_scale
tx_verts = (verts @ world_mat[:3, :3].T + world_mat[:3, 3]) * scale
# 3. Build connectivity lookup
# Only need edges where v2 = v1 + 1 or loop-back
edges_set = {tuple(sorted(e.vertices)) for e in mesh.edges} edges_set = {tuple(sorted(e.vertices)) for e in mesh.edges}
# Identify gaps in sequential connectivity
# connected[i] is True if vertex i is connected to i+1
connected = np.array([(i, i + 1) in edges_set for i in range(num_verts - 1)]) connected = np.array([(i, i + 1) in edges_set for i in range(num_verts - 1)])
# 4. Split into segments (Maintains original index order)
split_at = np.where(~connected)[0] + 1 split_at = np.where(~connected)[0] + 1
raw_paths = np.split(tx_verts, split_at) raw_paths = np.split(tx_verts, split_at)
indices_split = np.split(np.arange(num_verts), split_at) indices_split = np.split(np.arange(num_verts), split_at)
@ -394,19 +406,29 @@ def capture_mesh_edges(obj: Object) -> list[NDArray[float64]]:
for i, path_coords in enumerate(raw_paths): for i, path_coords in enumerate(raw_paths):
if path_coords.shape[0] == 0: if path_coords.shape[0] == 0:
continue continue
# 5. Handle Loop Closure
# Only check if the segment could potentially be a loop (indices > 2)
idx = indices_split[i] idx = indices_split[i]
if len(idx) > 2 and tuple(sorted((idx[0], idx[-1]))) in edges_set: if len(idx) > 2 and tuple(sorted((idx[0], idx[-1]))) in edges_set:
path_coords = np.vstack((path_coords, path_coords[0])) path_coords = np.vstack((path_coords, path_coords[0]))
final_paths.append(path_coords) final_paths.append(path_coords)
return final_paths return final_paths
finally: finally:
# Compatibility with Blender 2.8+ to avoid memory leaks if hasattr(obj_eval, "to_mesh_clear"):
# We check if the attribute exists as it's not used elsewhere in this file obj_eval.to_mesh_clear()
if hasattr(obj, "to_mesh_clear"):
obj.to_mesh_clear() def capture_mesh_edges(obj: Object) -> list[NDArray[float64]]:
if not obj or not obj.data: return []
assert bpy.context.scene and bpy.context.scene.unit_settings
scene_scale = 1000.0 * bpy.context.scene.unit_settings.scale_length
# Create a hashable key from object state
matrix_tuple = tuple(val for row in obj.matrix_world for val in row) # Flattened tuple
return _cached_capture_mesh_edges(
obj.name,
obj.data.name,
matrix_tuple,
scene_scale
)