nodeslicer/core/pdf_exporter/geometry.py
2026-08-07 15:00:58 +03:00

755 lines
26 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,
z_level: float = 0.0,
) -> tuple[Any, Any]:
"""
Generates 3D grid line geometry sitting at local Z=z_level 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, z_level], [gx, grid_max_y, z_level]], 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, z_level], [grid_max_x, gy, z_level]], dtype=np.float64)
if k % 10 == 0:
main_paths.append(line)
else:
minor_paths.append(line)
return SlicePaths(minor_paths), SlicePaths(main_paths)
def generate_legend_slice_paths(
grid_min_x: float,
grid_min_y: float,
grid_max_x: float,
main_step: float,
minor_step: float,
line_width_pt: float,
font_size_pt: float,
z_level: float = 0.0,
) -> tuple[Any, list[dict[str, Any]]]:
"""
Generates 3D legend line geometry (axes, arrowheads, tick marks) sitting at local Z=z_level
as a SlicePaths object, alongside tick and label metadata for text positioning.
Returns: (legend_slice_paths, label_metadata_list)
"""
from ..slice import SlicePaths
from .sizing import LegendSizing, MM_TO_PT
leg_sizing = LegendSizing(main_step, minor_step, line_width_pt, font_size_pt)
x0 = grid_min_x
y0 = grid_min_y
x_grid_end_mm = min(grid_max_x, grid_min_x + 10.0 * main_step)
num_main_steps = max(1, int(np.round((x_grid_end_mm - grid_min_x) / main_step)))
arrow_len_mm = leg_sizing.arrow_len_pt / MM_TO_PT
arrow_half_w_mm = leg_sizing.arrow_half_w_pt / MM_TO_PT
label_margin_mm = leg_sizing.label_margin_h_pt / MM_TO_PT
x_arrow_base = x_grid_end_mm + leg_sizing.x_axis_extension_mm
x_arrow_tip = x_arrow_base + arrow_len_mm
y_arrow_base = y0 + leg_sizing.y_axis_height_mm
y_arrow_tip = y_arrow_base + arrow_len_mm
legend_paths: list[NDArray[np.float64]] = []
# 1. Main horizontal X-axis line & Arrowhead
legend_paths.append(np.array([[x0, y0, z_level], [x_arrow_tip, y0, z_level]], dtype=np.float64))
legend_paths.append(np.array([[x_arrow_base, y0 + arrow_half_w_mm, z_level], [x_arrow_tip, y0, z_level]], dtype=np.float64))
legend_paths.append(np.array([[x_arrow_tip, y0, z_level], [x_arrow_base, y0 - arrow_half_w_mm, z_level]], dtype=np.float64))
# 2. X-axis tick marks & label positions
label_meta: list[dict[str, Any]] = []
for k in range(num_main_steps + 1):
xk = x0 + k * main_step
tick_h = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm
legend_paths.append(np.array([[xk, y0, z_level], [xk, y0 + tick_h, z_level]], dtype=np.float64))
if k > 0:
val_mm = k * main_step
if main_step >= 1000.0:
val_num = val_mm / 1000.0
unit = " m" if k == num_main_steps else ""
tick_str = f"{val_num:g}{unit}"
else:
unit = " mm" if k == num_main_steps else ""
tick_str = f"{val_mm:g}{unit}"
label_meta.append({
"type": "tick",
"text": tick_str,
"local_pt": (xk, y0 + tick_h + label_margin_mm, z_level),
})
# 3. Main vertical Y-axis line & Arrowhead
legend_paths.append(np.array([[x0, y0, z_level], [x0, y_arrow_tip, z_level]], dtype=np.float64))
legend_paths.append(np.array([[x0 - arrow_half_w_mm, y_arrow_base, z_level], [x0, y_arrow_tip, z_level]], dtype=np.float64))
legend_paths.append(np.array([[x0, y_arrow_tip, z_level], [x0 + arrow_half_w_mm, y_arrow_base, z_level]], dtype=np.float64))
# 4. Axis labels meta
label_meta.append({
"type": "x_axis",
"text": "X",
"local_pt": (x_arrow_tip + label_margin_mm, y0, z_level),
})
label_meta.append({
"type": "y_axis",
"text": "Y",
"local_pt": (x0, y_arrow_tip + label_margin_mm, z_level),
})
return SlicePaths(legend_paths), label_meta
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)