diff --git a/core/geometry.py b/core/geometry.py index 204c813..1d34c67 100644 --- a/core/geometry.py +++ b/core/geometry.py @@ -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) \ No newline at end of file + 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 \ No newline at end of file diff --git a/core/slice.py b/core/slice.py index 9f4f943..017b139 100644 --- a/core/slice.py +++ b/core/slice.py @@ -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] = [] diff --git a/infra/blender_mesh_capture.py b/infra/blender_mesh_capture.py index 5157f5c..8b722ff 100644 --- a/infra/blender_mesh_capture.py +++ b/infra/blender_mesh_capture.py @@ -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() \ No newline at end of file + 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 + ) \ No newline at end of file