From 46582f82d64cdcf4ba7969964aaa213af6b0e408 Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Thu, 30 Jul 2026 14:15:02 +0300 Subject: [PATCH] sa2 --- AGENTS.md | 48 +++++++++++++++++++++++ core/nodeslicer/nodes/output.py | 47 ++++++++++++++++------ core/pdf_exporter/sa2_measures.py | 44 ++++++++++++++------- core/pdf_exporter/writer.py | 65 ++++++++++++++++++++++--------- doc/NODES.md | 8 +++- tests/test_pdf_exporter.py | 15 +++++-- 6 files changed, 177 insertions(+), 50 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7e4c364 --- /dev/null +++ b/AGENTS.md @@ -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` diff --git a/core/nodeslicer/nodes/output.py b/core/nodeslicer/nodes/output.py index b577587..40d990c 100644 --- a/core/nodeslicer/nodes/output.py +++ b/core/nodeslicer/nodes/output.py @@ -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, diff --git a/core/pdf_exporter/sa2_measures.py b/core/pdf_exporter/sa2_measures.py index b871df3..6cfae68 100644 --- a/core/pdf_exporter/sa2_measures.py +++ b/core/pdf_exporter/sa2_measures.py @@ -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") diff --git a/core/pdf_exporter/writer.py b/core/pdf_exporter/writer.py index 32e9e36..10e08fe 100644 --- a/core/pdf_exporter/writer.py +++ b/core/pdf_exporter/writer.py @@ -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") diff --git a/doc/NODES.md b/doc/NODES.md index f4f8923..65f15a2 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -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. diff --git a/tests/test_pdf_exporter.py b/tests/test_pdf_exporter.py index dd6200e..ee20355 100644 --- a/tests/test_pdf_exporter.py +++ b/tests/test_pdf_exporter.py @@ -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))