mesh 2 pdf changes
This commit is contained in:
parent
f1d93bd98c
commit
5aa1345027
10 changed files with 632 additions and 46 deletions
6
.vscode/extensions.json
vendored
Normal file
6
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance"
|
||||
]
|
||||
}
|
||||
45
.vscode/settings.json
vendored
45
.vscode/settings.json
vendored
|
|
@ -1,23 +1,34 @@
|
|||
{
|
||||
"search.exclude": {
|
||||
"**/.venv": true,
|
||||
"**/release": true,
|
||||
"**/wheels": true,
|
||||
"**/__pycache__": true,
|
||||
"**/*.blend": true,
|
||||
"**/*.blend1": true,
|
||||
"**/*.bgcode": true,
|
||||
"**/*.whl": true,
|
||||
"**/*.zip": true,
|
||||
"profiles/prusaslicer/Link to Configs": true,
|
||||
"tests/Link to Configs": true
|
||||
},
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
|
||||
"python.analysis.extraPaths": [
|
||||
"${workspaceFolder}",
|
||||
"${workspaceFolder}/.venv/lib/python3.13/site-packages"
|
||||
],
|
||||
"python.analysis.autoSearchPaths": true,
|
||||
"python.analysis.typeCheckingMode": "off",
|
||||
"python.testing.unittestEnabled": true,
|
||||
"python.testing.unittestArgs": [
|
||||
"-v",
|
||||
"-s",
|
||||
"./tests",
|
||||
"-p",
|
||||
"test_*.py"
|
||||
],
|
||||
"python.testing.pytestEnabled": false,
|
||||
"files.exclude": {
|
||||
"**/.venv": true,
|
||||
"**/release": true,
|
||||
"**/wheels": true,
|
||||
"**/.git": true,
|
||||
"**/.svn": true,
|
||||
"**/.hg": true,
|
||||
"**/CVS": true,
|
||||
"**/.DS_Store": true,
|
||||
"**/Thumbs.db": true,
|
||||
"**/__pycache__": true,
|
||||
"**/*.pyc": true
|
||||
},
|
||||
"search.followSymlinks": false
|
||||
"files.watcherExclude": {
|
||||
"**/.git/objects/**": true,
|
||||
"**/.git/subtree-cache/**": true,
|
||||
"**/.venv/**": true,
|
||||
"**/release/**": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -408,16 +408,24 @@ def _copy_slicing_object(so: SlicingObject) -> SlicingObject:
|
|||
new_so.mesh = so.mesh.copy() if so.mesh is not None else np.empty((0, 4, 3))
|
||||
new_so.attributes = {}
|
||||
for k, v in so.attributes.items():
|
||||
new_so.attributes[k] = {
|
||||
"domain": v["domain"],
|
||||
"type": v["type"],
|
||||
"values": v["values"].copy() if isinstance(v["values"], np.ndarray) else v["values"]
|
||||
}
|
||||
if isinstance(v, dict):
|
||||
new_so.attributes[k] = {
|
||||
"domain": v.get("domain", "face"),
|
||||
"type": v.get("type", "string"),
|
||||
"values": v["values"].copy() if isinstance(v.get("values"), np.ndarray) else v.get("values")
|
||||
}
|
||||
elif isinstance(v, np.ndarray):
|
||||
new_so.attributes[k] = v.copy()
|
||||
elif isinstance(v, list):
|
||||
new_so.attributes[k] = list(v)
|
||||
else:
|
||||
new_so.attributes[k] = copy.deepcopy(v)
|
||||
new_so.object_type = so.object_type
|
||||
new_so.modifiers = so.modifiers.copy() if so.modifiers else []
|
||||
new_so.extruder = so.extruder
|
||||
new_so.transforms = [dict(t) for t in getattr(so, "transforms", [])]
|
||||
new_so.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties()
|
||||
new_so.object_properties = list(getattr(so, "object_properties", []))
|
||||
return new_so
|
||||
|
||||
def _copy_slicing_collection(sc: SlicingCollection) -> SlicingCollection:
|
||||
|
|
@ -566,34 +574,71 @@ class SLICENODE_DropGeometry(SliceNode):
|
|||
return copied_data
|
||||
|
||||
|
||||
def format_name_template(template: str, so: Any, default_old_name: str = "") -> str:
|
||||
if not template or "{" not in template or "}" not in template:
|
||||
return template
|
||||
|
||||
import re
|
||||
from ....core.pdf_exporter.metadata import get_first_attribute_value
|
||||
|
||||
def replace_match(match: re.Match) -> str:
|
||||
attr_name = match.group(1).strip()
|
||||
val = get_first_attribute_value(so, attr_name)
|
||||
if val is None and attr_name in ("Name", "name"):
|
||||
val = getattr(so, "name", "") or default_old_name
|
||||
if val is None:
|
||||
return ""
|
||||
if isinstance(val, float):
|
||||
return f"{val:g}"
|
||||
return str(val)
|
||||
|
||||
return re.sub(r"\{([^}]+)\}", replace_match, template)
|
||||
|
||||
|
||||
def _rename_slicing_item(geom: Any, new_name: str) -> Any:
|
||||
if geom is None or not new_name:
|
||||
return geom
|
||||
elif isinstance(geom, (list, tuple)):
|
||||
return [_rename_slicing_item(item, f"{new_name}_{i}" if len(geom) > 1 else new_name) for i, item in enumerate(geom)]
|
||||
return [_rename_slicing_item(item, f"{new_name}_{i}" if (len(geom) > 1 and "{" not in new_name) else new_name) for i, item in enumerate(geom)]
|
||||
elif isinstance(geom, SlicingGroup):
|
||||
copied = _copy_slicing_group(geom)
|
||||
old_colls = dict(copied.collections)
|
||||
new_colls = {}
|
||||
for i, (old_key, coll) in enumerate(old_colls.items()):
|
||||
coll_name = f"{new_name}_{i}" if len(old_colls) > 1 else new_name
|
||||
if coll.objects:
|
||||
coll_name = format_name_template(new_name, coll.objects[0], default_old_name=old_key)
|
||||
else:
|
||||
coll_name = format_name_template(new_name, None, default_old_name=old_key)
|
||||
if len(old_colls) > 1 and "{" not in new_name:
|
||||
coll_name = f"{coll_name}_{i}"
|
||||
coll.name = coll_name
|
||||
for obj in coll.objects:
|
||||
for j, obj in enumerate(coll.objects):
|
||||
obj_name = format_name_template(new_name, obj, default_old_name=obj.name)
|
||||
if (len(coll.objects) > 1 or len(old_colls) > 1) and "{" not in new_name:
|
||||
obj_name = f"{obj_name}_{j}"
|
||||
obj.name = obj_name
|
||||
obj.parent = coll_name
|
||||
obj.name = coll_name
|
||||
new_colls[coll_name] = coll
|
||||
copied.collections = new_colls
|
||||
return copied
|
||||
elif isinstance(geom, SlicingCollection):
|
||||
copied = _copy_slicing_collection(geom)
|
||||
copied.name = new_name
|
||||
for obj in copied.objects:
|
||||
obj.parent = new_name
|
||||
obj.name = new_name
|
||||
old_coll_name = geom.name
|
||||
if copied.objects:
|
||||
coll_name = format_name_template(new_name, copied.objects[0], default_old_name=old_coll_name)
|
||||
else:
|
||||
coll_name = format_name_template(new_name, None, default_old_name=old_coll_name)
|
||||
copied.name = coll_name
|
||||
for i, obj in enumerate(copied.objects):
|
||||
obj_name = format_name_template(new_name, obj, default_old_name=obj.name)
|
||||
if len(copied.objects) > 1 and "{" not in new_name:
|
||||
obj_name = f"{obj_name}_{i}"
|
||||
obj.name = obj_name
|
||||
obj.parent = coll_name
|
||||
return copied
|
||||
elif isinstance(geom, SlicingObject):
|
||||
copied = _copy_slicing_object(geom)
|
||||
copied.name = new_name
|
||||
copied.name = format_name_template(new_name, copied, default_old_name=geom.name)
|
||||
return copied
|
||||
return geom
|
||||
|
||||
|
|
@ -693,3 +738,65 @@ class SLICENODE_AssignPdfProperties(SliceNode):
|
|||
return _assign_pdf_properties_to_item(geom, lw, lc, sn)
|
||||
|
||||
|
||||
def _assign_properties_to_item(geom: Any, props: list[tuple[str, str]]) -> Any:
|
||||
from ....core.pdf_exporter.metadata import resolve_object_properties
|
||||
if geom is None:
|
||||
return None
|
||||
elif isinstance(geom, (list, tuple)):
|
||||
return [_assign_properties_to_item(item, props) for item in geom]
|
||||
elif isinstance(geom, SlicingGroup):
|
||||
copied = _copy_slicing_group(geom)
|
||||
for coll in copied.collections.values():
|
||||
for obj in coll.objects:
|
||||
obj.object_properties = resolve_object_properties(obj, props)
|
||||
return copied
|
||||
elif isinstance(geom, SlicingCollection):
|
||||
copied = _copy_slicing_collection(geom)
|
||||
for obj in copied.objects:
|
||||
obj.object_properties = resolve_object_properties(obj, props)
|
||||
return copied
|
||||
elif isinstance(geom, SlicingObject):
|
||||
copied = _copy_slicing_object(geom)
|
||||
copied.object_properties = resolve_object_properties(copied, props)
|
||||
return copied
|
||||
return geom
|
||||
|
||||
|
||||
@register_class
|
||||
class SLICENODE_AssignPropertyToObject(SliceNode):
|
||||
bl_label = "Assign Property to Object"
|
||||
|
||||
def init(self, context):
|
||||
self.add_input("SLICESOCK_Mesh", "Geometry")
|
||||
self.add_input("SLICESOCK_Settings", "Settings")
|
||||
self.add_output("SLICESOCK_Mesh", "Geometry")
|
||||
|
||||
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
|
||||
if socket_name != "Geometry":
|
||||
return None
|
||||
|
||||
geom: Any = self.resolve("Geometry")
|
||||
if not geom:
|
||||
return None
|
||||
|
||||
raw_settings = self.resolve("Settings")
|
||||
props: list[tuple[str, str]] = []
|
||||
|
||||
if raw_settings:
|
||||
if isinstance(raw_settings, dict):
|
||||
for k, v in raw_settings.items():
|
||||
props.append((str(k), str(v)))
|
||||
elif isinstance(raw_settings, (list, tuple)):
|
||||
for item in raw_settings:
|
||||
if isinstance(item, tuple) and len(item) == 2:
|
||||
props.append((str(item[0]), str(item[1])))
|
||||
elif isinstance(item, dict):
|
||||
for k, v in item.items():
|
||||
props.append((str(k), str(v)))
|
||||
else:
|
||||
props.append(("", str(item)))
|
||||
|
||||
return _assign_properties_to_item(geom, props)
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -500,6 +500,7 @@ class SLICENODE_MT_Geometry(Menu):
|
|||
layout.operator("node.add_node", text="Get Mesh").type = "SLICENODE_GetMesh"
|
||||
layout.operator("node.add_node", text="Rename Mesh").type = "SLICENODE_RenameMesh"
|
||||
layout.operator("node.add_node", text="Assign PDF Properties").type = "SLICENODE_AssignPdfProperties"
|
||||
layout.operator("node.add_node", text="Assign Property to Object").type = "SLICENODE_AssignPropertyToObject"
|
||||
layout.operator("node.add_node", text="Transform Geometry").type = "SLICENODE_TransformGeometry"
|
||||
layout.operator("node.add_node", text="Drop Geometry").type = "SLICENODE_DropGeometry"
|
||||
layout.operator("node.add_node", text="Separate Mesh").type = "SLICENODE_SeparateMesh"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,29 @@ def get_first_attribute_value(so_item: Any, attr_name: str) -> Any:
|
|||
return val
|
||||
|
||||
|
||||
def resolve_object_properties(so_item: Any, props: list[tuple[str, str]]) -> list[tuple[str, str]]:
|
||||
"""
|
||||
Resolves property tuples for a SlicingObject.
|
||||
If the first position (p1) exists as an attribute on so_item and the second position (p2) is empty,
|
||||
replaces p2 with the value of that attribute at index zero.
|
||||
"""
|
||||
resolved: list[tuple[str, str]] = []
|
||||
for p1, p2 in props:
|
||||
if p1 and (not p2 or p2 == ""):
|
||||
attr_val = get_first_attribute_value(so_item, p1)
|
||||
if attr_val is not None:
|
||||
if isinstance(attr_val, float):
|
||||
p2_val = f"{attr_val:g}"
|
||||
else:
|
||||
p2_val = str(attr_val)
|
||||
resolved.append((p1, p2_val))
|
||||
else:
|
||||
resolved.append((p1, p2))
|
||||
else:
|
||||
resolved.append((p1, p2))
|
||||
return resolved
|
||||
|
||||
|
||||
def extract_settings_items(settings: Any) -> list[tuple[str, str]]:
|
||||
"""
|
||||
Parses settings dict or object into key/suffix list.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from .metadata import (
|
|||
extract_object_bounding_boxes,
|
||||
extract_settings_items,
|
||||
get_first_attribute_value,
|
||||
resolve_object_properties,
|
||||
)
|
||||
from .geometry import (
|
||||
extract_unique_top_view_edges,
|
||||
|
|
@ -54,9 +55,9 @@ def render_technical_header_table(
|
|||
h_table_mm = cell_height_mm * 3
|
||||
|
||||
x1_pt = page_w_pt - (total_margin_mm * MM_TO_PT)
|
||||
y0_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)
|
||||
y1_pt = y0_pt + (h_table_mm * MM_TO_PT)
|
||||
y0_pt = y1_pt - (h_table_mm * MM_TO_PT)
|
||||
|
||||
# Fill background box
|
||||
br, bg, bb = bg_color
|
||||
|
|
@ -146,6 +147,142 @@ def render_technical_header_table(
|
|||
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 hasattr(so, "object_properties") and so.object_properties
|
||||
]
|
||||
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
|
||||
objects = []
|
||||
|
|
@ -469,9 +606,21 @@ class PDFObjectLabelsElement(PDFElement):
|
|||
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:
|
||||
|
|
@ -488,7 +637,7 @@ class PDFObjectLabelsElement(PDFElement):
|
|||
|
||||
class PDFHeaderTableElement(PDFElement):
|
||||
"""
|
||||
Renders ISO/DIN technical drawing header table at bottom-right corner.
|
||||
Renders ISO/DIN technical drawing header table at top-right corner.
|
||||
"""
|
||||
def render(self, s: PdfStream) -> None:
|
||||
exp = self.exporter
|
||||
|
|
@ -515,6 +664,30 @@ class PDFHeaderTableElement(PDFElement):
|
|||
)
|
||||
|
||||
|
||||
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
|
||||
|
|
@ -530,6 +703,7 @@ class export_mesh_to_pdf:
|
|||
LegendElement = PDFLegendElement
|
||||
ObjectLabelsElement = PDFObjectLabelsElement
|
||||
HeaderTableElement = PDFHeaderTableElement
|
||||
ObjectPropertiesTableElement = PDFObjectPropertiesTableElement
|
||||
|
||||
# Convenient aliases for element subclasses
|
||||
Legend = PDFLegendElement
|
||||
|
|
@ -541,6 +715,7 @@ class export_mesh_to_pdf:
|
|||
SA2Measures = PDFSA2MeasuresElement
|
||||
ObjectLabels = PDFObjectLabelsElement
|
||||
Background = PDFBackgroundElement
|
||||
ObjectPropertiesTable = PDFObjectPropertiesTableElement
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -576,6 +751,8 @@ class export_mesh_to_pdf:
|
|||
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.show_bbox_size = show_bbox_size
|
||||
|
|
@ -600,12 +777,15 @@ class export_mesh_to_pdf:
|
|||
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:
|
||||
self.slicing_objs = get_all_slicing_objects(self.raw_geometry)
|
||||
|
||||
edge_list: list[NDArray[np.float64]] = []
|
||||
|
||||
if self.draw_wireframe and len(self.edges_2d) > 0:
|
||||
|
|
@ -668,16 +848,42 @@ class export_mesh_to_pdf:
|
|||
|
||||
self.total_margin_mm = self.margin_mm + 2.0 * self.minor_step
|
||||
|
||||
w_total_mm = w_grid_mm + 2.0 * self.total_margin_mm
|
||||
h_total_mm = h_grid_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
|
||||
|
||||
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 hasattr(so, "object_properties") and so.object_properties
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
max_grid_steps = 2000
|
||||
|
||||
# Vectorized vertical grid lines
|
||||
|
|
@ -821,3 +1027,8 @@ 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
|
||||
|
||||
|
|
|
|||
|
|
@ -60,13 +60,16 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
|
|||
|
||||
### Geometry Processing
|
||||
- **Get Mesh**: Converts a Blender Object into internal `SlicingObject` format.
|
||||
- **Rename Mesh**: Overrides the internal metadata name of a `SlicingObject`, `SlicingCollection`, or `SlicingGroup`.
|
||||
- **Rename Mesh**: Overrides the internal metadata name of a `SlicingObject`, `SlicingCollection`, or `SlicingGroup`. Supports `{AttributeName}` attribute placeholders (e.g., `Rebar-L{Rebar Length}M`) that evaluate to index zero of the target attribute for each object, as well as `{Name}` to include the item's previous name (e.g., `{Name}_T{Thickness}mm`).
|
||||
- **Geometry**: Input geometry data.
|
||||
- **Name**: String input socket or property for the new name.
|
||||
- **Name**: String input socket or property for the new name template.
|
||||
- **Assign PDF Properties**: Assigns line width (mm) and line color PDF export properties to all `SlicingObject` instances within a `SlicingObject`, `SlicingCollection`, or `SlicingGroup`.
|
||||
- **Geometry**: Input geometry socket.
|
||||
- **Line Width**: Input socket / float property for line width in mm.
|
||||
- **Line Color**: Color property for stroke color.
|
||||
- **Assign Property to Object**: Assigns custom settings/properties to each object in a `SlicingObject`, `SlicingCollection`, or `SlicingGroup` for rendering in PDF properties tables.
|
||||
- **Geometry**: Input geometry socket.
|
||||
- **Settings**: Input socket accepting configuration settings or property items.
|
||||
- **Transform Geometry**: Moves, rotates, and scales a `SlicingCollection` or `SlicingGroup` relative to the origin.
|
||||
- **Mesh**: Input mesh data.
|
||||
- **Translation**: 3D vector specifying the translation offset.
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ class SlicingObject():
|
|||
modifiers: list[dict] = []
|
||||
extruder: int = 1
|
||||
pdf_properties: PdfProperties
|
||||
object_properties: list[tuple[str, str]]
|
||||
|
||||
def __init__(self, obj: Object | None = None, parent: str = "") -> None:
|
||||
if not bpy.context.scene: raise Exception('No scene currently open!')
|
||||
|
|
@ -115,6 +116,7 @@ class SlicingObject():
|
|||
self.modifiers = []
|
||||
self.transforms = []
|
||||
self.pdf_properties = PdfProperties()
|
||||
self.object_properties = []
|
||||
|
||||
assert bpy.context.scene.unit_settings
|
||||
scene_scale: float = bpy.context.scene.unit_settings.scale_length
|
||||
|
|
@ -141,6 +143,7 @@ class SlicingObject():
|
|||
merged.object_type = objects[0].object_type
|
||||
merged.extruder = objects[0].extruder
|
||||
merged.pdf_properties = copy.deepcopy(objects[0].pdf_properties) if hasattr(objects[0], "pdf_properties") and objects[0].pdf_properties else PdfProperties()
|
||||
merged.object_properties = list(getattr(objects[0], "object_properties", []))
|
||||
merged.transforms = []
|
||||
for o in objects:
|
||||
for t in getattr(o, "transforms", []):
|
||||
|
|
|
|||
|
|
@ -1059,7 +1059,177 @@ class TestPdfExporter(unittest.TestCase):
|
|||
self.assertNotIn("(X) Tj", content)
|
||||
self.assertNotIn("(Y) Tj", content)
|
||||
|
||||
def test_header_table_positioned_to_right_of_drawing(self):
|
||||
edges_2d = np.array([[[0.0, 0.0], [10.0, 0.0]]], dtype=np.float64)
|
||||
exp_no_header = export_mesh_to_pdf(edges_2d=edges_2d, draw_wireframe=True, draw_header=False)
|
||||
exp_with_header = export_mesh_to_pdf(edges_2d=edges_2d, draw_wireframe=True, draw_header=True, header_info={})
|
||||
|
||||
w_table_pt = exp_with_header.font_size_pt * 25.0
|
||||
# Page width with header is wider by the exact width of the table
|
||||
self.assertAlmostEqual(exp_with_header.page_w_pt, exp_no_header.page_w_pt + w_table_pt, places=4)
|
||||
|
||||
# Drawing grid right edge in PDF points
|
||||
w_grid_mm = exp_with_header.grid_max_x - exp_with_header.grid_min_x
|
||||
grid_right_pt = (exp_with_header.total_margin_mm + w_grid_mm) * MM_TO_PT
|
||||
|
||||
# Table left edge in PDF points
|
||||
table_left_pt = exp_with_header.page_w_pt - (exp_with_header.total_margin_mm * MM_TO_PT) - w_table_pt
|
||||
|
||||
# Table left edge aligns precisely with drawing grid right edge
|
||||
self.assertAlmostEqual(table_left_pt, grid_right_pt, places=4)
|
||||
|
||||
# Drawing grid top edge & table top edge in PDF points
|
||||
grid_top_pt = exp_with_header.page_h_pt - (exp_with_header.total_margin_mm * MM_TO_PT)
|
||||
table_top_pt = exp_with_header.page_h_pt - (exp_with_header.total_margin_mm * MM_TO_PT)
|
||||
self.assertAlmostEqual(table_top_pt, grid_top_pt, places=4)
|
||||
|
||||
def test_assign_property_to_object_node_and_helper(self):
|
||||
from NodeSlicer.core.nodeslicer.nodes.mesh import _assign_properties_to_item, SLICENODE_AssignPropertyToObject
|
||||
|
||||
so = SlicingObject()
|
||||
so.name = "TestBracket"
|
||||
so.mesh = np.array([[[0, 0, 0], [10, 0, 0], [0, 10, 0], [0, 0, 1]]], dtype=np.float64)
|
||||
|
||||
sc = SlicingCollection([], "Coll1")
|
||||
sc.objects = [so]
|
||||
sg = SlicingGroup([])
|
||||
sg.collections = {"Coll1": sc}
|
||||
|
||||
props = [("", "Text Only Property"), ("Infill", "40%"), ("Layer", "0.2mm")]
|
||||
updated_sg = _assign_properties_to_item(sg, props)
|
||||
|
||||
for coll in updated_sg.collections.values():
|
||||
for obj in coll.objects:
|
||||
self.assertEqual(obj.object_properties, props)
|
||||
|
||||
def test_export_pdf_object_properties_table(self):
|
||||
so1 = SlicingObject()
|
||||
so1.name = "BracketA"
|
||||
so1.mesh = np.array([[[0, 0, 0], [10, 0, 0], [0, 10, 0], [0, 0, 1]]], dtype=np.float64)
|
||||
|
||||
so2 = SlicingObject()
|
||||
so2.name = "BracketB"
|
||||
so2.mesh = np.array([[[20, 20, 0], [30, 20, 0], [20, 30, 0], [0, 0, 1]]], dtype=np.float64)
|
||||
|
||||
sc1 = SlicingCollection([], "Coll1")
|
||||
sc1.objects = [so1]
|
||||
sc2 = SlicingCollection([], "Coll2")
|
||||
sc2.objects = [so2]
|
||||
|
||||
sg = SlicingGroup([])
|
||||
sg.collections = {"Coll1": sc1, "Coll2": sc2}
|
||||
|
||||
# Assign properties to so1 only
|
||||
so1.object_properties = [("", "Material: PETG"), ("Tolerance", "ISO 2768-f")]
|
||||
|
||||
edges_2d = extract_unique_top_view_edges(sg)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
pdf_path = os.path.join(tmpdir, "obj_props_table.pdf")
|
||||
export_mesh_to_pdf(
|
||||
edges_2d,
|
||||
pdf_path,
|
||||
draw_wireframe=True,
|
||||
draw_header=True,
|
||||
header_info={"company": "Acme Inc"},
|
||||
raw_geometry=sg,
|
||||
)
|
||||
|
||||
self.assertTrue(os.path.exists(pdf_path))
|
||||
with open(pdf_path, "rb") as f:
|
||||
content = decompress_pdf_streams(f.read())
|
||||
|
||||
# Check bold object name in table
|
||||
self.assertIn("(BracketA) Tj", content)
|
||||
self.assertIn("2 Tr", content)
|
||||
|
||||
# Check single line text property
|
||||
self.assertIn("(Material: PETG) Tj", content)
|
||||
|
||||
# Check pair ID-value property
|
||||
self.assertIn("(Tolerance) Tj", content)
|
||||
self.assertIn("(ISO 2768-f) Tj", content)
|
||||
|
||||
# Object B has no properties, so no box for BracketB in object properties table
|
||||
# Note: BracketB name is also not rendered in PDF since show_name=False
|
||||
self.assertNotIn("(BracketB) Tj", content)
|
||||
|
||||
def test_assign_property_resolves_attribute_at_index_zero(self):
|
||||
from NodeSlicer.core.nodeslicer.nodes.mesh import _assign_properties_to_item
|
||||
|
||||
so = SlicingObject()
|
||||
so.name = "AttrObject"
|
||||
so.mesh = np.array([[[0, 0, 0], [10, 0, 0], [0, 10, 0], [0, 0, 1]]], dtype=np.float64)
|
||||
so.attributes = {
|
||||
"infill_density": {"domain": "face", "type": "float", "values": np.array([15.5, 15.5])},
|
||||
"material": ["ABS", "ABS"],
|
||||
}
|
||||
|
||||
sc = SlicingCollection([], "Coll")
|
||||
sc.objects = [so]
|
||||
sg = SlicingGroup([])
|
||||
sg.collections = {"Coll": sc}
|
||||
|
||||
# Tuple where 1st position exists as attribute and 2nd position is empty
|
||||
props = [("infill_density", ""), ("material", ""), ("custom_setting", "fixed_value")]
|
||||
updated_sg = _assign_properties_to_item(sg, props)
|
||||
|
||||
res_obj = updated_sg.collections["Coll"].objects[0]
|
||||
self.assertEqual(
|
||||
res_obj.object_properties,
|
||||
[("infill_density", "15.5"), ("material", "ABS"), ("custom_setting", "fixed_value")]
|
||||
)
|
||||
|
||||
edges_2d = extract_unique_top_view_edges(sg)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
pdf_path = os.path.join(tmpdir, "attr_index_zero.pdf")
|
||||
export_mesh_to_pdf(
|
||||
edges_2d,
|
||||
pdf_path,
|
||||
draw_wireframe=True,
|
||||
raw_geometry=updated_sg,
|
||||
)
|
||||
with open(pdf_path, "rb") as f:
|
||||
content = decompress_pdf_streams(f.read())
|
||||
|
||||
self.assertIn("(AttrObject) Tj", content)
|
||||
self.assertIn("(infill_density) Tj", content)
|
||||
self.assertIn("(15.5) Tj", content)
|
||||
self.assertIn("(material) Tj", content)
|
||||
self.assertIn("(ABS) Tj", content)
|
||||
|
||||
def test_object_name_drawing_white_contour(self):
|
||||
so = SlicingObject()
|
||||
so.name = "ContourObj"
|
||||
so.mesh = np.array([[[0, 0, 0], [10, 0, 0], [0, 10, 0], [0, 0, 1]]], dtype=np.float64)
|
||||
|
||||
sc = SlicingCollection([], "Coll")
|
||||
sc.objects = [so]
|
||||
sg = SlicingGroup([])
|
||||
sg.collections = {"Coll": sc}
|
||||
|
||||
edges_2d = extract_unique_top_view_edges(sg)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
pdf_path = os.path.join(tmpdir, "white_contour.pdf")
|
||||
export_mesh_to_pdf(
|
||||
edges_2d,
|
||||
pdf_path,
|
||||
show_name=True,
|
||||
draw_wireframe=True,
|
||||
raw_geometry=sg,
|
||||
)
|
||||
with open(pdf_path, "rb") as f:
|
||||
content = decompress_pdf_streams(f.read())
|
||||
|
||||
self.assertIn("(ContourObj) Tj", content)
|
||||
# Verify white fill/stroke color pass exists for contour
|
||||
self.assertIn("1.000 1.000 1.000 rg", content)
|
||||
self.assertIn("1.000 1.000 1.000 RG", content)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,26 +7,37 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|||
sys.path.append(str(PROJECT_ROOT.parent))
|
||||
|
||||
# Mock bpy and dependencies for unit testing
|
||||
class DummyNodeSocket: pass
|
||||
class DummyNode: pass
|
||||
class DummyObject:
|
||||
def __init__(self, name="TestObj"):
|
||||
self.name = name
|
||||
|
||||
class DummyMesh: pass
|
||||
|
||||
class DummyBase: pass
|
||||
mock_types = MagicMock()
|
||||
mock_types.NodeSocket = DummyNodeSocket
|
||||
mock_types.Node = DummyNode
|
||||
mock_types.NodeSocket = type("NodeSocket", (DummyBase,), {})
|
||||
mock_types.Node = type("Node", (DummyBase,), {})
|
||||
mock_types.NodeCustomGroup = type("NodeCustomGroup", (DummyBase,), {})
|
||||
mock_types.NodeTree = type("NodeTree", (DummyBase,), {})
|
||||
mock_types.PropertyGroup = type("PropertyGroup", (DummyBase,), {})
|
||||
mock_types.Operator = type("Operator", (DummyBase,), {})
|
||||
mock_types.Panel = type("Panel", (DummyBase,), {})
|
||||
mock_types.Object = DummyObject
|
||||
mock_types.Mesh = DummyMesh
|
||||
|
||||
mock_bpy = MagicMock()
|
||||
mock_bpy.context.scene = MagicMock()
|
||||
mock_bpy.context.scene.unit_settings.scale_length = 0.001
|
||||
mock_bpy.types = mock_types
|
||||
mock_bpy.path.abspath = lambda path: path.replace("//", "/abs/")
|
||||
|
||||
sys.modules['bpy'] = mock_bpy
|
||||
sys.modules['bpy.types'] = mock_types
|
||||
sys.modules['bpy.props'] = MagicMock()
|
||||
sys.modules['bpy.utils'] = MagicMock()
|
||||
sys.modules['bpy.utils.previews'] = MagicMock()
|
||||
sys.modules['bpy.app'] = MagicMock()
|
||||
sys.modules['bpy.app.handlers'] = MagicMock()
|
||||
|
||||
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingCollection, SlicingGroup
|
||||
from NodeSlicer.core.nodeslicer.nodes.mesh import _rename_slicing_item
|
||||
|
|
@ -60,6 +71,46 @@ class TestRenameMesh(unittest.TestCase):
|
|||
self.assertIn("NewGroup", renamed.collections)
|
||||
self.assertEqual(renamed.collections["NewGroup"].name, "NewGroup")
|
||||
|
||||
def test_rename_with_attribute_template(self):
|
||||
import numpy as np
|
||||
so = SlicingObject()
|
||||
so.name = "OriginalBar"
|
||||
so.attributes = {
|
||||
"Rebar Length": {"domain": "face", "type": "float", "values": np.array([12.5, 12.5])}
|
||||
}
|
||||
|
||||
renamed = _rename_slicing_item(so, "Rebar-L{Rebar Length}M")
|
||||
self.assertEqual(renamed.name, "Rebar-L12.5M")
|
||||
|
||||
def test_rename_with_multiple_attribute_placeholders(self):
|
||||
import numpy as np
|
||||
so = SlicingObject()
|
||||
so.name = "Piece"
|
||||
so.attributes = {
|
||||
"Material": ["Steel", "Steel"],
|
||||
"Length": {"domain": "face", "type": "float", "values": np.array([500.0, 500.0])}
|
||||
}
|
||||
|
||||
sc = SlicingCollection([], "Coll1")
|
||||
sc.objects = [so]
|
||||
|
||||
renamed_sc = _rename_slicing_item(sc, "Part-{Material}-{Length}mm")
|
||||
self.assertEqual(renamed_sc.name, "Part-Steel-500mm")
|
||||
self.assertEqual(renamed_sc.objects[0].name, "Part-Steel-500mm")
|
||||
|
||||
def test_rename_with_old_name_placeholder(self):
|
||||
import numpy as np
|
||||
so = SlicingObject()
|
||||
so.name = "OriginalBracket"
|
||||
so.attributes = {
|
||||
"Thickness": {"domain": "face", "type": "float", "values": np.array([3.5, 3.5])}
|
||||
}
|
||||
|
||||
renamed = _rename_slicing_item(so, "{Name}_T{Thickness}mm")
|
||||
self.assertEqual(renamed.name, "OriginalBracket_T3.5mm")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue