sa2
This commit is contained in:
parent
7e43f42c1c
commit
46582f82d6
6 changed files with 177 additions and 50 deletions
48
AGENTS.md
Normal file
48
AGENTS.md
Normal 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`
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue