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 zlib
import numpy as np
import zlib
import struct
from typing import Any, Callable
def crc32_array(a: np.ndarray) -> int:
if not a.size: return 0
ac = np.ascontiguousarray(a)
mv = memoryview(ac.data).cast("B")
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 typing import Any
from numpy import ndarray
import numpy as np
import pyclipper
from .geometry import ndarray_lru_cache
# Scaling factor for pyclipper (it works with integers)
SCALE = 1_000_000
@ -163,6 +166,7 @@ def link_segments(segments: np.ndarray, tolerance=1e-6) -> list[np.ndarray]:
paths.append(res)
return paths
@ndarray_lru_cache(maxsize=128)
def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]:
linked = link_segments(segments)
@ -192,10 +196,11 @@ def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]:
res.append(fpath_3d)
return res
@ndarray_lru_cache(maxsize=128)
def boolean_slice(subject: list[np.ndarray], object: list[np.ndarray], operation: str = 'UNION') -> list[np.ndarray]:
all_z_keys: set[int] = set()
subject_dict: dict[int, list[ndarray]] = {}
object_dict: dict[int, list[ndarray]] = {}
subject_dict: dict[int, list[np.ndarray]] = {}
object_dict: dict[int, list[np.ndarray]] = {}
for s in subject:
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)
if subject_slices is None:
assert object_slices
if operation == 'UNION':
if object_slices and operation == 'UNION':
for s in object_slices: results.append(s)
continue
if object_slices is None:
assert subject_slices
if operation == 'INTERSECTION': continue
for s in subject_slices: results.append(s)
if operation != 'INTERSECTION':
for s in subject_slices: results.append(s)
continue
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()
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]
if operation == 'UNION': op_type = pyclipper.CT_UNION # 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
@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]]:
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
@ndarray_lru_cache(maxsize=32)
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."""
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]])
# 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([
[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]
@ -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()
# 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_max_x += density
rg_min_y -= density
@ -357,7 +360,7 @@ def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float
scaled_grid_paths = []
# Vertical lines (constant X, varies Y)
# Vertical lines
x = rg_min_x
while x <= rg_max_x:
scaled_grid_paths.append([
@ -366,7 +369,7 @@ def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float
])
x += density
# Horizontal lines (constant Y, varies X)
# Horizontal lines
y = rg_min_y
while y <= rg_max_y:
scaled_grid_paths.append([
@ -377,8 +380,14 @@ def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float
# Group by Z
groups: dict[int, list[np.ndarray]] = {}
for p, g in zip(slice_paths, slice_paths.groups):
groups.setdefault(g, []).append(p)
if hasattr(slice_paths, 'groups'):
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_groups: list[int] = []

View file

@ -287,14 +287,23 @@ class TriMesh():
self.length_tris = t
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
meshes: list[TriMesh] = []
for obj in objects:
for name in obj_names:
obj = bpy.data.objects.get(name)
if not obj: continue
try:
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
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())
)]
tris_flat: np.ndarray[tuple[int], dtype[np.float64]] = np.empty(tris_count * 4 * 3, dtype=dtype(np.float64))
tris: np.ndarray[tuple[int, int, int], dtype[np.float64]] = tris_flat.reshape(-1, 4, 3)
tris_flat: np.ndarray = np.empty(tris_count * 4 * 3, dtype=np.float64)
tris: np.ndarray = tris_flat.reshape(-1, 4, 3)
col_idx = 0
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_n_flat: np.ndarray[tuple[int], dtype[np.float64]] = np.empty(trimesh.length_tris * 3, dtype=dtype(np.float64))
tris_verts_flat: np.ndarray[tuple[int], dtype[np.float64]] = np.empty(trimesh.length_verts * 3, dtype=dtype(np.float64))
tris_v_i_flat: np.ndarray = np.empty(trimesh.length_tris * 3, dtype=np.int32)
tris_v_n_flat: np.ndarray = np.empty(trimesh.length_tris * 3, 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("normal", tris_v_n_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_n: np.ndarray[tuple[int, int], dtype[np.float64]] = tris_v_n_flat.reshape((-1, 3))
tris_verts: np.ndarray[tuple[int, int], dtype[np.float64]] = tris_verts_flat.reshape((-1, 3))
tris_v_i: np.ndarray = tris_v_i_flat.reshape((-1, 3))
tris_v_n: np.ndarray = tris_v_n_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
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
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:
depsgraph = bpy.context.evaluated_depsgraph_get()
obj_eval = obj.evaluated_get(depsgraph)
mesh = obj_eval.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
except Exception:
return []
@ -360,32 +388,16 @@ def capture_mesh_edges(obj: Object) -> list[NDArray[float64]]:
return []
try:
if not mesh.vertices:
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
world_mat = np.array(matrix_tuple).reshape(4, 4)
num_verts = len(mesh.vertices)
verts = np.empty((num_verts, 3), dtype=np.float64)
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]) * scale
tx_verts = (verts @ world_mat[:3, :3].T + world_mat[:3, 3]) * scene_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}
# 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)])
# 4. Split into segments (Maintains original index order)
split_at = np.where(~connected)[0] + 1
raw_paths = np.split(tx_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):
if path_coords.shape[0] == 0:
continue
# 5. Handle Loop Closure
# Only check if the segment could potentially be a loop (indices > 2)
idx = indices_split[i]
if len(idx) > 2 and tuple(sorted((idx[0], idx[-1]))) in edges_set:
path_coords = np.vstack((path_coords, path_coords[0]))
final_paths.append(path_coords)
return final_paths
finally:
# Compatibility with Blender 2.8+ to avoid memory leaks
# We check if the attribute exists as it's not used elsewhere in this file
if hasattr(obj, "to_mesh_clear"):
obj.to_mesh_clear()
if hasattr(obj_eval, "to_mesh_clear"):
obj_eval.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
)