1142 lines
46 KiB
Python
1142 lines
46 KiB
Python
"""
|
|
PDF Export Stream Assembly & Writer Module.
|
|
Formats vector edge arrays and renders scalable PDF files with grid overlays, text labels, and SA2 technical markings.
|
|
"""
|
|
import datetime
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
import numpy as np
|
|
from numpy.typing import NDArray
|
|
|
|
from .metadata import (
|
|
extract_object_label_info,
|
|
extract_object_bounding_boxes,
|
|
extract_settings_items,
|
|
get_first_attribute_value,
|
|
resolve_object_properties,
|
|
)
|
|
from .geometry import (
|
|
extract_unique_top_view_edges,
|
|
extract_boundary_edges,
|
|
extract_outer_contour_edges,
|
|
extract_crease_edges,
|
|
extract_slice_paths_edges,
|
|
deduplicate_2d_edges,
|
|
transform_slice_paths,
|
|
get_first_object_rotation,
|
|
generate_grid_slice_paths,
|
|
generate_legend_slice_paths,
|
|
)
|
|
from .sa2_measures import render_sa2_measures_pdf
|
|
from .sizing import (
|
|
compute_grid_steps,
|
|
compute_effective_stroke_and_font,
|
|
LegendSizing,
|
|
MM_TO_PT,
|
|
)
|
|
from .pdf_utils import PdfStream
|
|
|
|
|
|
def render_technical_header_table(
|
|
s: PdfStream,
|
|
page_w_pt: float,
|
|
page_h_pt: float,
|
|
total_margin_mm: float,
|
|
header_info: dict[str, Any],
|
|
stroke_color: tuple[float, float, float],
|
|
bg_color: tuple[float, float, float],
|
|
font_size_pt: float,
|
|
line_width_pt: float,
|
|
obj_name: str = "",
|
|
) -> None:
|
|
"""
|
|
Renders an ISO/DIN technical drawing header block (title block) at the bottom-right corner.
|
|
"""
|
|
w_table_mm = font_size_pt / MM_TO_PT * 25.0
|
|
cell_height_mm = font_size_pt / MM_TO_PT * 1.4
|
|
|
|
h_table_mm = cell_height_mm * 3
|
|
|
|
x1_pt = page_w_pt - (total_margin_mm * MM_TO_PT)
|
|
y1_pt = page_h_pt - (total_margin_mm * MM_TO_PT)
|
|
x0_pt = x1_pt - (w_table_mm * MM_TO_PT)
|
|
y0_pt = y1_pt - (h_table_mm * MM_TO_PT)
|
|
|
|
# Fill background box
|
|
br, bg, bb = bg_color
|
|
sr, sg, sb = stroke_color
|
|
s.opacity("GS100")
|
|
s.fill_color(br, bg, bb)
|
|
s.fill_rect(x0_pt, y0_pt, w_table_mm * MM_TO_PT, h_table_mm * MM_TO_PT)
|
|
|
|
# Table borders and grid
|
|
table_lw_pt = line_width_pt * 1.2
|
|
grid_lw_pt = line_width_pt * 0.8
|
|
|
|
s.stroke_color(sr, sg, sb).line_width(table_lw_pt).round_caps()
|
|
s.rect(x0_pt, y0_pt, w_table_mm * MM_TO_PT, h_table_mm * MM_TO_PT).stroke()
|
|
|
|
h_row_pt = cell_height_mm * MM_TO_PT
|
|
y_row1_pt = y0_pt + h_row_pt
|
|
y_row2_pt = y0_pt + 2.0 * h_row_pt
|
|
|
|
s.line_width(grid_lw_pt)
|
|
# Horizontal lines
|
|
s.line_stroke(x0_pt, y_row1_pt, x1_pt, y_row1_pt)
|
|
s.line_stroke(x0_pt, y_row2_pt, x1_pt, y_row2_pt)
|
|
|
|
# Vertical dividers
|
|
x_div1_pt = x0_pt + (0.5 * w_table_mm * MM_TO_PT)
|
|
x_div2_pt = x0_pt + (0.8 * w_table_mm * MM_TO_PT)
|
|
s.line_stroke(x_div1_pt, y_row1_pt, x_div1_pt, y1_pt)
|
|
s.line_stroke(x_div2_pt, y_row1_pt, x_div2_pt, y1_pt)
|
|
|
|
x_r2_div1_pt = x0_pt + (0.4 * w_table_mm * MM_TO_PT)
|
|
x_r2_div2_pt = x0_pt + (0.8 * w_table_mm * MM_TO_PT)
|
|
s.line_stroke(x_r2_div1_pt, y0_pt, x_r2_div1_pt, y_row1_pt)
|
|
s.line_stroke(x_r2_div2_pt, y0_pt, x_r2_div2_pt, y_row1_pt)
|
|
|
|
# Extract field values with fallbacks
|
|
company_val = str(header_info.get("company", "") or "-")
|
|
raw_part_name = header_info.get("part_name", "")
|
|
part_name_val = str((raw_part_name or "").strip() or obj_name or "-")
|
|
dwg_val = str(header_info.get("drawing_number", "") or "-")
|
|
author_val = str(header_info.get("author", "") or "-")
|
|
rev_val = str(header_info.get("revision", "") or "A")
|
|
mat_val = str(header_info.get("material", "") or "-")
|
|
tol_val = str(header_info.get("tolerance", "") or "-")
|
|
date_val = str(header_info.get("date", "") or datetime.date.today().isoformat())
|
|
scale_val = str(header_info.get("scale", "") or "1:1")
|
|
|
|
cells = [
|
|
# Row 0 (Top)
|
|
("COMPANY", company_val, x0_pt, y_row2_pt, h_row_pt),
|
|
("DWG NO", dwg_val, x_div1_pt, y_row2_pt, h_row_pt),
|
|
("REV", rev_val, x_div2_pt, y_row2_pt, h_row_pt),
|
|
# Row 1 (Middle)
|
|
("PART NAME", part_name_val, x0_pt, y_row1_pt, h_row_pt),
|
|
("MATERIAL", mat_val, x_div1_pt, y_row1_pt, h_row_pt),
|
|
("TOLERANCE", tol_val, x_div2_pt, y_row1_pt, h_row_pt),
|
|
# Row 2 (Bottom)
|
|
("AUTHOR", author_val, x0_pt, y0_pt, h_row_pt),
|
|
("DATE", date_val, x_r2_div1_pt, y0_pt, h_row_pt),
|
|
("SCALE", scale_val, x_r2_div2_pt, y0_pt, h_row_pt),
|
|
]
|
|
|
|
eff_font = font_size_pt if font_size_pt is not None and font_size_pt > 0 else 10.0
|
|
lbl_font_pt = eff_font * 0.5
|
|
val_font_pt = eff_font * 0.75
|
|
label_color = (0.35, 0.35, 0.35)
|
|
|
|
for lbl_text, val_text, cx, cy, ch in cells:
|
|
# Small field label
|
|
lx = cx + (1.5 * MM_TO_PT)
|
|
ly = cy + ch - (lbl_font_pt * 1.1)
|
|
s.begin_text().font("F1", lbl_font_pt)
|
|
s.fill_color(*label_color)
|
|
s.text_position(lx, ly)
|
|
s.show(lbl_text)
|
|
s.end_text()
|
|
|
|
# Field value (bold)
|
|
vx = cx + (1.5 * MM_TO_PT)
|
|
vy = cy + (1.5 * MM_TO_PT)
|
|
s.begin_text().font("F1", val_font_pt)
|
|
s.fill_color(sr, sg, sb)
|
|
bold_w = max(0.15, val_font_pt * 0.035)
|
|
s.stroke_color(sr, sg, sb).line_width(bold_w).text_rendering_mode(2)
|
|
s.text_position(vx, vy)
|
|
s.show(val_text)
|
|
s.text_rendering_mode(0)
|
|
s.end_text()
|
|
|
|
|
|
def render_object_properties_table(
|
|
s: PdfStream,
|
|
page_w_pt: float,
|
|
page_h_pt: float,
|
|
total_margin_mm: float,
|
|
slicing_objs: list[Any],
|
|
draw_header: bool,
|
|
header_info: dict[str, Any] | None,
|
|
stroke_color: tuple[float, float, float],
|
|
bg_color: tuple[float, float, float],
|
|
font_size_pt: float,
|
|
line_width_pt: float,
|
|
) -> None:
|
|
"""
|
|
Renders per-object properties boxes under the technical header table on the right side of the drawing.
|
|
"""
|
|
objs_with_props = [
|
|
so for so in slicing_objs
|
|
if getattr(so, "object_properties", None) is not None and len(getattr(so, "object_properties", [])) > 0
|
|
]
|
|
if not objs_with_props:
|
|
return
|
|
|
|
w_table_mm = (font_size_pt / MM_TO_PT) * 25.0
|
|
w_box_pt = w_table_mm * MM_TO_PT
|
|
cell_height_mm = (font_size_pt / MM_TO_PT) * 1.4
|
|
cell_h_pt = cell_height_mm * MM_TO_PT
|
|
gap_pt = cell_h_pt * 0.5
|
|
|
|
x1_pt = page_w_pt - (total_margin_mm * MM_TO_PT)
|
|
x0_pt = x1_pt - w_box_pt
|
|
|
|
top_y_pt = page_h_pt - (total_margin_mm * MM_TO_PT)
|
|
if draw_header and header_info is not None:
|
|
h_header_table_mm = cell_height_mm * 3.0
|
|
header_table_h_pt = h_header_table_mm * MM_TO_PT
|
|
curr_y_pt = top_y_pt - header_table_h_pt - gap_pt
|
|
else:
|
|
curr_y_pt = top_y_pt
|
|
|
|
table_lw_pt = line_width_pt * 1.2
|
|
grid_lw_pt = line_width_pt * 0.8
|
|
br, bg, bb = bg_color
|
|
sr, sg, sb = stroke_color
|
|
eff_font = font_size_pt if font_size_pt is not None and font_size_pt > 0 else 10.0
|
|
val_font_pt = eff_font * 0.75
|
|
lbl_font_pt = eff_font * 0.65
|
|
label_color = (0.35, 0.35, 0.35)
|
|
|
|
for so in objs_with_props:
|
|
raw_props = getattr(so, "object_properties", [])
|
|
props = resolve_object_properties(so, raw_props)
|
|
if not props:
|
|
continue
|
|
|
|
obj_name = str(getattr(so, "name", "Object"))
|
|
num_rows = 1 + len(props)
|
|
h_box_pt = num_rows * cell_h_pt
|
|
|
|
y1_box_pt = curr_y_pt
|
|
y0_box_pt = y1_box_pt - h_box_pt
|
|
|
|
# Fill background
|
|
s.opacity("GS100")
|
|
s.fill_color(br, bg, bb)
|
|
s.fill_rect(x0_pt, y0_box_pt, w_box_pt, h_box_pt)
|
|
|
|
# Outer border
|
|
s.stroke_color(sr, sg, sb).line_width(table_lw_pt).round_caps()
|
|
s.rect(x0_pt, y0_box_pt, w_box_pt, h_box_pt).stroke()
|
|
|
|
# Divider between Subbox 1 (Object Name) and Subbox 2 (Properties)
|
|
y_name_div_pt = y1_box_pt - cell_h_pt
|
|
s.line_width(grid_lw_pt)
|
|
s.line_stroke(x0_pt, y_name_div_pt, x1_pt, y_name_div_pt)
|
|
|
|
# Subbox 1: Object Name (in bold)
|
|
name_vx = x0_pt + (1.5 * MM_TO_PT)
|
|
name_vy = y_name_div_pt + (1.5 * MM_TO_PT)
|
|
s.begin_text().font("F1", val_font_pt)
|
|
s.fill_color(sr, sg, sb)
|
|
bold_w = max(0.15, val_font_pt * 0.035)
|
|
s.stroke_color(sr, sg, sb).line_width(bold_w).text_rendering_mode(2)
|
|
s.text_position(name_vx, name_vy)
|
|
s.show(obj_name)
|
|
s.text_rendering_mode(0)
|
|
s.end_text()
|
|
|
|
# Subbox 2: Properties
|
|
y_prop_curr_pt = y_name_div_pt
|
|
x_div_40_pt = x0_pt + (0.40 * w_box_pt)
|
|
|
|
for prop_idx, (prop_id, prop_val) in enumerate(props):
|
|
row_top_pt = y_prop_curr_pt - prop_idx * cell_h_pt
|
|
row_bot_pt = row_top_pt - cell_h_pt
|
|
|
|
if prop_idx > 0:
|
|
s.line_width(grid_lw_pt)
|
|
s.line_stroke(x0_pt, row_top_pt, x1_pt, row_top_pt)
|
|
|
|
str_val = str(prop_val)
|
|
if not prop_id:
|
|
# Single line text (just the value in a cell)
|
|
vx = x0_pt + (1.5 * MM_TO_PT)
|
|
vy = row_bot_pt + (1.5 * MM_TO_PT)
|
|
s.begin_text().font("F1", val_font_pt)
|
|
s.fill_color(sr, sg, sb)
|
|
s.text_position(vx, vy)
|
|
s.show(str_val)
|
|
s.end_text()
|
|
else:
|
|
# Pair ID-value (two cells with divider at 40% of width)
|
|
s.line_width(grid_lw_pt)
|
|
s.line_stroke(x_div_40_pt, row_bot_pt, x_div_40_pt, row_top_pt)
|
|
|
|
# Left cell: ID
|
|
lx = x0_pt + (1.5 * MM_TO_PT)
|
|
ly = row_bot_pt + (1.5 * MM_TO_PT)
|
|
s.begin_text().font("F1", lbl_font_pt)
|
|
s.fill_color(*label_color)
|
|
s.text_position(lx, ly)
|
|
s.show(str(prop_id))
|
|
s.end_text()
|
|
|
|
# Right cell: Value
|
|
vx = x_div_40_pt + (1.5 * MM_TO_PT)
|
|
vy = row_bot_pt + (1.5 * MM_TO_PT)
|
|
s.begin_text().font("F1", val_font_pt)
|
|
s.fill_color(sr, sg, sb)
|
|
s.text_position(vx, vy)
|
|
s.show(str_val)
|
|
s.end_text()
|
|
|
|
curr_y_pt = y0_box_pt - gap_pt
|
|
|
|
|
|
def get_all_slicing_objects(mesh_data: Any) -> list[Any]:
|
|
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
|
|
from ..slice import SlicePaths
|
|
|
|
objects = []
|
|
|
|
def process_item(item: Any):
|
|
if item is None:
|
|
return
|
|
elif isinstance(item, (SlicingObject, SlicePaths)):
|
|
objects.append(item)
|
|
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)
|
|
|
|
process_item(mesh_data)
|
|
return objects
|
|
|
|
|
|
class PDFElement:
|
|
"""
|
|
Base class for individual PDF layout and geometry rendering elements.
|
|
"""
|
|
def __init__(self, exporter: "export_mesh_to_pdf") -> None:
|
|
self.exporter = exporter
|
|
|
|
def render(self, s: PdfStream) -> None:
|
|
"""
|
|
Renders the element content into the given PdfStream.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
|
|
class PDFBackgroundElement(PDFElement):
|
|
"""
|
|
Renders solid page background fill.
|
|
"""
|
|
def render(self, s: PdfStream) -> None:
|
|
exp = self.exporter
|
|
br, bg, bb = exp.bg_color
|
|
s.fill_color(br, bg, bb)
|
|
s.fill_rect(0, 0, exp.page_w_pt, exp.page_h_pt)
|
|
|
|
|
|
class PDFGridElement(PDFElement):
|
|
"""
|
|
Renders minor (25% opacity) and main (50% opacity) background grid lines.
|
|
"""
|
|
def render(self, s: PdfStream) -> None:
|
|
exp = self.exporter
|
|
sr, sg, sb = exp.stroke_color
|
|
s.stroke_color(sr, sg, sb)
|
|
|
|
if len(exp.minor_lines_arr) > 0:
|
|
s.opacity("GS25").line_width(exp.line_width_pt)
|
|
s.draw_line_batch(exp.minor_lines_arr)
|
|
|
|
if len(exp.main_lines_arr) > 0:
|
|
s.opacity("GS50").line_width(exp.line_width_pt)
|
|
s.draw_line_batch(exp.main_lines_arr)
|
|
|
|
|
|
class PDFGeometryElement(PDFElement):
|
|
"""
|
|
Renders 2D vector geometry (wireframe, boundary, contour, crease lines, and slice paths).
|
|
"""
|
|
def render(self, s: PdfStream) -> None:
|
|
exp = self.exporter
|
|
slicing_objs = exp.slicing_objs
|
|
from ..slice import SlicePaths
|
|
if slicing_objs:
|
|
for so in slicing_objs:
|
|
so_edge_list: list[NDArray[np.float64]] = []
|
|
if isinstance(so, SlicePaths):
|
|
sp_edges = extract_slice_paths_edges(so)
|
|
if len(sp_edges) > 0:
|
|
so_edge_list.append(sp_edges)
|
|
else:
|
|
if exp.draw_wireframe:
|
|
so_w = extract_unique_top_view_edges(so)
|
|
if len(so_w) > 0:
|
|
so_edge_list.append(so_w)
|
|
if exp.draw_boundary and not exp.draw_crease:
|
|
so_b = extract_boundary_edges(so)
|
|
if len(so_b) > 0:
|
|
so_edge_list.append(so_b)
|
|
if exp.draw_contour:
|
|
so_c = extract_outer_contour_edges(so)
|
|
if len(so_c) > 0:
|
|
so_edge_list.append(so_c)
|
|
if exp.draw_crease:
|
|
so_cr = extract_crease_edges(so, max_angle_deg=exp.crease_angle)
|
|
if len(so_cr) > 0:
|
|
so_edge_list.append(so_cr)
|
|
|
|
if so_edge_list:
|
|
so_combined = np.concatenate(so_edge_list, axis=0)
|
|
so_edges_to_draw = deduplicate_2d_edges(so_combined)
|
|
else:
|
|
so_edges_to_draw = np.empty((0, 2, 2), dtype=np.float64)
|
|
|
|
if len(so_edges_to_draw) > 0:
|
|
so_lw_mm = (
|
|
so.pdf_properties.line_width
|
|
if (hasattr(so, "pdf_properties") and so.pdf_properties and so.pdf_properties.line_width > 0.0)
|
|
else exp.line_width_mm
|
|
)
|
|
so_color = (
|
|
so.pdf_properties.line_color
|
|
if (hasattr(so, "pdf_properties") and so.pdf_properties and so.pdf_properties.line_color is not None)
|
|
else exp.stroke_color
|
|
)
|
|
|
|
so_line_width_pt, _ = compute_effective_stroke_and_font(
|
|
so_lw_mm, exp.font_size_mm, exp.minor_step
|
|
)
|
|
|
|
s.opacity("GS100").line_width(so_line_width_pt).stroke_color(so_color[0], so_color[1], so_color[2]).round_caps()
|
|
p0_pt = exp.to_pdf_pt(so_edges_to_draw[:, 0, :])
|
|
p1_pt = exp.to_pdf_pt(so_edges_to_draw[:, 1, :])
|
|
obj_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
|
|
s.draw_line_batch(obj_lines_arr)
|
|
elif len(exp.edges_to_draw) > 0:
|
|
s.opacity("GS100").line_width(exp.line_width_pt).stroke_color(exp.stroke_color[0], exp.stroke_color[1], exp.stroke_color[2]).round_caps()
|
|
p0_pt = exp.to_pdf_pt(exp.edges_to_draw[:, 0, :])
|
|
p1_pt = exp.to_pdf_pt(exp.edges_to_draw[:, 1, :])
|
|
obj_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
|
|
s.draw_line_batch(obj_lines_arr)
|
|
|
|
|
|
class PDFBoundingBoxElement(PDFElement):
|
|
"""
|
|
Renders 2D object bounding boxes in bbox_color.
|
|
"""
|
|
def render(self, s: PdfStream) -> None:
|
|
exp = self.exporter
|
|
if exp.draw_bbox and exp.raw_geometry is not None:
|
|
bbox_edges = extract_object_bounding_boxes(exp.raw_geometry)
|
|
if len(bbox_edges) > 0:
|
|
b_r, b_g, b_b = exp.bbox_color
|
|
s.opacity("GS100").line_width(exp.line_width_pt).round_caps()
|
|
s.stroke_color(b_r, b_g, b_b)
|
|
|
|
p0_pt = exp.to_pdf_pt(bbox_edges[:, 0, :])
|
|
p1_pt = exp.to_pdf_pt(bbox_edges[:, 1, :])
|
|
bbox_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
|
|
s.draw_line_batch(bbox_lines_arr)
|
|
|
|
|
|
class PDFSA2MeasuresElement(PDFElement):
|
|
"""
|
|
Renders SA2 technical measurement annotations (bores, holes, pitch, chamfers).
|
|
"""
|
|
def render(self, s: PdfStream) -> None:
|
|
exp = self.exporter
|
|
if exp.raw_geometry is not None and exp.draw_sa2_holes:
|
|
render_sa2_measures_pdf(
|
|
s,
|
|
exp.raw_geometry,
|
|
draw_sa2_holes=exp.draw_sa2_holes,
|
|
minor_step_mm=exp.minor_step,
|
|
main_step=exp.main_step,
|
|
to_pdf_pt=exp.to_pdf_pt,
|
|
font_size_pt=exp.font_size_pt,
|
|
line_width_pt=exp.line_width_pt,
|
|
draw_wireframe=exp.draw_wireframe,
|
|
draw_boundary=exp.draw_boundary,
|
|
draw_contour=exp.draw_contour,
|
|
draw_crease=exp.draw_crease,
|
|
crease_angle=exp.crease_angle,
|
|
)
|
|
|
|
|
|
class PDFLegendElement(PDFElement):
|
|
"""
|
|
Renders physical size scale legend (axes, tick marks, arrows, and units)
|
|
using 3D SlicePaths geometry oriented with object rotation.
|
|
"""
|
|
def render(self, s: PdfStream) -> None:
|
|
exp = self.exporter
|
|
|
|
# Extract 3D rotation of the first object
|
|
rot_vec = get_first_object_rotation(
|
|
exp.slicing_objs, exp.raw_geometry, exp.slice_paths
|
|
)
|
|
r_arr = np.array(rot_vec, dtype=np.float64)
|
|
|
|
# Generate legend 3D SlicePaths & label metadata
|
|
legend_sp, label_meta = generate_legend_slice_paths(
|
|
exp.local_min_x_3d,
|
|
exp.local_min_y_3d,
|
|
exp.local_max_x_3d,
|
|
exp.main_step,
|
|
exp.minor_step,
|
|
exp.line_width_pt,
|
|
exp.font_size_pt,
|
|
draw_x=getattr(exp, "draw_x_axis", True),
|
|
draw_y=getattr(exp, "draw_y_axis", True),
|
|
draw_z=getattr(exp, "draw_z_axis", False),
|
|
local_origin_3d=getattr(exp, "legend_origin_3d", None),
|
|
local_max_3d=getattr(exp, "legend_max_3d", None),
|
|
)
|
|
|
|
# Rotate legend SlicePaths with existing transform_slice_paths
|
|
rot_legend_sp = transform_slice_paths(legend_sp, np.zeros(3), r_arr, np.ones(3))
|
|
|
|
# Extract 2D edge segments using existing extract_slice_paths_edges
|
|
legend_edges_2d = extract_slice_paths_edges(rot_legend_sp)
|
|
|
|
# Render vector lines
|
|
if len(legend_edges_2d) > 0:
|
|
s.opacity("GS100").line_width(exp.line_width_pt).stroke_color(0, 0, 0).round_caps()
|
|
p0_pt = exp.to_pdf_pt(legend_edges_2d[:, 0, :])
|
|
p1_pt = exp.to_pdf_pt(legend_edges_2d[:, 1, :])
|
|
lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
|
|
s.draw_line_batch(lines_arr)
|
|
|
|
# Calculate rotation transpose matrix for text label placement
|
|
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)
|
|
|
|
s.fill_color(0, 0, 0)
|
|
s.begin_text().font("F1", exp.font_size_pt)
|
|
|
|
for meta in label_meta:
|
|
local_pt = np.array(meta["local_pt"], dtype=np.float64)
|
|
world_pt = local_pt @ R.T
|
|
pt_pdf = exp.to_pdf_pt(world_pt.reshape(1, 3)[:, :2])[0]
|
|
txt = meta["text"]
|
|
|
|
if meta["type"] == "tick":
|
|
tw_pt = len(txt) * exp.font_size_pt * 0.60
|
|
s.text_position(pt_pdf[0] - (tw_pt / 2.0), pt_pdf[1]).show(txt)
|
|
elif meta["type"] == "x_axis":
|
|
s.text_position(pt_pdf[0], pt_pdf[1] - (exp.font_size_pt * 0.35)).show(txt)
|
|
elif meta["type"] == "y_axis":
|
|
s.text_position(pt_pdf[0] - (exp.font_size_pt * 0.35), pt_pdf[1]).show(txt)
|
|
|
|
s.end_text()
|
|
|
|
|
|
class PDFObjectLabelsElement(PDFElement):
|
|
"""
|
|
Renders object labels, bounding box sizes, and attribute settings.
|
|
"""
|
|
def render(self, s: PdfStream) -> None:
|
|
exp = self.exporter
|
|
settings_items = extract_settings_items(exp.settings)
|
|
slicing_objs_list = exp.slicing_objs
|
|
has_any_obj_show_name = any(hasattr(o, "pdf_properties") and o.pdf_properties and o.pdf_properties.show_name for o in slicing_objs_list)
|
|
|
|
if (exp.show_name or has_any_obj_show_name or settings_items) and (exp.raw_geometry is not None or exp.slice_paths is not None):
|
|
geom_source = [g for g in (exp.raw_geometry, exp.slice_paths) if g is not None]
|
|
label_infos = extract_object_label_info(geom_source)
|
|
if label_infos:
|
|
eff_font_size_pt = exp.font_size_pt if exp.font_size_pt is not None and exp.font_size_pt > 0 else max(8.0, exp.line_width_pt * 4.0)
|
|
line_spacing_pt = eff_font_size_pt * 1.25
|
|
leg_sizing = LegendSizing(exp.main_step, exp.minor_step, exp.line_width_pt, exp.font_size_pt)
|
|
s.opacity("GS100")
|
|
|
|
for so_item, obj_name, max_x_mm, y_val_mm, w_obj_mm, h_obj_mm in label_infos:
|
|
p_pt = exp.to_pdf_pt(np.array([[max_x_mm, y_val_mm]]))
|
|
x_text_pt = p_pt[0, 0] + leg_sizing.label_margin_w_pt
|
|
y_text_pt = p_pt[0, 1] - (eff_font_size_pt * 0.35)
|
|
|
|
lines_to_draw: list[tuple[str, tuple[float, float, float], bool]] = []
|
|
|
|
so_show_name = (hasattr(so_item, "pdf_properties") and so_item.pdf_properties and so_item.pdf_properties.show_name) or exp.show_name
|
|
so_color = (
|
|
so_item.pdf_properties.line_color
|
|
if (hasattr(so_item, "pdf_properties") and so_item.pdf_properties and so_item.pdf_properties.line_color is not None)
|
|
else exp.stroke_color
|
|
)
|
|
if so_show_name:
|
|
lines_to_draw.append((str(obj_name), so_color, True))
|
|
|
|
for key_name, suffix in settings_items:
|
|
attr_val = get_first_attribute_value(so_item, key_name)
|
|
if attr_val is not None:
|
|
if isinstance(attr_val, float):
|
|
val_str = f"{attr_val:g}"
|
|
else:
|
|
val_str = str(attr_val)
|
|
if suffix:
|
|
val_str = f"{val_str}{suffix}"
|
|
lines_to_draw.append((f"{key_name}: {val_str}", exp.stroke_color, False))
|
|
|
|
if lines_to_draw:
|
|
contour_w = max(0.5, eff_font_size_pt * 0.12)
|
|
for line_idx, (line_str, col, is_bold) in enumerate(lines_to_draw):
|
|
cr, cg, cb = col
|
|
curr_y_pt = y_text_pt - (line_idx * line_spacing_pt)
|
|
|
|
# 1. White contour outline pass (behind text for contrast against geometry)
|
|
s.begin_text().font("F1", eff_font_size_pt)
|
|
s.fill_color(1.0, 1.0, 1.0).stroke_color(1.0, 1.0, 1.0)
|
|
s.line_width(contour_w).text_rendering_mode(2)
|
|
s.text_position(x_text_pt, curr_y_pt)
|
|
s.show(line_str)
|
|
s.text_rendering_mode(0)
|
|
s.end_text()
|
|
|
|
# 2. Foreground text pass
|
|
s.begin_text().font("F1", eff_font_size_pt)
|
|
s.fill_color(cr, cg, cb)
|
|
if is_bold:
|
|
bold_stroke_w = max(0.2, eff_font_size_pt * 0.035)
|
|
s.stroke_color(cr, cg, cb).line_width(bold_stroke_w).text_rendering_mode(2)
|
|
else:
|
|
s.text_rendering_mode(0)
|
|
s.text_position(x_text_pt, curr_y_pt)
|
|
s.show(line_str)
|
|
if is_bold:
|
|
s.text_rendering_mode(0)
|
|
s.end_text()
|
|
|
|
|
|
class PDFHeaderTableElement(PDFElement):
|
|
"""
|
|
Renders ISO/DIN technical drawing header table at top-right corner.
|
|
"""
|
|
def render(self, s: PdfStream) -> None:
|
|
exp = self.exporter
|
|
if not exp.draw_header or not exp.header_info:
|
|
return
|
|
|
|
from .metadata import get_first_object_name
|
|
|
|
primary_obj_name = ""
|
|
if exp.slicing_objs:
|
|
for so in exp.slicing_objs:
|
|
if hasattr(so, "name") and getattr(so, "name"):
|
|
primary_obj_name = str(getattr(so, "name"))
|
|
break
|
|
if not primary_obj_name:
|
|
primary_obj_name = get_first_object_name(exp.raw_geometry) or get_first_object_name(exp.slice_paths)
|
|
|
|
render_technical_header_table(
|
|
s=s,
|
|
page_w_pt=exp.page_w_pt,
|
|
page_h_pt=exp.page_h_pt,
|
|
total_margin_mm=exp.total_margin_mm,
|
|
header_info=exp.header_info,
|
|
stroke_color=exp.stroke_color,
|
|
bg_color=exp.bg_color,
|
|
font_size_pt=exp.font_size_pt,
|
|
line_width_pt=exp.line_width_pt,
|
|
obj_name=primary_obj_name,
|
|
)
|
|
|
|
|
|
class PDFObjectPropertiesTableElement(PDFElement):
|
|
"""
|
|
Renders object properties table under the technical header table.
|
|
"""
|
|
def render(self, s: PdfStream) -> None:
|
|
exp = self.exporter
|
|
if not exp.slicing_objs:
|
|
return
|
|
|
|
render_object_properties_table(
|
|
s=s,
|
|
page_w_pt=exp.page_w_pt,
|
|
page_h_pt=exp.page_h_pt,
|
|
total_margin_mm=exp.total_margin_mm,
|
|
slicing_objs=exp.slicing_objs,
|
|
draw_header=exp.draw_header,
|
|
header_info=exp.header_info,
|
|
stroke_color=exp.stroke_color,
|
|
bg_color=exp.bg_color,
|
|
font_size_pt=exp.font_size_pt,
|
|
line_width_pt=exp.line_width_pt,
|
|
)
|
|
|
|
|
|
class export_mesh_to_pdf:
|
|
"""
|
|
Class responsible for configuring, building, and exporting 2D vector mesh geometry
|
|
and technical annotations into scalable 1:1 vector PDF files.
|
|
"""
|
|
# Element Subclasses
|
|
Element = PDFElement
|
|
BackgroundElement = PDFBackgroundElement
|
|
GridElement = PDFGridElement
|
|
GeometryElement = PDFGeometryElement
|
|
BoundingBoxElement = PDFBoundingBoxElement
|
|
SA2MeasuresElement = PDFSA2MeasuresElement
|
|
LegendElement = PDFLegendElement
|
|
ObjectLabelsElement = PDFObjectLabelsElement
|
|
HeaderTableElement = PDFHeaderTableElement
|
|
ObjectPropertiesTableElement = PDFObjectPropertiesTableElement
|
|
|
|
# Convenient aliases for element subclasses
|
|
Legend = PDFLegendElement
|
|
Table = PDFHeaderTableElement
|
|
HeaderTable = PDFHeaderTableElement
|
|
Grid = PDFGridElement
|
|
Geometry = PDFGeometryElement
|
|
BoundingBox = PDFBoundingBoxElement
|
|
SA2Measures = PDFSA2MeasuresElement
|
|
ObjectLabels = PDFObjectLabelsElement
|
|
Background = PDFBackgroundElement
|
|
ObjectPropertiesTable = PDFObjectPropertiesTableElement
|
|
|
|
def __init__(
|
|
self,
|
|
edges_2d: NDArray[np.float64] | None = None,
|
|
filepath: str | Path | None = None,
|
|
draw_wireframe: bool = True,
|
|
draw_boundary: bool = False,
|
|
draw_contour: bool = False,
|
|
draw_crease: bool = False,
|
|
draw_bbox: bool = False,
|
|
draw_sa2_holes: bool = False,
|
|
draw_header: bool = False,
|
|
header_info: dict[str, Any] | None = None,
|
|
show_name: bool = False,
|
|
crease_angle: float = 30.0,
|
|
margin_mm: float = 10.0,
|
|
line_width_mm: float = 0.0,
|
|
font_size_mm: float = 0.0,
|
|
stroke_color: tuple[float, float, float] = (0.0, 0.0, 0.0),
|
|
bg_color: tuple[float, float, float] = (1.0, 1.0, 1.0),
|
|
bbox_color: tuple[float, float, float] = (0.0, 0.0, 1.0),
|
|
raw_geometry: Any = None,
|
|
slice_paths: Any = None,
|
|
settings: Any = None,
|
|
projection_plane: str | None = None,
|
|
draw_grid_xy: bool = True,
|
|
draw_grid_xz: bool = False,
|
|
draw_grid_yz: bool = False,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
if projection_plane is not None:
|
|
p_up = projection_plane.upper()
|
|
draw_grid_xy = (p_up == "XY")
|
|
draw_grid_xz = (p_up == "XZ")
|
|
draw_grid_yz = (p_up == "YZ")
|
|
|
|
self.draw_grid_xy = draw_grid_xy
|
|
self.draw_grid_xz = draw_grid_xz
|
|
self.draw_grid_yz = draw_grid_yz
|
|
self.projection_plane = "XY" if draw_grid_xy else ("XZ" if draw_grid_xz else "YZ")
|
|
self.edges_2d = edges_2d if edges_2d is not None else np.empty((0, 2, 2), dtype=np.float64)
|
|
self.filepath = filepath
|
|
self.draw_wireframe = draw_wireframe
|
|
self.draw_boundary = draw_boundary
|
|
self.draw_contour = draw_contour
|
|
self.draw_crease = draw_crease
|
|
self.draw_bbox = draw_bbox
|
|
self.draw_sa2_holes = draw_sa2_holes
|
|
self.draw_header = draw_header
|
|
if self.draw_header and header_info is None:
|
|
header_info = {}
|
|
self.header_info = header_info
|
|
self.show_name = show_name
|
|
self.crease_angle = crease_angle
|
|
self.margin_mm = margin_mm
|
|
self.line_width_mm = line_width_mm
|
|
self.font_size_mm = font_size_mm
|
|
self.stroke_color = stroke_color
|
|
self.bg_color = bg_color
|
|
self.bbox_color = bbox_color
|
|
self.raw_geometry = raw_geometry
|
|
self.slice_paths = slice_paths
|
|
self.settings = settings
|
|
|
|
self._prepare_geometry_and_layout()
|
|
|
|
self.elements: list[PDFElement] = [
|
|
self.BackgroundElement(self),
|
|
self.GridElement(self),
|
|
self.GeometryElement(self),
|
|
self.BoundingBoxElement(self),
|
|
self.SA2MeasuresElement(self),
|
|
self.LegendElement(self),
|
|
self.ObjectLabelsElement(self),
|
|
self.HeaderTableElement(self),
|
|
self.ObjectPropertiesTableElement(self),
|
|
]
|
|
|
|
if filepath is not None:
|
|
self.export(filepath)
|
|
|
|
def _prepare_geometry_and_layout(self) -> None:
|
|
objs_raw = get_all_slicing_objects(self.raw_geometry)
|
|
objs_paths = get_all_slicing_objects(self.slice_paths)
|
|
self.slicing_objs = list(objs_raw)
|
|
for po in objs_paths:
|
|
if not any(po is existing for existing in self.slicing_objs):
|
|
self.slicing_objs.append(po)
|
|
|
|
edge_list: list[NDArray[np.float64]] = []
|
|
|
|
if self.draw_wireframe and len(self.edges_2d) > 0:
|
|
edge_list.append(self.edges_2d)
|
|
|
|
if self.draw_boundary and not self.draw_crease and self.raw_geometry is not None:
|
|
b_edges = extract_boundary_edges(self.raw_geometry)
|
|
if len(b_edges) > 0:
|
|
edge_list.append(b_edges)
|
|
|
|
if self.draw_contour and self.raw_geometry is not None:
|
|
c_edges = extract_outer_contour_edges(self.raw_geometry)
|
|
if len(c_edges) > 0:
|
|
edge_list.append(c_edges)
|
|
|
|
if self.draw_crease and self.raw_geometry is not None:
|
|
cr_edges = extract_crease_edges(self.raw_geometry, max_angle_deg=self.crease_angle)
|
|
if len(cr_edges) > 0:
|
|
edge_list.append(cr_edges)
|
|
|
|
if self.slice_paths is not None:
|
|
sp_edges = extract_slice_paths_edges(self.slice_paths)
|
|
if len(sp_edges) > 0:
|
|
edge_list.append(sp_edges)
|
|
|
|
if edge_list:
|
|
combined = np.concatenate(edge_list, axis=0)
|
|
self.edges_to_draw = deduplicate_2d_edges(combined)
|
|
elif self.draw_wireframe:
|
|
self.edges_to_draw = self.edges_2d
|
|
else:
|
|
self.edges_to_draw = np.empty((0, 2, 2), dtype=np.float64)
|
|
|
|
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)
|
|
else:
|
|
pts_2d = np.empty((0, 2), dtype=np.float64)
|
|
|
|
# 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)
|
|
|
|
pts_3d_list: list[NDArray[np.float64]] = []
|
|
for obj in self.slicing_objs:
|
|
mesh_arr = getattr(obj, "mesh", None)
|
|
if isinstance(mesh_arr, np.ndarray) and mesh_arr.size > 0:
|
|
if mesh_arr.ndim == 3 and mesh_arr.shape[2] >= 3:
|
|
pts_3d_list.append(mesh_arr[:, :3, :3].reshape(-1, 3))
|
|
elif mesh_arr.ndim == 2 and mesh_arr.shape[1] >= 3:
|
|
pts_3d_list.append(mesh_arr[:, :3])
|
|
elif isinstance(obj, list) or (hasattr(obj, "__iter__") and not isinstance(obj, (str, bytes))):
|
|
for path in obj:
|
|
if isinstance(path, np.ndarray) and path.ndim == 2:
|
|
if path.shape[1] >= 3:
|
|
pts_3d_list.append(path[:, :3])
|
|
elif path.shape[1] == 2:
|
|
pts_3d_list.append(np.column_stack([path, np.zeros(len(path), dtype=np.float64)]))
|
|
|
|
if pts_3d_list:
|
|
pts_3d_world = np.vstack(pts_3d_list)
|
|
elif len(pts_2d) > 0:
|
|
pts_3d_world = np.column_stack([pts_2d, np.zeros(len(pts_2d), dtype=np.float64)])
|
|
else:
|
|
pts_3d_world = np.empty((0, 3), dtype=np.float64)
|
|
|
|
if len(pts_3d_world) > 0:
|
|
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())
|
|
l_min_z, l_max_z = float(pts_local[:, 2].min()), float(pts_local[:, 2].max())
|
|
else:
|
|
l_min_x, l_max_x, l_min_y, l_max_y, l_min_z, l_max_z = -50.0, 50.0, -50.0, 50.0, -50.0, 50.0
|
|
|
|
w_geom, h_geom = max(l_max_x - l_min_x, 1.0), max(l_max_y - l_min_y, 1.0)
|
|
if self.draw_grid_xz and not self.draw_grid_xy:
|
|
w_geom, h_geom = max(l_max_x - l_min_x, 1.0), max(l_max_z - l_min_z, 1.0)
|
|
elif self.draw_grid_yz and not self.draw_grid_xy and not self.draw_grid_xz:
|
|
w_geom, h_geom = max(l_max_y - l_min_y, 1.0), max(l_max_z - l_min_z, 1.0)
|
|
|
|
self.main_step, self.minor_step = compute_grid_steps(w_geom, h_geom)
|
|
|
|
self.grid_min_x = math.floor((l_min_x - self.minor_step) / self.minor_step) * self.minor_step
|
|
self.grid_max_x = math.ceil((l_max_x + self.minor_step) / self.minor_step) * self.minor_step
|
|
self.grid_min_y = math.floor((l_min_y - self.minor_step) / self.minor_step) * self.minor_step
|
|
self.grid_max_y = math.ceil((l_max_y + self.minor_step) / self.minor_step) * self.minor_step
|
|
|
|
# Object sits on XY grid: exact min Z (no margin below), margin over object on max Z
|
|
self.grid_min_z = l_min_z
|
|
self.grid_max_z = math.ceil((l_max_z + self.minor_step) / self.minor_step) * self.minor_step
|
|
|
|
self.local_min_x_3d = self.grid_min_x
|
|
self.local_max_x_3d = self.grid_max_x
|
|
self.local_min_y_3d = self.grid_min_y
|
|
self.local_max_y_3d = self.grid_max_y
|
|
self.local_min_z_3d = self.grid_min_z
|
|
self.local_max_z_3d = self.grid_max_z
|
|
|
|
# Shared origin point (min X, max Y, min Z)
|
|
x0 = self.grid_min_x
|
|
y0 = self.grid_max_y
|
|
z0 = self.grid_min_z
|
|
|
|
self.legend_origin_3d = (x0, y0, z0)
|
|
self.legend_max_3d = (self.grid_max_x, self.grid_min_y, self.grid_max_z)
|
|
|
|
self.draw_x_axis = self.draw_grid_xy or self.draw_grid_xz
|
|
self.draw_y_axis = self.draw_grid_xy or self.draw_grid_yz
|
|
self.draw_z_axis = self.draw_grid_xz or self.draw_grid_yz
|
|
|
|
self.local_min_x = self.grid_min_x
|
|
self.local_max_x = self.grid_max_x
|
|
self.local_min_y = self.grid_min_y
|
|
self.local_max_y = self.grid_max_y
|
|
self.local_min_z = z0
|
|
|
|
grid_minor_paths: list[NDArray[np.float64]] = []
|
|
grid_main_paths: list[NDArray[np.float64]] = []
|
|
|
|
if self.draw_grid_xy:
|
|
min_sp, main_sp = generate_grid_slice_paths(
|
|
self.grid_min_x, self.grid_max_x,
|
|
self.grid_min_y, self.grid_max_y,
|
|
self.minor_step, u0=x0, v0=y0,
|
|
z_level=z0, plane="XY"
|
|
)
|
|
grid_minor_paths.extend(min_sp)
|
|
grid_main_paths.extend(main_sp)
|
|
|
|
if self.draw_grid_xz:
|
|
min_sp, main_sp = generate_grid_slice_paths(
|
|
self.grid_min_x, self.grid_max_x,
|
|
self.grid_min_z, self.grid_max_z,
|
|
self.minor_step, u0=x0, v0=z0,
|
|
z_level=y0, plane="XZ"
|
|
)
|
|
grid_minor_paths.extend(min_sp)
|
|
grid_main_paths.extend(main_sp)
|
|
|
|
if self.draw_grid_yz:
|
|
min_sp, main_sp = generate_grid_slice_paths(
|
|
self.grid_min_y, self.grid_max_y,
|
|
self.grid_min_z, self.grid_max_z,
|
|
self.minor_step, u0=y0, v0=z0,
|
|
z_level=x0, plane="YZ"
|
|
)
|
|
grid_minor_paths.extend(min_sp)
|
|
grid_main_paths.extend(main_sp)
|
|
|
|
from ..slice import SlicePaths
|
|
minor_grid_sp = SlicePaths(grid_minor_paths)
|
|
main_grid_sp = SlicePaths(grid_main_paths)
|
|
|
|
# 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))
|
|
|
|
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:
|
|
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
|
|
|
|
self.total_margin_mm = self.margin_mm + 2.0 * self.minor_step
|
|
|
|
self.line_width_pt, self.font_size_pt = compute_effective_stroke_and_font(
|
|
self.line_width_mm, self.font_size_mm, self.minor_step
|
|
)
|
|
|
|
objs_with_props = [
|
|
so for so in self.slicing_objs
|
|
if getattr(so, "object_properties", None) is not None and len(getattr(so, "object_properties", [])) > 0
|
|
]
|
|
|
|
w_table_mm = 0.0
|
|
h_table_col_mm = 0.0
|
|
cell_height_mm = (self.font_size_pt / MM_TO_PT) * 1.4
|
|
gap_mm = cell_height_mm * 0.5
|
|
|
|
if self.draw_header and self.header_info is not None:
|
|
w_table_mm = (self.font_size_pt / MM_TO_PT) * 25.0
|
|
h_header_table_mm = cell_height_mm * 3.0
|
|
h_table_col_mm = h_header_table_mm
|
|
|
|
if objs_with_props:
|
|
w_table_mm = (self.font_size_pt / MM_TO_PT) * 25.0
|
|
h_props_total_mm = sum(
|
|
(1 + len(getattr(so, "object_properties", []))) * cell_height_mm
|
|
for so in objs_with_props
|
|
) + len(objs_with_props) * gap_mm
|
|
if h_table_col_mm > 0.0:
|
|
h_table_col_mm += gap_mm + h_props_total_mm
|
|
else:
|
|
h_table_col_mm = h_props_total_mm
|
|
|
|
w_total_mm = w_grid_mm + w_table_mm + 2.0 * self.total_margin_mm
|
|
h_total_mm = max(h_grid_mm, h_table_col_mm) + 2.0 * self.total_margin_mm
|
|
|
|
self.page_w_pt = w_total_mm * MM_TO_PT
|
|
self.page_h_pt = h_total_mm * MM_TO_PT
|
|
|
|
# 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:
|
|
self.minor_lines_arr = np.empty((0, 2, 2), dtype=np.float64)
|
|
|
|
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:
|
|
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
|
|
y_pt = (pt_mm[:, 1] - self.grid_min_y + self.total_margin_mm) * MM_TO_PT
|
|
return np.column_stack([x_pt, y_pt])
|
|
|
|
def export(self, filepath: str | Path | None = None) -> None:
|
|
target_path = filepath if filepath is not None else self.filepath
|
|
if target_path is None:
|
|
raise ValueError("No filepath provided for PDF export.")
|
|
|
|
s = PdfStream()
|
|
|
|
for element in self.elements:
|
|
element.render(s)
|
|
|
|
stream_bytes = s.encode()
|
|
|
|
objects: list[bytes] = []
|
|
objects.append(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n")
|
|
objects.append(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n")
|
|
page_header = (
|
|
f"3 0 obj\n"
|
|
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {self.page_w_pt:.2f} {self.page_h_pt:.2f}] "
|
|
f"/Resources << /ExtGState << /GS25 5 0 R /GS50 6 0 R /GS100 7 0 R >> /Font << /F1 8 0 R >> >> "
|
|
f"/Contents 4 0 R >>\n"
|
|
f"endobj\n"
|
|
)
|
|
objects.append(page_header.encode("utf-8"))
|
|
content_header = f"4 0 obj\n<< /Length {len(stream_bytes)} >>\nstream\n".encode("utf-8")
|
|
content_footer = b"endstream\nendobj\n"
|
|
objects.append(content_header + stream_bytes + content_footer)
|
|
objects.append(b"5 0 obj\n<< /Type /ExtGState /CA 0.25 /ca 0.25 >>\nendobj\n")
|
|
objects.append(b"6 0 obj\n<< /Type /ExtGState /CA 0.5 /ca 0.5 >>\nendobj\n")
|
|
objects.append(b"7 0 obj\n<< /Type /ExtGState /CA 1.0 /ca 1.0 >>\nendobj\n")
|
|
|
|
font_file_path = Path(__file__).parent / "fonts" / "LeagueMono-Regular.otf"
|
|
if not font_file_path.exists():
|
|
font_file_path = Path(__file__).parents[2] / "fonts" / "LeagueMono-Regular.otf"
|
|
|
|
font_bytes: bytes | None = None
|
|
if font_file_path.exists():
|
|
try:
|
|
with open(font_file_path, "rb") as f:
|
|
font_bytes = f.read()
|
|
except Exception:
|
|
font_bytes = None
|
|
|
|
if font_bytes:
|
|
widths_str = " ".join(["600"] * 224)
|
|
objects.append(
|
|
f"8 0 obj\n"
|
|
f"<< /Type /Font /Subtype /Type1 /BaseFont /LeagueMono-Regular "
|
|
f"/FirstChar 32 /LastChar 255 "
|
|
f"/Widths [ {widths_str} ] "
|
|
f"/FontDescriptor 9 0 R /Encoding /WinAnsiEncoding >>\nendobj\n".encode("utf-8")
|
|
)
|
|
objects.append(
|
|
b"9 0 obj\n"
|
|
b"<< /Type /FontDescriptor /FontName /LeagueMono-Regular /Flags 33 "
|
|
b"/FontBBox [-200 -200 1200 1000] /ItalicAngle 0 /Ascent 1000 /Descent -200 "
|
|
b"/CapHeight 700 /StemV 80 /FontFile3 10 0 R >>\nendobj\n"
|
|
)
|
|
font_stream_header = f"10 0 obj\n<< /Length {len(font_bytes)} /Subtype /OpenType >>\nstream\n".encode("utf-8")
|
|
font_stream_footer = b"\nendstream\nendobj\n"
|
|
objects.append(font_stream_header + font_bytes + font_stream_footer)
|
|
else:
|
|
objects.append(b"8 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>\nendobj\n")
|
|
|
|
header = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n"
|
|
|
|
parent_dir = os.path.dirname(target_path)
|
|
if parent_dir:
|
|
os.makedirs(parent_dir, exist_ok=True)
|
|
|
|
with open(target_path, "wb") as f:
|
|
f.write(header)
|
|
offsets = [0]
|
|
current_offset = len(header)
|
|
|
|
for obj_bytes in objects:
|
|
offsets.append(current_offset)
|
|
f.write(obj_bytes)
|
|
current_offset += len(obj_bytes)
|
|
|
|
xref_offset = current_offset
|
|
f.write(b"xref\n")
|
|
f.write(f"0 {len(offsets)}\n".encode("utf-8"))
|
|
f.write(b"0000000000 65535 f \n")
|
|
for off in offsets[1:]:
|
|
f.write(f"{off:010d} 00000 n \n".encode("utf-8"))
|
|
|
|
f.write(b"trailer\n")
|
|
f.write(f"<< /Size {len(offsets)} /Root 1 0 R >>\n".encode("utf-8"))
|
|
f.write(b"startxref\n")
|
|
f.write(f"{xref_offset}\n".encode("utf-8"))
|
|
f.write(b"%%EOF\n")
|