668 lines
23 KiB
Python
668 lines
23 KiB
Python
"""
|
|
2D Vector Edge Extraction Module for PDF Exporter.
|
|
Extracts top-view wireframe, lineart, boundary, contour, and crease edges from 3D SlicingObjects.
|
|
"""
|
|
import math
|
|
from typing import Any
|
|
import numpy as np
|
|
from numpy.typing import NDArray
|
|
|
|
def deduplicate_2d_edges(edges_2d: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
"""
|
|
Deduplicates 2D edge segments regardless of direction (A->B or B->A).
|
|
"""
|
|
if len(edges_2d) == 0:
|
|
return edges_2d
|
|
|
|
p0 = edges_2d[:, 0, :]
|
|
p1 = edges_2d[:, 1, :]
|
|
|
|
swap_mask = (p0[:, 0] > p1[:, 0]) | ((p0[:, 0] == p1[:, 0]) & (p0[:, 1] > p1[:, 1]))
|
|
|
|
canonical_p0 = np.where(swap_mask[:, None], p1, p0)
|
|
canonical_p1 = np.where(swap_mask[:, None], p0, p1)
|
|
|
|
quantized_p0 = np.round(canonical_p0, decimals=4)
|
|
quantized_p1 = np.round(canonical_p1, decimals=4)
|
|
|
|
stacked = np.column_stack([quantized_p0, quantized_p1])
|
|
|
|
_, unique_indices = np.unique(stacked, axis=0, return_index=True)
|
|
return edges_2d[unique_indices]
|
|
|
|
|
|
def transform_slice_paths(
|
|
sp: Any,
|
|
t_arr: NDArray[np.float64],
|
|
r_arr: NDArray[np.float64],
|
|
s_arr: NDArray[np.float64],
|
|
) -> Any:
|
|
"""
|
|
Transforms 3D coordinates in a SlicePaths object using translation, Euler rotation (radians), and scale.
|
|
"""
|
|
from ..slice import SlicePaths
|
|
|
|
if not isinstance(sp, SlicePaths):
|
|
return sp
|
|
|
|
has_scale = any(s != 1.0 for s in s_arr)
|
|
has_rot = any(r != 0.0 for r in r_arr)
|
|
has_trans = any(t != 0.0 for t in t_arr)
|
|
|
|
if not (has_scale or has_rot or has_trans):
|
|
return sp.copy_with(paths=list(sp))
|
|
|
|
R = None
|
|
if has_rot:
|
|
rx, ry, rz = r_arr
|
|
cx, sx = np.cos(rx), np.sin(rx)
|
|
cy, sy = np.cos(ry), np.sin(ry)
|
|
cz, sz = np.cos(rz), np.sin(rz)
|
|
|
|
Rx = np.array([
|
|
[1.0, 0.0, 0.0],
|
|
[0.0, cx, -sx],
|
|
[0.0, sx, cx]
|
|
], dtype=np.float64)
|
|
Ry = np.array([
|
|
[cy, 0.0, sy],
|
|
[0.0, 1.0, 0.0],
|
|
[-sy, 0.0, cy]
|
|
], dtype=np.float64)
|
|
Rz = np.array([
|
|
[cz, -sz, 0.0],
|
|
[sz, cz, 0.0],
|
|
[0.0, 0.0, 1.0]
|
|
], dtype=np.float64)
|
|
R = Rz @ (Ry @ Rx)
|
|
|
|
new_paths = []
|
|
for p in sp:
|
|
if len(p) == 0:
|
|
new_paths.append(p.copy())
|
|
continue
|
|
|
|
new_p = p.astype(np.float64, copy=True)
|
|
dim = new_p.shape[1] if new_p.ndim > 1 else 0
|
|
|
|
if has_scale and dim > 0:
|
|
if dim >= 3:
|
|
new_p[:, :3] *= s_arr
|
|
else:
|
|
new_p[:, :dim] *= s_arr[:dim]
|
|
|
|
if has_rot and R is not None and dim > 0:
|
|
if dim >= 3:
|
|
new_p[:, :3] = new_p[:, :3] @ R.T
|
|
elif dim == 2:
|
|
rot_2d = R[:2, :2]
|
|
new_p[:, :2] = new_p[:, :2] @ rot_2d.T
|
|
|
|
if has_trans and dim > 0:
|
|
if dim >= 3:
|
|
new_p[:, :3] += t_arr
|
|
else:
|
|
new_p[:, :dim] += t_arr[:dim]
|
|
|
|
new_paths.append(new_p)
|
|
|
|
return sp.copy_with(paths=new_paths)
|
|
|
|
|
|
def get_first_object_rotation(
|
|
slicing_objs: list[Any],
|
|
raw_geometry: Any = None,
|
|
slice_paths: Any = None,
|
|
) -> tuple[float, float, float]:
|
|
"""
|
|
Extracts the accumulated 3D Euler rotation angles (rx, ry, rz) in radians
|
|
from the first object or raw_geometry / slice_paths.
|
|
"""
|
|
candidates: list[Any] = []
|
|
if slicing_objs:
|
|
candidates.extend(slicing_objs)
|
|
if raw_geometry is not None and raw_geometry not in candidates:
|
|
candidates.append(raw_geometry)
|
|
if slice_paths is not None and slice_paths not in candidates:
|
|
candidates.append(slice_paths)
|
|
|
|
for item in candidates:
|
|
transforms = getattr(item, "transforms", None)
|
|
if transforms:
|
|
rx, ry, rz = 0.0, 0.0, 0.0
|
|
has_rot = False
|
|
for t in transforms:
|
|
if isinstance(t, dict) and "rotation" in t:
|
|
r = t["rotation"]
|
|
rx += float(r[0])
|
|
ry += float(r[1])
|
|
rz += float(r[2])
|
|
has_rot = True
|
|
if has_rot:
|
|
return (rx, ry, rz)
|
|
|
|
return (0.0, 0.0, 0.0)
|
|
|
|
|
|
def generate_grid_slice_paths(
|
|
grid_min_x: float,
|
|
grid_max_x: float,
|
|
grid_min_y: float,
|
|
grid_max_y: float,
|
|
minor_step: float,
|
|
max_grid_steps: int = 2000,
|
|
) -> tuple[Any, Any]:
|
|
"""
|
|
Generates 3D grid line geometry sitting on the XY plane (Z=0) as two SlicePaths objects:
|
|
(minor_grid_slice_paths, main_grid_slice_paths).
|
|
"""
|
|
from ..slice import SlicePaths
|
|
|
|
k_x_start = int(np.round(grid_min_x / minor_step))
|
|
k_x_end = int(np.round(grid_max_x / minor_step))
|
|
k_x = np.arange(k_x_start, k_x_end + 1)
|
|
|
|
k_y_start = int(np.round(grid_min_y / minor_step))
|
|
k_y_end = int(np.round(grid_max_y / minor_step))
|
|
k_y = np.arange(k_y_start, k_y_end + 1)
|
|
|
|
if len(k_x) > max_grid_steps or len(k_y) > max_grid_steps:
|
|
return SlicePaths(), SlicePaths()
|
|
|
|
minor_paths: list[NDArray[np.float64]] = []
|
|
main_paths: list[NDArray[np.float64]] = []
|
|
|
|
# Vertical grid lines (constant X)
|
|
for k in k_x:
|
|
gx = float(k * minor_step)
|
|
line = np.array([[gx, grid_min_y, 0.0], [gx, grid_max_y, 0.0]], dtype=np.float64)
|
|
if k % 10 == 0:
|
|
main_paths.append(line)
|
|
else:
|
|
minor_paths.append(line)
|
|
|
|
# Horizontal grid lines (constant Y)
|
|
for k in k_y:
|
|
gy = float(k * minor_step)
|
|
line = np.array([[grid_min_x, gy, 0.0], [grid_max_x, gy, 0.0]], dtype=np.float64)
|
|
if k % 10 == 0:
|
|
main_paths.append(line)
|
|
else:
|
|
minor_paths.append(line)
|
|
|
|
return SlicePaths(minor_paths), SlicePaths(main_paths)
|
|
|
|
|
|
|
|
def extract_slice_paths_edges(slice_paths: Any) -> NDArray[np.float64]:
|
|
"""
|
|
Extracts 2D line segments (shape N x 2 x 2) from SlicePaths or a list/collection of SlicePaths.
|
|
"""
|
|
from ..slice import SlicePaths
|
|
|
|
if slice_paths is None:
|
|
return np.empty((0, 2, 2), dtype=np.float64)
|
|
|
|
all_segments: list[np.ndarray] = []
|
|
|
|
def process_item(item: Any):
|
|
if item is None:
|
|
return
|
|
elif isinstance(item, SlicePaths):
|
|
for path in item:
|
|
if path is not None and len(path) >= 2:
|
|
pts = np.asarray(path, dtype=np.float64)[:, :2]
|
|
segs = np.stack([pts[:-1], pts[1:]], axis=1)
|
|
all_segments.append(segs)
|
|
elif isinstance(item, (list, tuple)):
|
|
for sub in item:
|
|
process_item(sub)
|
|
|
|
process_item(slice_paths)
|
|
|
|
if not all_segments:
|
|
return np.empty((0, 2, 2), dtype=np.float64)
|
|
|
|
return np.concatenate(all_segments, axis=0)
|
|
|
|
|
|
def extract_unique_top_view_edges(mesh_data: Any) -> NDArray[np.float64]:
|
|
"""
|
|
Extracts 2D top-view unique edges (X, Y projection) from mesh_data (SlicingObjects or SlicePaths).
|
|
"""
|
|
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
|
|
from ..slice import SlicePaths
|
|
|
|
all_edge_segments: list[NDArray[np.float64]] = []
|
|
|
|
def process_item(item: Any):
|
|
if item is None:
|
|
return
|
|
elif isinstance(item, SlicePaths):
|
|
sp_edges = extract_slice_paths_edges(item)
|
|
if len(sp_edges) > 0:
|
|
all_edge_segments.append(sp_edges)
|
|
elif isinstance(item, (list, tuple)):
|
|
for sub in item:
|
|
process_item(sub)
|
|
elif isinstance(item, SlicingGroup):
|
|
for coll in item.collections.values():
|
|
process_item(coll)
|
|
elif isinstance(item, SlicingCollection):
|
|
all_verts, all_idxs = item.unique_verts
|
|
if len(all_idxs) > 0:
|
|
edges = np.vstack([
|
|
all_idxs[:, [0, 1]],
|
|
all_idxs[:, [1, 2]],
|
|
all_idxs[:, [2, 0]]
|
|
])
|
|
sorted_edges = np.sort(edges, axis=1)
|
|
unique_edge_indices = np.unique(sorted_edges, axis=0)
|
|
edge_3d = all_verts[unique_edge_indices]
|
|
all_edge_segments.append(edge_3d[:, :, :2])
|
|
elif isinstance(item, SlicingObject):
|
|
if item.mesh.size > 0:
|
|
verts_3d = item.mesh[:, :3, :3]
|
|
verts_2d = verts_3d[:, :, :2]
|
|
e0 = verts_2d[:, [0, 1], :]
|
|
e1 = verts_2d[:, [1, 2], :]
|
|
e2 = verts_2d[:, [2, 0], :]
|
|
edges_2d = np.concatenate([e0, e1, e2], axis=0)
|
|
all_edge_segments.append(edges_2d)
|
|
|
|
process_item(mesh_data)
|
|
|
|
if not all_edge_segments:
|
|
return np.empty((0, 2, 2), dtype=np.float64)
|
|
|
|
combined_edges = np.concatenate(all_edge_segments, axis=0)
|
|
if combined_edges.shape[0] == 0:
|
|
return np.empty((0, 2, 2), dtype=np.float64)
|
|
|
|
dists_sq = np.sum((combined_edges[:, 0, :] - combined_edges[:, 1, :]) ** 2, axis=1)
|
|
valid_mask = dists_sq > 1e-8
|
|
valid_edges = combined_edges[valid_mask]
|
|
if valid_edges.shape[0] == 0:
|
|
return np.empty((0, 2, 2), dtype=np.float64)
|
|
|
|
return deduplicate_2d_edges(valid_edges)
|
|
|
|
|
|
def extract_lineart_edges(mesh_data: Any, max_angle_deg: float = 30.0) -> NDArray[np.float64]:
|
|
"""
|
|
Fully vectorized NumPy Lineart edge extraction:
|
|
1. Silhouette edge: adjacent faces have different normal.z sign categories (front/back/vertical).
|
|
2. Boundary edge: owned by only 1 face.
|
|
3. Crease edge: owned by 2 or more faces where 3D dihedral angle between normals > max_angle_deg.
|
|
Returns 2D edge segments array of shape (N, 2, 2).
|
|
"""
|
|
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
|
|
|
|
all_tris_3d = []
|
|
all_normals = []
|
|
|
|
def process_item(item: Any):
|
|
if item is None:
|
|
return
|
|
elif isinstance(item, (list, tuple)):
|
|
for sub in item:
|
|
process_item(sub)
|
|
elif isinstance(item, SlicingGroup):
|
|
for coll in item.collections.values():
|
|
process_item(coll)
|
|
elif isinstance(item, SlicingCollection):
|
|
for so in item.objects:
|
|
process_item(so)
|
|
elif isinstance(item, SlicingObject):
|
|
if item.mesh.size > 0:
|
|
verts_3d = item.mesh[:, :3, :3] # (N, 3, 3)
|
|
if item.mesh.shape[1] >= 4:
|
|
normals = item.mesh[:, 3, :] # (N, 3)
|
|
else:
|
|
v0_3d = verts_3d[:, 0, :]
|
|
v1_3d = verts_3d[:, 1, :]
|
|
v2_3d = verts_3d[:, 2, :]
|
|
normals = np.cross(v1_3d - v0_3d, v2_3d - v0_3d)
|
|
|
|
norms_len = np.linalg.norm(normals, axis=1, keepdims=True)
|
|
norms_len = np.where(norms_len == 0, 1.0, norms_len)
|
|
normals_unit = normals / norms_len
|
|
|
|
zero_norm_mask = (normals[:, 0] == 0) & (normals[:, 1] == 0) & (normals[:, 2] == 0)
|
|
if np.any(zero_norm_mask):
|
|
v0_3d = verts_3d[:, 0, :]
|
|
v1_3d = verts_3d[:, 1, :]
|
|
v2_3d = verts_3d[:, 2, :]
|
|
computed_normals = np.cross(v1_3d - v0_3d, v2_3d - v0_3d)
|
|
cn_len = np.linalg.norm(computed_normals, axis=1, keepdims=True)
|
|
cn_len = np.where(cn_len == 0, 1.0, cn_len)
|
|
normals_unit[zero_norm_mask] = (computed_normals / cn_len)[zero_norm_mask]
|
|
|
|
all_tris_3d.append(verts_3d)
|
|
all_normals.append(normals_unit)
|
|
|
|
process_item(mesh_data)
|
|
|
|
if not all_tris_3d:
|
|
return np.empty((0, 2, 2), dtype=np.float64)
|
|
|
|
tris_3d = np.concatenate(all_tris_3d, axis=0) # (N, 3, 3)
|
|
normals_unit = np.concatenate(all_normals, axis=0) # (N, 3)
|
|
num_tris = len(tris_3d)
|
|
|
|
if num_tris == 0:
|
|
return np.empty((0, 2, 2), dtype=np.float64)
|
|
|
|
# Top-view 2D vertices
|
|
verts_2d = tris_3d[:, :, :2] # (N, 3, 2)
|
|
|
|
# For silhouette detection, categorize faces by normal.z sign
|
|
# For view direction (0,0,-1):
|
|
# - normal.z < 0: front-facing (facing viewer)
|
|
# - normal.z > 0: back-facing (facing away)
|
|
# - normal.z == 0: edge-on / vertical
|
|
normal_z = normals_unit[:, 2]
|
|
epsilon = 1e-3
|
|
face_category = np.zeros(num_tris, dtype=int)
|
|
face_category[normal_z < -epsilon] = 1 # Front-facing
|
|
face_category[normal_z > epsilon] = 2 # Back-facing
|
|
# face_category == 0 means vertical (|normal_z| <= epsilon)
|
|
|
|
# Extract all 3 edges per triangle
|
|
e0_3d = tris_3d[:, [0, 1], :]
|
|
e1_3d = tris_3d[:, [1, 2], :]
|
|
e2_3d = tris_3d[:, [2, 0], :]
|
|
edges_3d = np.concatenate([e0_3d, e1_3d, e2_3d], axis=0) # (3N, 2, 3)
|
|
|
|
e0_2d = verts_2d[:, [0, 1], :]
|
|
e1_2d = verts_2d[:, [1, 2], :]
|
|
e2_2d = verts_2d[:, [2, 0], :]
|
|
edges_2d = np.concatenate([e0_2d, e1_2d, e2_2d], axis=0) # (3N, 2, 2)
|
|
|
|
edge_category = np.tile(face_category, 3) # (3N,)
|
|
edge_normals = np.tile(normals_unit, (3, 1)) # (3N, 3)
|
|
|
|
# Filter out near zero-length 2D edges
|
|
dists_sq_2d = np.sum((edges_2d[:, 0, :] - edges_2d[:, 1, :]) ** 2, axis=1)
|
|
valid_len_mask = dists_sq_2d >= 1e-8
|
|
|
|
edges_3d = edges_3d[valid_len_mask]
|
|
edges_2d = edges_2d[valid_len_mask]
|
|
edge_category = edge_category[valid_len_mask]
|
|
edge_normals = edge_normals[valid_len_mask]
|
|
|
|
if len(edges_3d) == 0:
|
|
return np.empty((0, 2, 2), dtype=np.float64)
|
|
|
|
# Canonical undirected edge sorting in 3D (rounded to 4 decimal places)
|
|
rounded_3d = np.round(edges_3d, decimals=4)
|
|
pA = rounded_3d[:, 0, :]
|
|
pB = rounded_3d[:, 1, :]
|
|
swap_mask = (pA[:, 0] > pB[:, 0]) | (
|
|
(pA[:, 0] == pB[:, 0]) & (pA[:, 1] > pB[:, 1])
|
|
) | (
|
|
(pA[:, 0] == pB[:, 0]) & (pA[:, 1] == pB[:, 1]) & (pA[:, 2] > pB[:, 2])
|
|
)
|
|
|
|
canonical_3d = rounded_3d.copy()
|
|
canonical_3d[swap_mask] = canonical_3d[swap_mask][:, ::-1, :]
|
|
|
|
canonical_2d = edges_2d.copy()
|
|
canonical_2d[swap_mask] = canonical_2d[swap_mask][:, ::-1, :]
|
|
|
|
# Unique 3D edge identification using NumPy structured view
|
|
flat_3d = canonical_3d.reshape(len(canonical_3d), 6)
|
|
view_bytes = np.ascontiguousarray(flat_3d).view(np.dtype((np.void, 48))).ravel()
|
|
unique_keys, first_indices, inverse_indices, counts = np.unique(
|
|
view_bytes, return_index=True, return_inverse=True, return_counts=True
|
|
)
|
|
|
|
num_unique = len(unique_keys)
|
|
|
|
max_cat = np.full(num_unique, -1, dtype=int)
|
|
min_cat = np.full(num_unique, 999, dtype=int)
|
|
np.maximum.at(max_cat, inverse_indices, edge_category)
|
|
np.minimum.at(min_cat, inverse_indices, edge_category)
|
|
|
|
# Rule 1: Silhouette edge - adjacent faces have different categories
|
|
rule1_unique = (counts >= 2) & (max_cat != min_cat)
|
|
|
|
# Rule 2: Boundary edge (owned by 1 face)
|
|
rule2_unique = counts == 1
|
|
|
|
# Rule 3: Crease edge (owned by 2+ faces with normal angle > max_angle_deg)
|
|
cos_threshold = math.cos(math.radians(max_angle_deg))
|
|
rule3_unique = np.zeros(num_unique, dtype=bool)
|
|
|
|
multi_edge_mask = counts >= 2
|
|
if np.any(multi_edge_mask):
|
|
sort_by_edge = np.argsort(inverse_indices)
|
|
sorted_edge_ids = inverse_indices[sort_by_edge]
|
|
sorted_normals = edge_normals[sort_by_edge]
|
|
|
|
# Vectorized pair matching for adjacent faces sharing the same edge ID
|
|
match = sorted_edge_ids[:-1] == sorted_edge_ids[1:]
|
|
if np.any(match):
|
|
n1 = sorted_normals[:-1][match]
|
|
n2 = sorted_normals[1:][match]
|
|
e_ids = sorted_edge_ids[:-1][match]
|
|
|
|
dot_prods = np.sum(n1 * n2, axis=1)
|
|
is_crease = dot_prods < (cos_threshold - 1e-6)
|
|
rule3_unique[e_ids[is_crease]] = True
|
|
|
|
marked_unique_mask = rule1_unique | rule2_unique | rule3_unique
|
|
marked_first_indices = first_indices[marked_unique_mask]
|
|
|
|
return canonical_2d[marked_first_indices]
|
|
|
|
|
|
def extract_boundary_edges(mesh_data: Any) -> NDArray[np.float64]:
|
|
"""
|
|
Extracts 2D edge segments owned by only 1 face (single-face boundary edges).
|
|
"""
|
|
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
|
|
|
|
all_tris_3d = []
|
|
|
|
def process_item(item: Any):
|
|
if item is None:
|
|
return
|
|
elif isinstance(item, (list, tuple)):
|
|
for sub in item:
|
|
process_item(sub)
|
|
elif isinstance(item, SlicingGroup):
|
|
for coll in item.collections.values():
|
|
process_item(coll)
|
|
elif isinstance(item, SlicingCollection):
|
|
for so in item.objects:
|
|
process_item(so)
|
|
elif isinstance(item, SlicingObject):
|
|
if item.mesh.size > 0:
|
|
all_tris_3d.append(item.mesh[:, :3, :3])
|
|
|
|
process_item(mesh_data)
|
|
|
|
if not all_tris_3d:
|
|
return np.empty((0, 2, 2), dtype=np.float64)
|
|
|
|
tris_3d = np.concatenate(all_tris_3d, axis=0)
|
|
if len(tris_3d) == 0:
|
|
return np.empty((0, 2, 2), dtype=np.float64)
|
|
|
|
verts_2d = tris_3d[:, :, :2]
|
|
e0_3d = tris_3d[:, [0, 1], :]
|
|
e1_3d = tris_3d[:, [1, 2], :]
|
|
e2_3d = tris_3d[:, [2, 0], :]
|
|
edges_3d = np.concatenate([e0_3d, e1_3d, e2_3d], axis=0)
|
|
|
|
e0_2d = verts_2d[:, [0, 1], :]
|
|
e1_2d = verts_2d[:, [1, 2], :]
|
|
e2_2d = verts_2d[:, [2, 0], :]
|
|
edges_2d = np.concatenate([e0_2d, e1_2d, e2_2d], axis=0)
|
|
|
|
dists_sq_2d = np.sum((edges_2d[:, 0, :] - edges_2d[:, 1, :]) ** 2, axis=1)
|
|
valid_mask = dists_sq_2d >= 1e-8
|
|
|
|
edges_3d = edges_3d[valid_mask]
|
|
edges_2d = edges_2d[valid_mask]
|
|
|
|
if len(edges_3d) == 0:
|
|
return np.empty((0, 2, 2), dtype=np.float64)
|
|
|
|
rounded_3d = np.round(edges_3d, decimals=4)
|
|
pA = rounded_3d[:, 0, :]
|
|
pB = rounded_3d[:, 1, :]
|
|
swap_mask = (pA[:, 0] > pB[:, 0]) | (
|
|
(pA[:, 0] == pB[:, 0]) & (pA[:, 1] > pB[:, 1])
|
|
) | (
|
|
(pA[:, 0] == pB[:, 0]) & (pA[:, 1] == pB[:, 1]) & (pA[:, 2] > pB[:, 2])
|
|
)
|
|
|
|
canonical_3d = rounded_3d.copy()
|
|
canonical_3d[swap_mask] = canonical_3d[swap_mask][:, ::-1, :]
|
|
|
|
canonical_2d = edges_2d.copy()
|
|
canonical_2d[swap_mask] = canonical_2d[swap_mask][:, ::-1, :]
|
|
|
|
flat_3d = canonical_3d.reshape(len(canonical_3d), 6)
|
|
view_bytes = np.ascontiguousarray(flat_3d).view(np.dtype((np.void, 48))).ravel()
|
|
unique_keys, first_indices, inverse_indices, counts = np.unique(
|
|
view_bytes, return_index=True, return_inverse=True, return_counts=True
|
|
)
|
|
|
|
boundary_mask = counts == 1
|
|
return canonical_2d[first_indices[boundary_mask]]
|
|
|
|
|
|
def extract_crease_edges(mesh_data: Any, max_angle_deg: float = 30.0) -> NDArray[np.float64]:
|
|
"""
|
|
Extracts 2D edge segments owned by 2 or more faces where the 3D dihedral angle between face normals > max_angle_deg.
|
|
"""
|
|
return extract_lineart_edges(mesh_data, max_angle_deg=max_angle_deg)
|
|
|
|
|
|
def extract_silhouette_contour_edges(mesh_data: Any) -> NDArray[np.float64]:
|
|
"""
|
|
Extracts top-view silhouette contour edges using 3D face normals.
|
|
Filters front-facing faces (normal_z > epsilon) and extracts edges owned by exactly 1 front-facing face.
|
|
"""
|
|
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
|
|
|
|
all_tris_3d = []
|
|
all_normals = []
|
|
|
|
def process_item(item: Any):
|
|
if item is None:
|
|
return
|
|
elif isinstance(item, (list, tuple)):
|
|
for sub in item:
|
|
process_item(sub)
|
|
elif isinstance(item, SlicingGroup):
|
|
for coll in item.collections.values():
|
|
process_item(coll)
|
|
elif isinstance(item, SlicingCollection):
|
|
for so in item.objects:
|
|
process_item(so)
|
|
elif isinstance(item, SlicingObject):
|
|
if item.mesh.size > 0:
|
|
verts_3d = item.mesh[:, :3, :3]
|
|
if item.mesh.shape[1] >= 4:
|
|
normals = item.mesh[:, 3, :]
|
|
else:
|
|
v0_3d = verts_3d[:, 0, :]
|
|
v1_3d = verts_3d[:, 1, :]
|
|
v2_3d = verts_3d[:, 2, :]
|
|
normals = np.cross(v1_3d - v0_3d, v2_3d - v0_3d)
|
|
|
|
norms_len = np.linalg.norm(normals, axis=1, keepdims=True)
|
|
norms_len = np.where(norms_len == 0, 1.0, norms_len)
|
|
normals_unit = normals / norms_len
|
|
|
|
zero_norm_mask = (normals[:, 0] == 0) & (normals[:, 1] == 0) & (normals[:, 2] == 0)
|
|
if np.any(zero_norm_mask):
|
|
computed_normals = np.cross(v1_3d - v0_3d, v2_3d - v0_3d)
|
|
cn_len = np.linalg.norm(computed_normals, axis=1, keepdims=True)
|
|
cn_len = np.where(cn_len == 0, 1.0, cn_len)
|
|
normals_unit[zero_norm_mask] = (computed_normals / cn_len)[zero_norm_mask]
|
|
|
|
all_tris_3d.append(verts_3d)
|
|
all_normals.append(normals_unit)
|
|
|
|
process_item(mesh_data)
|
|
|
|
if not all_tris_3d:
|
|
return np.empty((0, 2, 2), dtype=np.float64)
|
|
|
|
tris_3d = np.concatenate(all_tris_3d, axis=0) # (N, 3, 3)
|
|
normals_unit = np.concatenate(all_normals, axis=0) # (N, 3)
|
|
num_tris = len(tris_3d)
|
|
|
|
if num_tris == 0:
|
|
return np.empty((0, 2, 2), dtype=np.float64)
|
|
|
|
# Filter for top-facing faces (normal_z > epsilon).
|
|
# Back-facing (normal_z < -epsilon) and vertical (|normal_z| <= epsilon) faces are masked out.
|
|
normal_z = normals_unit[:, 2]
|
|
epsilon = 1e-3
|
|
front_mask = normal_z > epsilon
|
|
|
|
tris_3d = tris_3d[front_mask]
|
|
if len(tris_3d) == 0:
|
|
return np.empty((0, 2, 2), dtype=np.float64)
|
|
|
|
# Extract 3 edges per front-facing triangle
|
|
e0_3d = tris_3d[:, [0, 1], :]
|
|
e1_3d = tris_3d[:, [1, 2], :]
|
|
e2_3d = tris_3d[:, [2, 0], :]
|
|
edges_3d = np.concatenate([e0_3d, e1_3d, e2_3d], axis=0) # (3M, 2, 3)
|
|
|
|
verts_2d = tris_3d[:, :, :2] # (M, 3, 2)
|
|
e0_2d = verts_2d[:, [0, 1], :]
|
|
e1_2d = verts_2d[:, [1, 2], :]
|
|
e2_2d = verts_2d[:, [2, 0], :]
|
|
edges_2d = np.concatenate([e0_2d, e1_2d, e2_2d], axis=0) # (3M, 2, 2)
|
|
|
|
# Filter out near zero-length 2D edges
|
|
dists_sq_2d = np.sum((edges_2d[:, 0, :] - edges_2d[:, 1, :]) ** 2, axis=1)
|
|
valid_len_mask = dists_sq_2d >= 1e-8
|
|
|
|
edges_3d = edges_3d[valid_len_mask]
|
|
edges_2d = edges_2d[valid_len_mask]
|
|
|
|
if len(edges_3d) == 0:
|
|
return np.empty((0, 2, 2), dtype=np.float64)
|
|
|
|
# Canonical undirected edge sorting in 3D
|
|
rounded_3d = np.round(edges_3d, decimals=4)
|
|
pA = rounded_3d[:, 0, :]
|
|
pB = rounded_3d[:, 1, :]
|
|
swap_mask = (pA[:, 0] > pB[:, 0]) | (
|
|
(pA[:, 0] == pB[:, 0]) & (pA[:, 1] > pB[:, 1])
|
|
) | (
|
|
(pA[:, 0] == pB[:, 0]) & (pA[:, 1] == pB[:, 1]) & (pA[:, 2] > pB[:, 2])
|
|
)
|
|
|
|
canonical_3d = rounded_3d.copy()
|
|
canonical_3d[swap_mask] = canonical_3d[swap_mask][:, ::-1, :]
|
|
|
|
canonical_2d = edges_2d.copy()
|
|
canonical_2d[swap_mask] = canonical_2d[swap_mask][:, ::-1, :]
|
|
|
|
# Unique 3D edge identification
|
|
flat_3d = canonical_3d.reshape(len(canonical_3d), 6)
|
|
view_bytes = np.ascontiguousarray(flat_3d).view(np.dtype((np.void, 48))).ravel()
|
|
_, first_indices, counts = np.unique(
|
|
view_bytes, return_index=True, return_counts=True
|
|
)
|
|
|
|
# Boundary edges of the front-facing surface have count == 1
|
|
contour_edges = canonical_2d[first_indices[counts == 1]]
|
|
return deduplicate_2d_edges(contour_edges)
|
|
|
|
|
|
def extract_outer_contour_edges(mesh_data: Any) -> NDArray[np.float64]:
|
|
"""
|
|
Extracts top-view contour edges using backfacing CW vs CCW triangle orientation.
|
|
"""
|
|
return extract_silhouette_contour_edges(mesh_data)
|