This commit is contained in:
Nicolas Predella 2026-08-06 12:31:44 +03:00
parent 579af57fb8
commit 0c266dd8c7
4 changed files with 316 additions and 68 deletions

View file

@ -47,7 +47,6 @@ from .sa2_measures import (
from .sizing import MM_TO_PT
from .writer import (
export_mesh_to_pdf,
export_wireframe_to_pdf,
PDFElement,
PDFBackgroundElement,
PDFGridElement,
@ -62,7 +61,6 @@ from .writer import (
__all__ = [
# Main exporters & element classes
"export_mesh_to_pdf",
"export_wireframe_to_pdf",
"PDFElement",
"PDFBackgroundElement",
"PDFGridElement",

View file

@ -31,6 +31,169 @@ def deduplicate_2d_edges(edges_2d: NDArray[np.float64]) -> NDArray[np.float64]:
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.

View file

@ -24,6 +24,9 @@ from .geometry import (
extract_crease_edges,
extract_slice_paths_edges,
deduplicate_2d_edges,
transform_slice_paths,
get_first_object_rotation,
generate_grid_slice_paths,
)
from .sa2_measures import render_sa2_measures_pdf
from .sizing import (
@ -836,31 +839,80 @@ class export_mesh_to_pdf:
target_edges = self.edges_to_draw if len(self.edges_to_draw) > 0 else self.edges_2d
if len(target_edges) > 0:
pts_2d = target_edges.reshape(-1, 2)
min_x, max_x = float(pts_2d[:, 0].min()), float(pts_2d[:, 0].max())
min_y, max_y = float(pts_2d[:, 1].min()), float(pts_2d[:, 1].max())
else:
min_x = max_x = min_y = max_y = 0.0
pts_2d = np.empty((0, 2), dtype=np.float64)
w_geom = max_x - min_x
h_geom = max_y - min_y
# Extract 3D rotation of the first object
rot_vec = get_first_object_rotation(
self.slicing_objs, self.raw_geometry, self.slice_paths
)
r_arr = np.array(rot_vec, dtype=np.float64)
# Calculate rotation matrix R to project world points into local unrotated object space
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)
if len(pts_2d) > 0:
pts_3d_world = np.column_stack([pts_2d, np.zeros(len(pts_2d), dtype=np.float64)])
pts_local = pts_3d_world @ R
l_min_x, l_max_x = float(pts_local[:, 0].min()), float(pts_local[:, 0].max())
l_min_y, l_max_y = float(pts_local[:, 1].min()), float(pts_local[:, 1].max())
else:
l_min_x, l_max_x, l_min_y, l_max_y = -50.0, 50.0, -50.0, 50.0
w_geom = max(l_max_x - l_min_x, 1.0)
h_geom = max(l_max_y - l_min_y, 1.0)
self.main_step, self.minor_step = compute_grid_steps(w_geom, h_geom)
if len(self.edges_to_draw) > 0 or len(self.edges_2d) > 0:
pad_x = self.minor_step
pad_y = self.minor_step
local_min_x = math.floor((l_min_x - self.minor_step) / self.minor_step) * self.minor_step
local_max_x = math.ceil((l_max_x + self.minor_step) / self.minor_step) * self.minor_step
local_min_y = math.floor((l_min_y - self.minor_step) / self.minor_step) * self.minor_step
local_max_y = math.ceil((l_max_y + self.minor_step) / self.minor_step) * self.minor_step
grid_min_x = min_x - pad_x
grid_max_x = max_x + pad_x
grid_min_y = min_y - pad_y
grid_max_y = max_y + pad_y
# Generate 3D grid line SlicePaths sitting on local XY axis (Z=0)
minor_grid_sp, main_grid_sp = generate_grid_slice_paths(
local_min_x,
local_max_x,
local_min_y,
local_max_y,
self.minor_step,
)
self.grid_min_x = math.floor(grid_min_x / self.minor_step) * self.minor_step
self.grid_max_x = math.ceil(grid_max_x / self.minor_step) * self.minor_step
self.grid_min_y = math.floor(grid_min_y / self.minor_step) * self.minor_step
self.grid_max_y = math.ceil(grid_max_y / self.minor_step) * self.minor_step
# Rotate grid SlicePaths back into world space
rot_minor_sp = transform_slice_paths(minor_grid_sp, np.zeros(3), r_arr, np.ones(3))
rot_main_sp = transform_slice_paths(main_grid_sp, np.zeros(3), r_arr, np.ones(3))
# Extract 2D top-view edge segments from rotated SlicePaths
minor_edges_2d = extract_slice_paths_edges(rot_minor_sp)
main_edges_2d = extract_slice_paths_edges(rot_main_sp)
# Compute world-space canvas bounding box covering all objects and rotated grid lines
all_world_pts_list: list[NDArray[np.float64]] = []
if len(pts_2d) > 0:
all_world_pts_list.append(pts_2d)
if len(minor_edges_2d) > 0:
all_world_pts_list.append(minor_edges_2d.reshape(-1, 2))
if len(main_edges_2d) > 0:
all_world_pts_list.append(main_edges_2d.reshape(-1, 2))
if all_world_pts_list:
all_world_pts = np.vstack(all_world_pts_list)
w_min_x, w_max_x = float(all_world_pts[:, 0].min()), float(all_world_pts[:, 0].max())
w_min_y, w_max_y = float(all_world_pts[:, 1].min()), float(all_world_pts[:, 1].max())
else:
self.grid_min_x, self.grid_max_x = -50.0, 50.0
self.grid_min_y, self.grid_max_y = -50.0, 50.0
w_min_x, w_max_x, w_min_y, w_max_y = -50.0, 50.0, -50.0, 50.0
self.grid_min_x = math.floor(w_min_x / self.minor_step) * self.minor_step
self.grid_max_x = math.ceil(w_max_x / self.minor_step) * self.minor_step
self.grid_min_y = math.floor(w_min_y / self.minor_step) * self.minor_step
self.grid_max_y = math.ceil(w_max_y / self.minor_step) * self.minor_step
w_grid_mm = self.grid_max_x - self.grid_min_x
h_grid_mm = self.grid_max_y - self.grid_min_y
@ -903,50 +955,20 @@ class export_mesh_to_pdf:
self.page_w_pt = w_total_mm * MM_TO_PT
self.page_h_pt = h_total_mm * MM_TO_PT
max_grid_steps = 2000
# Vectorized vertical grid lines
k_x_start = int(np.round(self.grid_min_x / self.minor_step))
k_x_end = int(np.round(self.grid_max_x / self.minor_step))
k_x = np.arange(k_x_start, k_x_end + 1)
if len(k_x) <= max_grid_steps:
gx = k_x * self.minor_step
pts0_v = np.column_stack([gx, np.full_like(gx, self.grid_min_y)])
pts1_v = np.column_stack([gx, np.full_like(gx, self.grid_max_y)])
p0_v = self.to_pdf_pt(pts0_v)
p1_v = self.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)
# Convert coordinates to PDF point coordinates
if len(minor_edges_2d) > 0:
p0_pt = self.to_pdf_pt(minor_edges_2d[:, 0, :])
p1_pt = self.to_pdf_pt(minor_edges_2d[:, 1, :])
self.minor_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
else:
main_v_lines = np.empty((0, 2, 2))
minor_v_lines = np.empty((0, 2, 2))
self.minor_lines_arr = np.empty((0, 2, 2), dtype=np.float64)
# Vectorized horizontal grid lines
k_y_start = int(np.round(self.grid_min_y / self.minor_step))
k_y_end = int(np.round(self.grid_max_y / self.minor_step))
k_y = np.arange(k_y_start, k_y_end + 1)
if len(k_y) <= max_grid_steps:
gy = k_y * self.minor_step
pts0_h = np.column_stack([np.full_like(gy, self.grid_min_x), gy])
pts1_h = np.column_stack([np.full_like(gy, self.grid_max_x), gy])
p0_h = self.to_pdf_pt(pts0_h)
p1_h = self.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)
if len(main_edges_2d) > 0:
p0_pt = self.to_pdf_pt(main_edges_2d[:, 0, :])
p1_pt = self.to_pdf_pt(main_edges_2d[:, 1, :])
self.main_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
else:
main_h_lines = np.empty((0, 2, 2))
minor_h_lines = np.empty((0, 2, 2))
self.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))
self.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))
self.main_lines_arr = np.empty((0, 2, 2), dtype=np.float64)
def to_pdf_pt(self, pt_mm: NDArray[np.float64]) -> NDArray[np.float64]:
x_pt = (pt_mm[:, 0] - self.grid_min_x + self.total_margin_mm) * MM_TO_PT
@ -1044,7 +1066,3 @@ class export_mesh_to_pdf:
f.write(b"startxref\n")
f.write(f"{xref_offset}\n".encode("utf-8"))
f.write(b"%%EOF\n")
# Alias for backward compatibility
export_wireframe_to_pdf = export_mesh_to_pdf

