wf lineart
This commit is contained in:
parent
9d2d948d89
commit
6f21e407ac
4 changed files with 410 additions and 52 deletions
|
|
@ -357,6 +357,17 @@ class SLICENODE_WireframeToPdf(ExecSliceNode):
|
|||
bl_label = "Wireframe to PDF"
|
||||
button_label = "Generate PDF"
|
||||
|
||||
style: NfEnumProperty(
|
||||
name="Style",
|
||||
items=[
|
||||
('WIREFRAME', "Wireframe", "All projected 3D wireframe edges"),
|
||||
('LINEART', "Lineart", "Silhouette, boundary, and crease edges above threshold angle"),
|
||||
('OUTER_CONTOUR', "Outer Contour", "Silhouette outline boundary"),
|
||||
],
|
||||
default='WIREFRAME'
|
||||
) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
crease_angle: NfFloatProperty(name="Crease Angle (°)", default=30.0, min=0.0, max=180.0) # pyright: ignore[reportInvalidTypeForm]
|
||||
line_width: NfFloatProperty(name="Line Width (mm)", default=0.5, min=.0) # pyright: ignore[reportInvalidTypeForm]
|
||||
margin: NfFloatProperty(name="Margin (mm)", default=10.0, min=.0) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
|
|
@ -385,6 +396,9 @@ class SLICENODE_WireframeToPdf(ExecSliceNode):
|
|||
|
||||
def draw_buttons(self, context, layout):
|
||||
super().draw_buttons(context, layout)
|
||||
layout.prop(self, "style")
|
||||
if self.style == 'LINEART':
|
||||
layout.prop(self, "crease_angle")
|
||||
layout.prop(self, "line_width")
|
||||
layout.prop(self, "margin")
|
||||
layout.prop(self, "bg_color")
|
||||
|
|
@ -437,10 +451,13 @@ class SLICENODE_WireframeToPdf(ExecSliceNode):
|
|||
export_wireframe_to_pdf(
|
||||
edges_2d=edges_2d,
|
||||
filepath=filepath,
|
||||
style=self.style,
|
||||
crease_angle=self.crease_angle,
|
||||
margin_mm=self.margin,
|
||||
line_width_mm=self.line_width,
|
||||
stroke_color=(float(self.wire_color[0]), float(self.wire_color[1]), float(self.wire_color[2])),
|
||||
bg_color=(float(self.bg_color[0]), float(self.bg_color[1]), float(self.bg_color[2]))
|
||||
bg_color=(float(self.bg_color[0]), float(self.bg_color[1]), float(self.bg_color[2])),
|
||||
raw_geometry=geom
|
||||
)
|
||||
if operator: operator.report({'INFO'}, f"Wireframe PDF saved to {filepath}")
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import os
|
|||
import math
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
import shapely
|
||||
from shapely.geometry import Polygon, MultiPolygon
|
||||
|
||||
MM_TO_PT = 72.0 / 25.4 # 1 mm = 2.8346456692913386 PostScript points
|
||||
|
||||
|
|
@ -80,20 +82,290 @@ def extract_unique_top_view_edges(mesh_data: Any) -> NDArray[np.float64]:
|
|||
return ordered[unique_indices]
|
||||
|
||||
|
||||
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: shared between at least one CCW face and at least one CW face in 2D top view.
|
||||
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)
|
||||
normals = item.mesh[:, 3, :] # (N, 3)
|
||||
|
||||
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)
|
||||
|
||||
# 2D orientation per triangle
|
||||
v0_2d = verts_2d[:, 0, :]
|
||||
v1_2d = verts_2d[:, 1, :]
|
||||
v2_2d = verts_2d[:, 2, :]
|
||||
cross_2d = (v1_2d[:, 0] - v0_2d[:, 0]) * (v2_2d[:, 1] - v0_2d[:, 1]) - (v1_2d[:, 1] - v0_2d[:, 1]) * (v2_2d[:, 0] - v0_2d[:, 0])
|
||||
is_ccw = cross_2d > 0
|
||||
is_cw = cross_2d < 0
|
||||
|
||||
# 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_ccw = np.tile(is_ccw, 3) # (3N,)
|
||||
edge_cw = np.tile(is_cw, 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_ccw = edge_ccw[valid_len_mask]
|
||||
edge_cw = edge_cw[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)
|
||||
|
||||
# Rule 1: Silhouette edge (has CCW and CW face)
|
||||
has_ccw = np.bincount(inverse_indices, weights=edge_ccw.astype(int), minlength=num_unique) > 0
|
||||
has_cw = np.bincount(inverse_indices, weights=edge_cw.astype(int), minlength=num_unique) > 0
|
||||
rule1_unique = has_ccw & has_cw
|
||||
|
||||
# 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]
|
||||
|
||||
splits = np.where(sorted_edge_ids[:-1] != sorted_edge_ids[1:])[0] + 1
|
||||
edge_groups = np.split(sorted_normals, splits)
|
||||
unique_group_ids = np.split(sorted_edge_ids, splits)
|
||||
|
||||
for grp_normals, grp_ids in zip(edge_groups, unique_group_ids):
|
||||
edge_id = grp_ids[0]
|
||||
if len(grp_normals) >= 2:
|
||||
dots = grp_normals @ grp_normals.T
|
||||
if np.any(dots < cos_threshold - 1e-6):
|
||||
rule3_unique[edge_id] = 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_outer_contour_edges(mesh_data: Any) -> NDArray[np.float64]:
|
||||
"""
|
||||
Extracts top-view outer boundary / silhouette contour line segments using Shapely 2D polygon union.
|
||||
"""
|
||||
from ..infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
|
||||
|
||||
all_tris_2d = []
|
||||
|
||||
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)
|
||||
verts_2d = verts_3d[:, :, :2] # (N, 3, 2)
|
||||
all_tris_2d.append(verts_2d)
|
||||
|
||||
process_item(mesh_data)
|
||||
|
||||
if not all_tris_2d:
|
||||
return np.empty((0, 2, 2), dtype=np.float64)
|
||||
|
||||
tris_2d = np.concatenate(all_tris_2d, axis=0)
|
||||
if len(tris_2d) == 0:
|
||||
return np.empty((0, 2, 2), dtype=np.float64)
|
||||
|
||||
polys = []
|
||||
for tri in tris_2d:
|
||||
p = Polygon(tri)
|
||||
if p.is_valid and p.area > 1e-6:
|
||||
polys.append(p)
|
||||
|
||||
if not polys:
|
||||
return np.empty((0, 2, 2), dtype=np.float64)
|
||||
|
||||
try:
|
||||
union_poly = shapely.union_all(polys)
|
||||
except Exception:
|
||||
return np.empty((0, 2, 2), dtype=np.float64)
|
||||
|
||||
boundary_edges = []
|
||||
|
||||
def process_geom(geom):
|
||||
if geom.is_empty:
|
||||
return
|
||||
if isinstance(geom, Polygon):
|
||||
coords = np.array(geom.exterior.coords)
|
||||
for i in range(len(coords) - 1):
|
||||
boundary_edges.append([coords[i], coords[i + 1]])
|
||||
for interior in geom.interiors:
|
||||
icoords = np.array(interior.coords)
|
||||
for i in range(len(icoords) - 1):
|
||||
boundary_edges.append([icoords[i], icoords[i + 1]])
|
||||
elif isinstance(geom, MultiPolygon):
|
||||
for sub in geom.geoms:
|
||||
process_geom(sub)
|
||||
elif hasattr(geom, 'geoms'):
|
||||
for sub in geom.geoms:
|
||||
process_geom(sub)
|
||||
|
||||
process_geom(union_poly)
|
||||
if not boundary_edges:
|
||||
return np.empty((0, 2, 2), dtype=np.float64)
|
||||
|
||||
return np.array(boundary_edges, dtype=np.float64)
|
||||
|
||||
|
||||
def _format_pdf_lines(lines_arr: NDArray[np.float64]) -> list[str]:
|
||||
"""
|
||||
Vectorized formatting of 2D line segments to PDF operator strings.
|
||||
lines_arr shape: (N, 2, 2)
|
||||
"""
|
||||
if len(lines_arr) == 0:
|
||||
return []
|
||||
flat = lines_arr.reshape(-1, 4)
|
||||
return [f"{row[0]:.2f} {row[1]:.2f} m {row[2]:.2f} {row[3]:.2f} l" for row in flat]
|
||||
|
||||
|
||||
def export_wireframe_to_pdf(
|
||||
edges_2d: NDArray[np.float64],
|
||||
filepath: str,
|
||||
style: str = 'WIREFRAME',
|
||||
crease_angle: float = 30.0,
|
||||
margin_mm: float = 10.0,
|
||||
line_width_mm: float = 0.5,
|
||||
stroke_color: tuple[float, float, float] = (0.0, 0.0, 0.0),
|
||||
bg_color: tuple[float, float, float] = (1.0, 1.0, 1.0)
|
||||
bg_color: tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||
raw_geometry: Any = None
|
||||
) -> None:
|
||||
"""
|
||||
Exports 2D wireframe line segments to a 1:1 true physical scale vector PDF file.
|
||||
- PDF page dimensions automatically match the physical size of the geometry + grid + margin.
|
||||
- line_width_mm is specified in physical millimeters and converted to PDF points (1 mm = 72/25.4 pt).
|
||||
Exports 2D vector geometry to a 1:1 true physical scale vector PDF file.
|
||||
Styles:
|
||||
- WIREFRAME: All projected 3D wireframe edges
|
||||
- LINEART: Silhouette, boundary, and crease edges above threshold angle
|
||||
- OUTER_CONTOUR: Silhouette boundary contour outline
|
||||
"""
|
||||
if len(edges_2d) > 0:
|
||||
if style == 'LINEART' and raw_geometry is not None:
|
||||
lineart_edges = extract_lineart_edges(raw_geometry, max_angle_deg=crease_angle)
|
||||
if len(lineart_edges) > 0:
|
||||
edges_to_draw = lineart_edges
|
||||
else:
|
||||
edges_to_draw = edges_2d
|
||||
elif style == 'OUTER_CONTOUR' and raw_geometry is not None:
|
||||
contour_edges = extract_outer_contour_edges(raw_geometry)
|
||||
if len(contour_edges) > 0:
|
||||
edges_to_draw = contour_edges
|
||||
else:
|
||||
edges_to_draw = edges_2d
|
||||
else:
|
||||
edges_to_draw = edges_2d
|
||||
|
||||
if len(edges_to_draw) > 0:
|
||||
min_x = float(edges_to_draw[:, :, 0].min())
|
||||
max_x = float(edges_to_draw[:, :, 0].max())
|
||||
min_y = float(edges_to_draw[:, :, 1].min())
|
||||
max_y = float(edges_to_draw[:, :, 1].max())
|
||||
elif len(edges_2d) > 0:
|
||||
min_x = float(edges_2d[:, :, 0].min())
|
||||
max_x = float(edges_2d[:, :, 0].max())
|
||||
min_y = float(edges_2d[:, :, 1].min())
|
||||
|
|
@ -114,7 +386,7 @@ def export_wireframe_to_pdf(
|
|||
|
||||
minor_step = main_step / 10.0
|
||||
|
||||
if len(edges_2d) > 0:
|
||||
if len(edges_to_draw) > 0 or len(edges_2d) > 0:
|
||||
grid_min_x = math.floor(min_x / main_step) * main_step
|
||||
grid_max_x = math.ceil(max_x / main_step) * main_step
|
||||
grid_min_y = math.floor(min_y / main_step) * main_step
|
||||
|
|
@ -137,87 +409,99 @@ def export_wireframe_to_pdf(
|
|||
page_w_pt = w_total_mm * MM_TO_PT
|
||||
page_h_pt = h_total_mm * MM_TO_PT
|
||||
|
||||
# Convert line width from millimeters to PostScript points
|
||||
line_width_pt = line_width_mm * MM_TO_PT
|
||||
|
||||
def to_pdf_pt(pt_mm):
|
||||
x_pt = (pt_mm[0] - grid_min_x + margin_mm) * MM_TO_PT
|
||||
y_pt = (pt_mm[1] - grid_min_y + margin_mm) * MM_TO_PT
|
||||
return x_pt, y_pt
|
||||
|
||||
minor_lines: list[tuple[tuple[float, float], tuple[float, float]]] = []
|
||||
main_lines: list[tuple[tuple[float, float], tuple[float, float]]] = []
|
||||
x_pt = (pt_mm[:, 0] - grid_min_x + margin_mm) * MM_TO_PT
|
||||
y_pt = (pt_mm[:, 1] - grid_min_y + margin_mm) * MM_TO_PT
|
||||
return np.column_stack([x_pt, y_pt])
|
||||
|
||||
max_grid_steps = 2000
|
||||
|
||||
# Vertical grid lines
|
||||
# Vectorized vertical grid lines
|
||||
k_x_start = int(np.round(grid_min_x / minor_step))
|
||||
k_x_end = int(np.round(grid_max_x / minor_step))
|
||||
if abs(k_x_end - k_x_start) <= max_grid_steps:
|
||||
for k_x in range(k_x_start, k_x_end + 1):
|
||||
gx = k_x * minor_step
|
||||
p0 = to_pdf_pt((gx, grid_min_y))
|
||||
p1 = to_pdf_pt((gx, grid_max_y))
|
||||
if k_x % 10 == 0:
|
||||
main_lines.append((p0, p1))
|
||||
else:
|
||||
minor_lines.append((p0, p1))
|
||||
k_x = np.arange(k_x_start, k_x_end + 1)
|
||||
|
||||
# Horizontal grid lines
|
||||
if len(k_x) <= max_grid_steps:
|
||||
gx = k_x * minor_step
|
||||
pts0_v = np.column_stack([gx, np.full_like(gx, grid_min_y)])
|
||||
pts1_v = np.column_stack([gx, np.full_like(gx, grid_max_y)])
|
||||
|
||||
p0_v = to_pdf_pt(pts0_v)
|
||||
p1_v = to_pdf_pt(pts1_v)
|
||||
is_main_x = (k_x % 10 == 0)
|
||||
|
||||
main_v_lines = np.column_stack([p0_v[is_main_x], p1_v[is_main_x]]).reshape(-1, 2, 2)
|
||||
minor_v_lines = np.column_stack([p0_v[~is_main_x], p1_v[~is_main_x]]).reshape(-1, 2, 2)
|
||||
else:
|
||||
main_v_lines = np.empty((0, 2, 2))
|
||||
minor_v_lines = np.empty((0, 2, 2))
|
||||
|
||||
# Vectorized horizontal grid lines
|
||||
k_y_start = int(np.round(grid_min_y / minor_step))
|
||||
k_y_end = int(np.round(grid_max_y / minor_step))
|
||||
if abs(k_y_end - k_y_start) <= max_grid_steps:
|
||||
for k_y in range(k_y_start, k_y_end + 1):
|
||||
k_y = np.arange(k_y_start, k_y_end + 1)
|
||||
|
||||
if len(k_y) <= max_grid_steps:
|
||||
gy = k_y * minor_step
|
||||
p0 = to_pdf_pt((grid_min_x, gy))
|
||||
p1 = to_pdf_pt((grid_max_x, gy))
|
||||
if k_y % 10 == 0:
|
||||
main_lines.append((p0, p1))
|
||||
pts0_h = np.column_stack([np.full_like(gy, grid_min_x), gy])
|
||||
pts1_h = np.column_stack([np.full_like(gy, grid_max_x), gy])
|
||||
|
||||
p0_h = to_pdf_pt(pts0_h)
|
||||
p1_h = to_pdf_pt(pts1_h)
|
||||
is_main_y = (k_y % 10 == 0)
|
||||
|
||||
main_h_lines = np.column_stack([p0_h[is_main_y], p1_h[is_main_y]]).reshape(-1, 2, 2)
|
||||
minor_h_lines = np.column_stack([p0_h[~is_main_y], p1_h[~is_main_y]]).reshape(-1, 2, 2)
|
||||
else:
|
||||
minor_lines.append((p0, p1))
|
||||
main_h_lines = np.empty((0, 2, 2))
|
||||
minor_h_lines = np.empty((0, 2, 2))
|
||||
|
||||
main_lines_arr = np.concatenate([main_v_lines, main_h_lines], axis=0) if len(main_v_lines) or len(main_h_lines) else np.empty((0, 2, 2))
|
||||
minor_lines_arr = np.concatenate([minor_v_lines, minor_h_lines], axis=0) if len(minor_v_lines) or len(minor_h_lines) else np.empty((0, 2, 2))
|
||||
|
||||
stream_cmds: list[str] = []
|
||||
|
||||
# 1. Solid background fill
|
||||
br, bg, bb = bg_color
|
||||
sr, sg, sb = stroke_color
|
||||
stream_cmds.append(f"{br:.3f} {bg:.3f} {bb:.3f} rg")
|
||||
stream_cmds.append(f"0 0 {page_w_pt:.2f} {page_h_pt:.2f} re f")
|
||||
|
||||
# 2. Wireframe stroke color
|
||||
sr, sg, sb = stroke_color
|
||||
stream_cmds.append(f"{sr:.3f} {sg:.3f} {sb:.3f} RG")
|
||||
|
||||
# 3. Minor Grid Lines (25% Opacity)
|
||||
if minor_lines:
|
||||
if len(minor_lines_arr) > 0:
|
||||
stream_cmds.append("/GS25 gs")
|
||||
minor_grid_pt = min(0.3 * MM_TO_PT, line_width_pt)
|
||||
stream_cmds.append(f"{minor_grid_pt:.2f} w")
|
||||
lines_str = [f"{p0[0]:.2f} {p0[1]:.2f} m {p1[0]:.2f} {p1[1]:.2f} l" for p0, p1 in minor_lines]
|
||||
lines_str = _format_pdf_lines(minor_lines_arr)
|
||||
for start_idx in range(0, len(lines_str), 5000):
|
||||
stream_cmds.extend(lines_str[start_idx:start_idx + 5000])
|
||||
stream_cmds.append("S")
|
||||
|
||||
# 4. Main Grid Lines (50% Opacity)
|
||||
if main_lines:
|
||||
if len(main_lines_arr) > 0:
|
||||
stream_cmds.append("/GS50 gs")
|
||||
main_grid_pt = min(0.4 * MM_TO_PT, line_width_pt)
|
||||
stream_cmds.append(f"{main_grid_pt:.2f} w")
|
||||
lines_str = [f"{p0[0]:.2f} {p0[1]:.2f} m {p1[0]:.2f} {p1[1]:.2f} l" for p0, p1 in main_lines]
|
||||
lines_str = _format_pdf_lines(main_lines_arr)
|
||||
for start_idx in range(0, len(lines_str), 5000):
|
||||
stream_cmds.extend(lines_str[start_idx:start_idx + 5000])
|
||||
stream_cmds.append("S")
|
||||
|
||||
# 5. Object Wireframe (100% Opacity, exact physical line_width_mm)
|
||||
if len(edges_2d) > 0:
|
||||
# 5. Object Lines (100% Opacity)
|
||||
if len(edges_to_draw) > 0:
|
||||
stream_cmds.append("/GS100 gs")
|
||||
stream_cmds.append(f"{line_width_pt:.2f} w")
|
||||
stream_cmds.append("1 j 1 J") # round join and cap
|
||||
|
||||
lines_str = []
|
||||
for i in range(len(edges_2d)):
|
||||
p0 = to_pdf_pt(edges_2d[i, 0])
|
||||
p1 = to_pdf_pt(edges_2d[i, 1])
|
||||
lines_str.append(f"{p0[0]:.2f} {p0[1]:.2f} m {p1[0]:.2f} {p1[1]:.2f} l")
|
||||
p0_pt = to_pdf_pt(edges_to_draw[:, 0, :])
|
||||
p1_pt = to_pdf_pt(edges_to_draw[:, 1, :])
|
||||
obj_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
|
||||
lines_str = _format_pdf_lines(obj_lines_arr)
|
||||
|
||||
for start_idx in range(0, len(lines_str), 5000):
|
||||
stream_cmds.extend(lines_str[start_idx:start_idx + 5000])
|
||||
|
|
|
|||
|
|
@ -115,10 +115,12 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
|
|||
- **Load Gcode**: Reads a G-code file from disk.
|
||||
- **Gcode to File**: Writes G-code string to a file on disk.
|
||||
- **Automatic Binary Conversion**: If the G-code contains the comment `; binary_gcode = 1`, the node automatically converts the output to Prusa's binary G-code format (`.bgcode`) using MeatPack encoding and Heatshrink compression.
|
||||
- **Wireframe to PDF**: Generates a 1:1 true-scale 2D top-view (+Z orthographic) wireframe PDF from a `SlicingGroup` or `SlicingCollection` with a custom grid and background color.
|
||||
- **Wireframe to PDF**: Generates a 1:1 true-scale 2D top-view (+Z orthographic) vector PDF from a `SlicingGroup` or `SlicingCollection` with a custom grid and background color.
|
||||
- **Geometry**: Input socket accepting a `SlicingGroup`, `SlicingCollection`, or `SlicingObject`.
|
||||
- **Folder**: Target folder or PDF file path on disk.
|
||||
- **Line Width (mm)**: Stroke width in millimeters for the wireframe lines.
|
||||
- **Style**: Visualization style (Wireframe, Lineart, Outer Contour).
|
||||
- **Crease Angle (°)**: Maximum dihedral angle threshold (default 30°) for marking feature crease lines when in Lineart mode.
|
||||
- **Line Width (mm)**: Stroke width in millimeters for the exported lines.
|
||||
- **Margin (mm)**: Margin around the geometry grid in mm.
|
||||
- **Background**: Color picker for the canvas background.
|
||||
- **Wireframe**: Color picker for the wireframe lines and grid.
|
||||
|
|
|
|||
|
|
@ -34,7 +34,12 @@ sys.modules['bpy.props'] = MagicMock()
|
|||
sys.modules['bpy.utils'] = MagicMock()
|
||||
|
||||
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
|
||||
from NodeSlicer.core.pdf_exporter import extract_unique_top_view_edges, export_wireframe_to_pdf, MM_TO_PT
|
||||
from NodeSlicer.core.pdf_exporter import (
|
||||
extract_unique_top_view_edges,
|
||||
extract_lineart_edges,
|
||||
export_wireframe_to_pdf,
|
||||
MM_TO_PT
|
||||
)
|
||||
|
||||
|
||||
class TestPdfExporter(unittest.TestCase):
|
||||
|
|
@ -89,8 +94,32 @@ class TestPdfExporter(unittest.TestCase):
|
|||
# Vertical edge (0,0,0)-(0,0,10) becomes 2D length 0 and should be filtered out
|
||||
self.assertTrue(len(edges) < 3)
|
||||
|
||||
def test_extract_lineart_edges(self):
|
||||
so = SlicingObject()
|
||||
# Create 2 triangles sharing an edge at 90 degree angle (crease)
|
||||
tri1 = np.array([
|
||||
[0.0, 0.0, 0.0],
|
||||
[10.0, 0.0, 0.0],
|
||||
[0.0, 10.0, 0.0],
|
||||
[0.0, 0.0, 1.0] # Normal up
|
||||
], dtype=np.float64)
|
||||
tri2 = np.array([
|
||||
[10.0, 0.0, 0.0],
|
||||
[10.0, 0.0, 10.0],
|
||||
[0.0, 10.0, 0.0],
|
||||
[1.0, 0.0, 0.0] # Normal right (90 deg to tri1)
|
||||
], dtype=np.float64)
|
||||
so.mesh = np.array([tri1, tri2])
|
||||
|
||||
# At max_angle_deg=30, the 90 deg crease edge should be marked
|
||||
lineart_30 = extract_lineart_edges(so, max_angle_deg=30.0)
|
||||
self.assertTrue(len(lineart_30) > 0)
|
||||
|
||||
# At max_angle_deg=100, crease edge (90 deg) is below threshold, but boundary edges remain
|
||||
lineart_100 = extract_lineart_edges(so, max_angle_deg=100.0)
|
||||
self.assertTrue(len(lineart_100) > 0)
|
||||
|
||||
def test_export_pdf_true_1_to_1_scale(self):
|
||||
# Setup 100mm x 100mm 2D square
|
||||
edges_2d = np.array([
|
||||
[[0.0, 0.0], [100.0, 0.0]],
|
||||
[[100.0, 0.0], [100.0, 100.0]],
|
||||
|
|
@ -99,8 +128,6 @@ class TestPdfExporter(unittest.TestCase):
|
|||
], dtype=np.float64)
|
||||
|
||||
margin_mm = 10.0
|
||||
# Main grid step for 100mm is 10mm. Bounds: [0, 100], [0, 100].
|
||||
# Total width = 100 + 2*10 = 120mm. Expected pt = 120 * MM_TO_PT = 340.16 pt.
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
pdf_path = os.path.join(tmpdir, "test_true_scale.pdf")
|
||||
|
|
@ -129,6 +156,34 @@ class TestPdfExporter(unittest.TestCase):
|
|||
self.assertIn(b"/GS100 gs", content)
|
||||
self.assertTrue(content.endswith(b"%%EOF\n"))
|
||||
|
||||
def test_export_pdf_visualization_styles(self):
|
||||
so = SlicingObject()
|
||||
tri = np.array([
|
||||
[0.0, 0.0, 0.0],
|
||||
[100.0, 0.0, 0.0],
|
||||
[0.0, 100.0, 0.0],
|
||||
[0.0, 0.0, 1.0]
|
||||
], dtype=np.float64)
|
||||
so.mesh = np.array([tri])
|
||||
|
||||
edges_2d = extract_unique_top_view_edges(so)
|
||||
|
||||
for style in ['WIREFRAME', 'LINEART', 'OUTER_CONTOUR']:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
pdf_path = os.path.join(tmpdir, f"test_{style}.pdf")
|
||||
export_wireframe_to_pdf(
|
||||
edges_2d,
|
||||
pdf_path,
|
||||
style=style,
|
||||
crease_angle=30.0,
|
||||
raw_geometry=so
|
||||
)
|
||||
self.assertTrue(os.path.exists(pdf_path))
|
||||
with open(pdf_path, "rb") as f:
|
||||
content = f.read()
|
||||
self.assertTrue(content.startswith(b"%PDF-1.4"))
|
||||
self.assertTrue(content.endswith(b"%%EOF\n"))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue