This commit is contained in:
Nicolas Predella 2026-07-30 14:15:02 +03:00
parent 7e43f42c1c
commit 46582f82d6
6 changed files with 177 additions and 50 deletions

48
AGENTS.md Normal file
View file

@ -0,0 +1,48 @@
# NodeSlicer Agent Instructions
## Project Overview
NodeSlicer is a high-performance Blender extension providing a custom node-based interface for mesh slicing and G-code generation.
## Critical Context
- **Purpose**: 3D printing slicing, CNC toolpathing, and procedural geometry tasks
- **Main Technologies**: Python, Blender API (`bpy`), NumPy, Pyclipper, Shapely
- **Blender Version**: 4.2 or newer (required for the Extension platform)
- **Architecture**: Separates Blender-specific UI and data capture (`infra/`) from core geometric and slicing algorithms (`core/`)
## Development Conventions
### Registration
All Blender classes (Nodes, Sockets, Operators, Menus) must be decorated with `@register_class` from `registry.py` to ensure they are registered correctly during initialization.
### Node Evaluation
Nodes follow a pull-based evaluation model. Use `resolve_input(self.inputs.get("SocketName"))` within a node's `evaluate_output` method to trigger the evaluation of upstream nodes.
### Performance
For any geometry processing, prioritize NumPy vectorized operations over standard Python loops to maintain high performance.
### Scaling
2D polygon operations via Pyclipper use a `SCALE` factor (defined in `core/slice.py`) to convert floats to integers, as Pyclipper operates in integer space.
### Type Hinting
Use exhaustive type hints for all new functions and classes.
### Documentation
Update `doc/NODES.md` when adding new nodes and `doc/MODULES.md` when adding new modules.
## Project Structure
- `__init__.py`: Main entry point. Defines the `NodeSlicer Tree`, custom Sockets, and all Nodes.
- `registry.py`: Utility for automatic registration of Blender classes using the `@register_class` decorator.
- `core/slice.py`: Core slicing engine (plane-mesh intersection), buffering (offsetting), and infill generation.
- `core/geometry.py`: Shared geometry utility functions.
- `infra/blender_mesh_capture.py`: Logic for extracting mesh data from Blender objects into NumPy arrays.
- `infra/blender_slice_nodes_generic.py`: Base classes for the node system and the evaluation logic (`resolve_input`).
- `doc/`: Comprehensive documentation on modules and nodes.
## Commands
- Build the extension: `./build.sh` (generates a zip file in the `release/` directory)
- Install dependencies: `uv sync`
## Testing
- Tests are located in the `tests/` directory
- Run PDF exporter tests: `python -m unittest tests.test_pdf_exporter -v`
- Requires the virtual environment: `source .venv/bin/activate`

View file

@ -363,12 +363,17 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
draw_crease: NfBoolProperty(name="Crease Angle", default=False) # pyright: ignore[reportInvalidTypeForm]
crease_angle: NfFloatProperty(name="Angle (°)", default=30.0, min=0.0, max=180.0) # pyright: ignore[reportInvalidTypeForm]
draw_bbox: NfBoolProperty(name="Bounding Box", default=False) # pyright: ignore[reportInvalidTypeForm]
draw_sa2_measures: NfBoolProperty(name="SA2 measures", default=False) # pyright: ignore[reportInvalidTypeForm]
draw_sa2_holes: NfBoolProperty(name="Holes", default=False) # pyright: ignore[reportInvalidTypeForm]
draw_sa2_arcs: NfBoolProperty(name="Arcs", default=False) # pyright: ignore[reportInvalidTypeForm]
draw_sa2_chamfers: NfBoolProperty(name="Chamfers", default=False) # pyright: ignore[reportInvalidTypeForm]
draw_sa2_step_features: NfBoolProperty(name="Step Features", default=False) # pyright: ignore[reportInvalidTypeForm]
show_lineart: NfBoolProperty(name="Lineart Visibility", default=True) # pyright: ignore[reportInvalidTypeForm]
show_sa2: NfBoolProperty(name="SA2 Settings", default=True) # pyright: ignore[reportInvalidTypeForm]
show_name: NfBoolProperty(name="Show Name", default=False) # pyright: ignore[reportInvalidTypeForm]
show_bbox_size: NfBoolProperty(name="Bounding Box Sizes", default=False) # pyright: ignore[reportInvalidTypeForm]
line_width: NfFloatProperty(name="Line Width (mm)", default=0.5, min=0.01, max=50.0) # pyright: ignore[reportInvalidTypeForm]
font_size: NfFloatProperty(name="Font Size (pt)", default=8.0, min=1.0, max=100.0) # pyright: ignore[reportInvalidTypeForm]
line_width: NfFloatProperty(name="Line Width (mm)", default=0.0, min=0.0, max=50.0) # pyright: ignore[reportInvalidTypeForm]
font_size: NfFloatProperty(name="Font Size (pt)", default=0.0, min=0.0, max=100.0) # pyright: ignore[reportInvalidTypeForm]
margin: NfFloatProperty(name="Margin (mm)", default=10.0, min=0.0, max=500.0) # pyright: ignore[reportInvalidTypeForm]
bg_color: NfFloatVectorProperty(
@ -406,18 +411,35 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
def draw_buttons(self, context, layout):
super().draw_buttons(context, layout)
layout.prop(self, "draw_wireframe")
layout.prop(self, "draw_boundary")
layout.prop(self, "draw_contour")
layout.prop(self, "draw_crease")
if self.draw_crease:
layout.prop(self, "crease_angle")
# Lineart Visibility subpanel (collapsible)
layout.prop(self, "show_lineart", text="Lineart Visibility", icon='TRIA_DOWN' if self.show_lineart else 'TRIA_RIGHT')
if self.show_lineart:
box = layout.box()
box.prop(self, "draw_wireframe")
box.prop(self, "draw_boundary")
box.prop(self, "draw_contour")
box.prop(self, "draw_crease")
if self.draw_crease:
box.prop(self, "crease_angle")
# SA2 Settings subpanel (collapsible)
layout.prop(self, "show_sa2", text="SA2 Settings", icon='TRIA_DOWN' if self.show_sa2 else 'TRIA_RIGHT')
if self.show_sa2:
box = layout.box()
box.prop(self, "draw_sa2_holes")
box.prop(self, "draw_sa2_arcs")
box.prop(self, "draw_sa2_chamfers")
box.prop(self, "draw_sa2_step_features")
# Display settings
layout.prop(self, "draw_bbox")
if self.draw_bbox:
layout.prop(self, "bbox_color")
layout.prop(self, "draw_sa2_measures")
layout.prop(self, "show_name")
layout.prop(self, "show_bbox_size")
# Styling settings
layout.prop(self, "line_width")
layout.prop(self, "font_size")
layout.prop(self, "margin")
@ -477,7 +499,10 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
draw_contour=self.draw_contour,
draw_crease=self.draw_crease,
draw_bbox=self.draw_bbox,
draw_sa2_measures=self.draw_sa2_measures,
draw_sa2_holes=self.draw_sa2_holes,
draw_sa2_arcs=self.draw_sa2_arcs,
draw_sa2_chamfers=self.draw_sa2_chamfers,
draw_sa2_step_features=self.draw_sa2_step_features,
show_name=self.show_name,
show_bbox_size=self.show_bbox_size,
crease_angle=self.crease_angle,

View file

@ -270,24 +270,39 @@ def extract_slicing_object_features(so_item: Any) -> tuple[
def render_sa2_measures_pdf(
stream_cmds: list[str],
raw_geometry: Any,
draw_sa2_measures: bool,
line_width_pt: float,
main_step: float,
to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]],
font_size_pt: float | None = None
draw_sa2_holes: bool = False,
draw_sa2_arcs: bool = False,
draw_sa2_chamfers: bool = False,
draw_sa2_step_features: bool = False,
minor_step_mm: float = 1.0,
main_step: float = 10.0,
to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]] | None = None,
font_size_pt: float | None = None,
line_width_pt: float | None = None
) -> None:
"""
Renders ISO 129-1 compliant SA2 automated technical measurements on PDF stream.
"""
if not draw_sa2_measures or raw_geometry is None:
if raw_geometry is None:
return
# Check if any SA2 feature is enabled
if not (draw_sa2_holes or draw_sa2_arcs or draw_sa2_chamfers or draw_sa2_step_features):
return
sa2_objects = extract_slicing_objects_bounds(raw_geometry)
if not sa2_objects:
return
# Automatic sizing based on minor grid
MM_TO_PT = 2.834645669291339
if line_width_pt is None or line_width_pt <= 0:
line_width_pt = minor_step_mm * 0.05 * MM_TO_PT
if font_size_pt is None or font_size_pt <= 0:
font_size_pt = minor_step_mm * MM_TO_PT
stream_cmds.append("/GS100 gs")
sa2_lw_pt = max(0.4 * MM_TO_PT, line_width_pt * 0.8)
sa2_lw_pt = line_width_pt * 0.8
stream_cmds.append(f"{sa2_lw_pt:.2f} w")
stream_cmds.append("1 j 1 J")
@ -295,9 +310,8 @@ def render_sa2_measures_pdf(
red_r, red_g, red_b = 0.85, 0.15, 0.15
stream_cmds.append(f"{red_r:.3f} {red_g:.3f} {red_b:.3f} RG")
if font_size_pt is None or font_size_pt <= 0:
font_size_pt = max(8.0, line_width_pt * 4.0)
arrow_len_pt = max(3.5 * MM_TO_PT, line_width_pt * 4.0)
# SA2 arrow length: scale with line width (which is based on minor grid)
arrow_len_pt = line_width_pt * 4.0
arrow_half_w_pt = arrow_len_pt * 0.40
for so_item, obj_name, min_x, max_x, min_y, max_y in sa2_objects:
@ -388,7 +402,7 @@ def render_sa2_measures_pdf(
holes, arcs, chamfers, step_features = extract_slicing_object_features(so_item)
# 1. ISO 129-1 Circular Bores / Hole Diameter Callouts (Ø - Electric Blue)
if holes:
if draw_sa2_holes and holes:
blue_r, blue_g, blue_b = 0.10, 0.45, 0.90
stream_cmds.append(f"{blue_r:.3f} {blue_g:.3f} {blue_b:.3f} RG")
stream_cmds.append(f"{blue_r:.3f} {blue_g:.3f} {blue_b:.3f} rg")
@ -425,7 +439,7 @@ def render_sa2_measures_pdf(
stream_cmds.append("ET")
# 2. ISO 129-1 Arc Cutouts & Corner Fillets (R - Amber Orange)
if arcs:
if draw_sa2_arcs and arcs:
amber_r, amber_g, amber_b = 0.90, 0.45, 0.10
stream_cmds.append(f"{amber_r:.3f} {amber_g:.3f} {amber_b:.3f} RG")
stream_cmds.append(f"{amber_r:.3f} {amber_g:.3f} {amber_b:.3f} rg")
@ -462,7 +476,7 @@ def render_sa2_measures_pdf(
stream_cmds.append("ET")
# 3. ISO 129-1 Bevels & Chamfers (C / 45° - Violet Purple)
if chamfers:
if draw_sa2_chamfers and chamfers:
purple_r, purple_g, purple_b = 0.60, 0.20, 0.85
stream_cmds.append(f"{purple_r:.3f} {purple_g:.3f} {purple_b:.3f} RG")
stream_cmds.append(f"{purple_r:.3f} {purple_g:.3f} {purple_b:.3f} rg")
@ -498,7 +512,7 @@ def render_sa2_measures_pdf(
stream_cmds.append("ET")
# 4. Hole Center Pitch Distances (Teal Green)
if len(holes) >= 2:
if draw_sa2_holes and len(holes) >= 2:
teal_r, teal_g, teal_b = 0.05, 0.58, 0.53
stream_cmds.append(f"{teal_r:.3f} {teal_g:.3f} {teal_b:.3f} RG")
stream_cmds.append(f"{teal_r:.3f} {teal_g:.3f} {teal_b:.3f} rg")
@ -523,7 +537,7 @@ def render_sa2_measures_pdf(
stream_cmds.append("ET")
# 5. Interior Step / Notch Feature Segment Lengths (Deep Purple)
if step_features:
if draw_sa2_step_features and step_features:
purple_r, purple_g, purple_b = 0.48, 0.23, 0.92
stream_cmds.append(f"{purple_r:.3f} {purple_g:.3f} {purple_b:.3f} RG")
stream_cmds.append(f"{purple_r:.3f} {purple_g:.3f} {purple_b:.3f} rg")

View file

@ -72,7 +72,10 @@ def export_mesh_to_pdf(
draw_contour: bool = False,
draw_crease: bool = False,
draw_bbox: bool = False,
draw_sa2_measures: bool = False,
draw_sa2_holes: bool = False,
draw_sa2_arcs: bool = False,
draw_sa2_chamfers: bool = False,
draw_sa2_step_features: bool = False,
show_name: bool = False,
show_bbox_size: bool = False,
crease_angle: float = 30.0,
@ -158,26 +161,30 @@ def export_mesh_to_pdf(
pad_x = minor_step
pad_y = minor_step
half_span_x = math.ceil(max(abs((max_x + pad_x) - center_x), abs((min_x - pad_x) - center_x)) / main_step) * main_step
half_span_y = math.ceil(max(abs((max_y + pad_y) - center_y), abs((min_y - pad_y) - center_y)) / main_step) * main_step
# Calculate required grid span with padding
span_x = max(abs(max_x - center_x), abs(min_x - center_x)) + pad_x
span_y = max(abs(max_y - center_y), abs(min_y - center_y)) + pad_y
# Round up to nearest main_step
half_span_x = math.ceil(span_x / main_step) * main_step
half_span_y = math.ceil(span_y / main_step) * main_step
if half_span_x == 0:
half_span_x = main_step
if half_span_y == 0:
half_span_y = main_step
grid_min_x = math.floor((center_x - half_span_x) / main_step) * main_step
grid_max_x = math.ceil((center_x + half_span_x) / main_step) * main_step
grid_min_y = math.floor((center_y - half_span_y) / main_step) * main_step
grid_max_y = math.ceil((center_y + half_span_y) / main_step) * main_step
if min_x - grid_min_x < minor_step:
grid_min_x -= main_step
if grid_max_x - max_x < minor_step:
grid_max_x += main_step
if min_y - grid_min_y < minor_step:
grid_min_y -= main_step
if grid_max_y - max_y < minor_step:
grid_max_y += main_step
# Ensure grid is symmetric around center
grid_min_x = center_x - half_span_x
grid_max_x = center_x + half_span_x
grid_min_y = center_y - half_span_y
grid_max_y = center_y + half_span_y
# Ensure grid aligns with main_step grid lines
grid_min_x = math.floor(grid_min_x / main_step) * main_step
grid_max_x = math.ceil(grid_max_x / main_step) * main_step
grid_min_y = math.floor(grid_min_y / main_step) * main_step
grid_max_y = math.ceil(grid_max_y / main_step) * main_step
if grid_min_x == grid_max_x:
grid_max_x += main_step
@ -198,7 +205,15 @@ def export_mesh_to_pdf(
page_w_pt = w_total_mm * MM_TO_PT
page_h_pt = h_total_mm * MM_TO_PT
line_width_pt = line_width_mm * MM_TO_PT
# Automatic sizing based on minor grid
if line_width_mm <= 0:
line_width_pt = minor_step * 0.05 * MM_TO_PT
else:
line_width_pt = line_width_mm * MM_TO_PT
# Automatic font size: equal to minor grid spacing
if font_size_pt is None or font_size_pt <= 0:
font_size_pt = minor_step * MM_TO_PT
def to_pdf_pt(pt_mm: NDArray[np.float64]) -> NDArray[np.float64]:
x_pt = (pt_mm[:, 0] - grid_min_x + total_margin_mm) * MM_TO_PT
@ -316,8 +331,20 @@ def export_mesh_to_pdf(
stream_cmds.append("S")
# 5.6. SA2 Automated Technical Measures Block
if draw_sa2_measures and raw_geometry is not None:
render_sa2_measures_pdf(stream_cmds, raw_geometry, draw_sa2_measures, line_width_pt, main_step, to_pdf_pt, font_size_pt=font_size_pt)
if raw_geometry is not None and (draw_sa2_holes or draw_sa2_arcs or draw_sa2_chamfers or draw_sa2_step_features):
render_sa2_measures_pdf(
stream_cmds,
raw_geometry,
draw_sa2_holes=draw_sa2_holes,
draw_sa2_arcs=draw_sa2_arcs,
draw_sa2_chamfers=draw_sa2_chamfers,
draw_sa2_step_features=draw_sa2_step_features,
minor_step_mm=minor_step,
main_step=main_step,
to_pdf_pt=to_pdf_pt,
font_size_pt=font_size_pt,
line_width_pt=line_width_pt
)
# 6. Size Scale Legend (Bottom Left, aligned directly on bottom-left main grid lines with arrows and X/Y labels)
stream_cmds.append("/GS100 gs")

View file

@ -130,8 +130,12 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **BBox Color**: Color picker for the bounding box rectangle lines (default blue).
- **Show Name**: Checkbox to render the object name to the right of the rightmost vertex of each `SlicingObject`.
- **Bounding Box Sizes**: Checkbox to render 2D bounding box size dimensions (e.g. `X: 120mm Y: 230mm` or `X: 1.2m Y: 2.3m`) next to each object label.
- **Line Width (mm)**: Stroke width in millimeters for the exported lines.
- **Font Size (pt)**: Font size in PostScript points for all text labels and measurement callouts.
- **Holes**: Checkbox to render circular bore/hole diameter callouts (Ø) with ISO 129-1 compliant markings.
- **Arcs**: Checkbox to render arc cutouts and corner fillet radius callouts (R) with ISO 129-1 compliant markings.
- **Chamfers**: Checkbox to render bevel/chamfer callouts (C) with ISO 129-1 compliant markings.
- **Step Features**: Checkbox to render interior step/notch feature segment length callouts with ISO 129-1 compliant markings.
- **Line Width (mm)**: Stroke width in millimeters for the exported lines. Set to 0 for automatic sizing (5% of minor grid spacing).
- **Font Size (pt)**: Font size in PostScript points for all text labels and measurement callouts. Set to 0 for automatic sizing (equal to minor grid spacing).
- **Margin (mm)**: Margin around the geometry grid in mm.
- **Background**: Color picker for the canvas background.
- **Wireframe**: Color picker for the wireframe lines and grid.

View file

@ -545,7 +545,10 @@ class TestPdfExporter(unittest.TestCase):
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_sa2_measures=True,
draw_sa2_holes=True,
draw_sa2_arcs=True,
draw_sa2_chamfers=True,
draw_sa2_step_features=True,
raw_geometry=so
)
self.assertTrue(os.path.exists(pdf_path))
@ -590,7 +593,10 @@ class TestPdfExporter(unittest.TestCase):
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_sa2_measures=True,
draw_sa2_holes=True,
draw_sa2_arcs=True,
draw_sa2_chamfers=True,
draw_sa2_step_features=True,
raw_geometry=so
)
self.assertTrue(os.path.exists(pdf_path))
@ -640,7 +646,10 @@ class TestPdfExporter(unittest.TestCase):
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_sa2_measures=True,
draw_sa2_holes=True,
draw_sa2_arcs=True,
draw_sa2_chamfers=True,
draw_sa2_step_features=True,
raw_geometry=so
)
self.assertTrue(os.path.exists(pdf_path))