View file

@ -48,7 +48,7 @@ from NodeSlicer.core.pdf_exporter import (
extract_lineart_edges,
extract_outer_contour_edges,
extract_slicing_object_features,
export_wireframe_to_pdf,
export_mesh_to_pdf,
export_mesh_to_pdf,
escape_pdf_text,
MM_TO_PT
@ -238,7 +238,7 @@ class TestPdfExporter(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "test_true_scale.pdf")
export_wireframe_to_pdf(
export_mesh_to_pdf(
edges_2d,
pdf_path,
margin_mm=margin_mm,
@ -288,7 +288,7 @@ class TestPdfExporter(unittest.TestCase):
for i, cfg in enumerate(configs):
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, f"test_layers_{i}.pdf")
export_wireframe_to_pdf(
export_mesh_to_pdf(
edges_2d,
pdf_path,
crease_angle=30.0,
@ -1503,6 +1503,74 @@ class TestPdfExporter(unittest.TestCase):
content = decompress_pdf_streams(f.read())
self.assertIn("(GearWheel) Tj", content)
def test_3d_grid_slice_paths_generation_and_rotation(self):
from NodeSlicer.core.pdf_exporter.geometry import (
generate_grid_slice_paths,
get_first_object_rotation,
transform_slice_paths,
extract_slice_paths_edges,
)
from NodeSlicer.core.slice import SlicePaths
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject
# 1. Test generate_grid_slice_paths
minor_sp, main_sp = generate_grid_slice_paths(-20.0, 20.0, -20.0, 20.0, 5.0)
self.assertIsInstance(minor_sp, SlicePaths)
self.assertIsInstance(main_sp, SlicePaths)
self.assertGreater(len(minor_sp), 0)
self.assertGreater(len(main_sp), 0)
# 2. Test get_first_object_rotation
so = SlicingObject()
so.transforms = [
{"type": "transform_geometry", "rotation": (0.0, 0.0, np.pi / 4.0)}
]
rot = get_first_object_rotation([so])
self.assertAlmostEqual(rot[2], np.pi / 4.0)
# 3. Test rotating grid slice paths
rot_minor = transform_slice_paths(minor_sp, np.zeros(3), np.array(rot), np.ones(3))
edges_2d = extract_slice_paths_edges(rot_minor)
self.assertGreater(len(edges_2d), 0)
self.assertEqual(edges_2d.shape[1:], (2, 2))
def test_export_mesh_to_pdf_with_object_rotation(self):
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject
so = SlicingObject()
so.name = "RotatedCube"
so.mesh = np.array([[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]]], dtype=np.float64)
so.transforms = [
{"type": "transform_geometry", "rotation": (0.0, 0.0, np.pi / 6.0)}
]
with tempfile.TemporaryDirectory() as tmpdir:
pdf_file = os.path.join(tmpdir, "rotated_grid_output.pdf")
export_mesh_to_pdf(
raw_geometry=so,
filepath=pdf_file,
draw_wireframe=True,
)
self.assertTrue(os.path.exists(pdf_file))
with open(pdf_file, "rb") as f:
content = decompress_pdf_streams(f.read())
def test_grid_covers_rotated_object_bounds(self):
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject
so = SlicingObject()
so.name = "ZRotatedBox"
# 20x20 box centered at (50, 50)
tri = np.array([[40.0, 40.0, 0.0], [60.0, 40.0, 0.0], [40.0, 60.0, 0.0]], dtype=np.float64)
so.mesh = np.array([tri])
so.transforms = [
{"type": "transform_geometry", "rotation": (0.0, 0.0, np.pi / 4.0)}
]
exporter = export_mesh_to_pdf(raw_geometry=so)
self.assertGreater(len(exporter.minor_lines_arr), 0)
self.assertGreater(len(exporter.main_lines_arr), 0)
# Verify exporter bounds cleanly encompass geometry
self.assertLessEqual(exporter.grid_min_x, 0.0)
self.assertGreaterEqual(exporter.grid_max_x, 70.0)
if __name__ == '__main__':
unittest.main()
@ -1510,3 +1578,4 @@ if __name__ == '__main__':