From 9d2d948d89394e8b248bc365a43bdabea171576e Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Wed, 29 Jul 2026 16:53:31 +0300 Subject: [PATCH] wf viz --- core/nodeslicer/nodes/output.py | 104 +++++++++++- core/nodeslicer/tree.py | 1 + core/pdf_exporter.py | 274 ++++++++++++++++++++++++++++++++ doc/NODES.md | 7 + tests/test_pdf_exporter.py | 134 ++++++++++++++++ 5 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 core/pdf_exporter.py create mode 100644 tests/test_pdf_exporter.py diff --git a/core/nodeslicer/nodes/output.py b/core/nodeslicer/nodes/output.py index c79f985..e086bfe 100644 --- a/core/nodeslicer/nodes/output.py +++ b/core/nodeslicer/nodes/output.py @@ -7,7 +7,7 @@ import tempfile import re import json -from ..bpy_ns_types import NfStringProperty +from ..bpy_ns_types import NfStringProperty, NfFloatProperty, NfEnumProperty, NfFloatVectorProperty from ....registry import register_class from .base import ExecSliceNode, SlicePaths, SliceNode, SlicingCollection, SlicingGroup @@ -350,3 +350,105 @@ class SLICENODE_AirtableSubmit(ExecSliceNode_DatabaseSubmit): db.upload_attachment(self.base_id, record_id, 'GCODE', temp_path) return str(record_id) + + +@register_class +class SLICENODE_WireframeToPdf(ExecSliceNode): + bl_label = "Wireframe to PDF" + button_label = "Generate PDF" + + line_width: NfFloatProperty(name="Line Width (mm)", default=0.5, min=.0) # pyright: ignore[reportInvalidTypeForm] + margin: NfFloatProperty(name="Margin (mm)", default=10.0, min=.0) # pyright: ignore[reportInvalidTypeForm] + + bg_color: NfFloatVectorProperty( + name="Background", + subtype='COLOR', + default=(1.0, 1.0, 1.0), + min=0.0, + max=1.0, + size=3 + ) # pyright: ignore[reportInvalidTypeForm] + + wire_color: NfFloatVectorProperty( + name="Wireframe", + subtype='COLOR', + default=(0.0, 0.0, 0.0), + min=0.0, + max=1.0, + size=3 + ) # pyright: ignore[reportInvalidTypeForm] + + def init(self, context): + self._init_exec() + self.add_input("SLICESOCK_DirPath", "Folder") + self.add_multi_socket_group("SLICESOCK_Mesh", name="Geometry") + + def draw_buttons(self, context, layout): + super().draw_buttons(context, layout) + layout.prop(self, "line_width") + layout.prop(self, "margin") + layout.prop(self, "bg_color") + layout.prop(self, "wire_color") + + def evaluate(self, operator=None): + from ...pdf_exporter import extract_unique_top_view_edges, export_wireframe_to_pdf + + geometries: list[Any] = [g for g in self.resolve_multi_input('Geometry') if g is not None] + folder: str = self.resolve("Folder") or "" + + if not geometries: + if operator: operator.report({'ERROR'}, "No Geometry connected") + return 'error' + + if not folder: + if operator: operator.report({'ERROR'}, "No Folder provided") + return 'error' + + abs_folder = bpy.path.abspath(f"//{folder}" if not folder.startswith(("/", "\\")) and ":" not in folder else folder) + folder_path = Path(abs_folder) + + is_single_pdf_file = len(geometries) == 1 and folder_path.suffix.lower() == ".pdf" + + if is_single_pdf_file: + os.makedirs(folder_path.parent, exist_ok=True) + target_list = [(geometries[0], str(folder_path))] + else: + out_dir = folder_path if folder_path.suffix == "" else folder_path.parent + os.makedirs(out_dir, exist_ok=True) + target_list = [] + for i, geom in enumerate(geometries): + if isinstance(geom, SlicingGroup) and geom.collections: + raw_name = "_".join(geom.collections.keys()) + elif hasattr(geom, 'name') and getattr(geom, 'name'): + raw_name = str(getattr(geom, 'name')) + else: + raw_name = f"object_{i}" + + name_part = raw_name.split('.')[0] if raw_name else f"object_{i}" + filename = f"{name_part}.pdf" + target_list.append((geom, str(out_dir / filename))) + + for geom, filepath in target_list: + edges_2d = extract_unique_top_view_edges(geom) + if len(edges_2d) == 0: + if operator: operator.report({'WARNING'}, f"Geometry for {os.path.basename(filepath)} has no valid 2D top-view wireframe edges") + + try: + export_wireframe_to_pdf( + edges_2d=edges_2d, + filepath=filepath, + margin_mm=self.margin, + line_width_mm=self.line_width, + stroke_color=(float(self.wire_color[0]), float(self.wire_color[1]), float(self.wire_color[2])), + bg_color=(float(self.bg_color[0]), float(self.bg_color[1]), float(self.bg_color[2])) + ) + if operator: operator.report({'INFO'}, f"Wireframe PDF saved to {filepath}") + except Exception as e: + if operator: operator.report({'ERROR'}, f"PDF Generation Error: {str(e)}") + return 'error' + + return 'ok' + + + + diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index fd4be57..e80db27 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -560,6 +560,7 @@ class SLICENODE_MT_ActionIO(Menu): layout = self.layout assert layout layout.operator("node.add_node", text="Gcode to File").type = "SLICENODE_GcodeToFile" + layout.operator("node.add_node", text="Wireframe to PDF").type = "SLICENODE_WireframeToPdf" layout.operator("node.add_node", text="Gcode to Text Block").type = "SLICENODE_GcodeToTextBlock" layout.operator("node.add_node", text="Start Print").type = "SLICENODE_StartPrint" layout.separator() diff --git a/core/pdf_exporter.py b/core/pdf_exporter.py new file mode 100644 index 0000000..a6a82fd --- /dev/null +++ b/core/pdf_exporter.py @@ -0,0 +1,274 @@ +from __future__ import annotations +from typing import Any +import os +import math +import numpy as np +from numpy.typing import NDArray + +MM_TO_PT = 72.0 / 25.4 # 1 mm = 2.8346456692913386 PostScript points + + +def extract_unique_top_view_edges(mesh_data: Any) -> NDArray[np.float64]: + """ + Extracts unique 2D line segments projected orthographically from top (+Z). + Handles SlicingGroup, SlicingCollection, SlicingObject, or lists/dicts of them. + Returns array of shape (N, 2, 2) where each element is [(x0, y0), (x1, y1)] in mm. + """ + from ..infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject + + all_edge_segments: list[NDArray[np.float64]] = [] + + def process_item(item: Any): + if item is None: + return + 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): + all_verts, all_idxs = item.unique_verts + if len(all_idxs) > 0: + edges = np.vstack([ + all_idxs[:, [0, 1]], + all_idxs[:, [1, 2]], + all_idxs[:, [2, 0]] + ]) + sorted_edges = np.sort(edges, axis=1) + unique_edge_indices = np.unique(sorted_edges, axis=0) + edge_3d = all_verts[unique_edge_indices] # shape (E, 2, 3) + all_edge_segments.append(edge_3d[:, :, :2]) # top view (X, Y) + elif isinstance(item, SlicingObject): + if item.mesh.size > 0: + verts_3d = item.mesh[:, :3, :3] # (N, 3, 3) + verts_2d = verts_3d[:, :, :2] # (N, 3, 2) + e0 = verts_2d[:, [0, 1], :] + e1 = verts_2d[:, [1, 2], :] + e2 = verts_2d[:, [2, 0], :] + edges_2d = np.concatenate([e0, e1, e2], axis=0) # (3N, 2, 2) + all_edge_segments.append(edges_2d) + + process_item(mesh_data) + + if not all_edge_segments: + return np.empty((0, 2, 2), dtype=np.float64) + + combined_edges = np.concatenate(all_edge_segments, axis=0) + if combined_edges.shape[0] == 0: + return np.empty((0, 2, 2), dtype=np.float64) + + # Filter out near-zero-length 2D edges (e.g. vertical edges seen from top) + dists_sq = np.sum((combined_edges[:, 0, :] - combined_edges[:, 1, :]) ** 2, axis=1) + valid_mask = dists_sq > 1e-8 + valid_edges = combined_edges[valid_mask] + if valid_edges.shape[0] == 0: + return np.empty((0, 2, 2), dtype=np.float64) + + # Deduplicate 2D edges regardless of endpoint orientation + rounded = np.round(valid_edges, decimals=5) + swap_mask = (rounded[:, 0, 0] > rounded[:, 1, 0]) | ( + (rounded[:, 0, 0] == rounded[:, 1, 0]) & (rounded[:, 0, 1] > rounded[:, 1, 1]) + ) + ordered = valid_edges.copy() + ordered[swap_mask] = ordered[swap_mask][:, ::-1, :] + ordered_rounded = rounded.copy() + ordered_rounded[swap_mask] = ordered_rounded[swap_mask][:, ::-1, :] + + view = ordered_rounded.reshape(len(ordered_rounded), -1) + _, unique_indices = np.unique(view, axis=0, return_index=True) + return ordered[unique_indices] + + +def export_wireframe_to_pdf( + edges_2d: NDArray[np.float64], + filepath: str, + margin_mm: float = 10.0, + line_width_mm: float = 0.5, + stroke_color: tuple[float, float, float] = (0.0, 0.0, 0.0), + bg_color: tuple[float, float, float] = (1.0, 1.0, 1.0) +) -> None: + """ + Exports 2D wireframe line segments to a 1:1 true physical scale vector PDF file. + - PDF page dimensions automatically match the physical size of the geometry + grid + margin. + - line_width_mm is specified in physical millimeters and converted to PDF points (1 mm = 72/25.4 pt). + """ + if len(edges_2d) > 0: + min_x = float(edges_2d[:, :, 0].min()) + max_x = float(edges_2d[:, :, 0].max()) + min_y = float(edges_2d[:, :, 1].min()) + max_y = float(edges_2d[:, :, 1].max()) + else: + min_x = max_x = min_y = max_y = 0.0 + + w_geom = max_x - min_x + h_geom = max_y - min_y + + L = max(w_geom, h_geom) + if L > 1e-6: + step_raw = L / 10.0 + exponent = int(np.round(np.log10(step_raw))) + main_step = 10.0 ** exponent + else: + main_step = 10.0 + + minor_step = main_step / 10.0 + + if len(edges_2d) > 0: + grid_min_x = math.floor(min_x / main_step) * main_step + grid_max_x = math.ceil(max_x / main_step) * main_step + grid_min_y = math.floor(min_y / main_step) * main_step + grid_max_y = math.ceil(max_y / main_step) * main_step + + if grid_min_x == grid_max_x: + grid_max_x += main_step + if grid_min_y == grid_max_y: + grid_max_y += main_step + else: + grid_min_x, grid_max_x = -50.0, 50.0 + grid_min_y, grid_max_y = -50.0, 50.0 + + w_grid_mm = grid_max_x - grid_min_x + h_grid_mm = grid_max_y - grid_min_y + + w_total_mm = w_grid_mm + 2.0 * margin_mm + h_total_mm = h_grid_mm + 2.0 * margin_mm + + page_w_pt = w_total_mm * MM_TO_PT + page_h_pt = h_total_mm * MM_TO_PT + + # Convert line width from millimeters to PostScript points + line_width_pt = line_width_mm * MM_TO_PT + + def to_pdf_pt(pt_mm): + x_pt = (pt_mm[0] - grid_min_x + margin_mm) * MM_TO_PT + y_pt = (pt_mm[1] - grid_min_y + margin_mm) * MM_TO_PT + return x_pt, y_pt + + minor_lines: list[tuple[tuple[float, float], tuple[float, float]]] = [] + main_lines: list[tuple[tuple[float, float], tuple[float, float]]] = [] + + max_grid_steps = 2000 + + # Vertical grid lines + k_x_start = int(np.round(grid_min_x / minor_step)) + k_x_end = int(np.round(grid_max_x / minor_step)) + if abs(k_x_end - k_x_start) <= max_grid_steps: + for k_x in range(k_x_start, k_x_end + 1): + gx = k_x * minor_step + p0 = to_pdf_pt((gx, grid_min_y)) + p1 = to_pdf_pt((gx, grid_max_y)) + if k_x % 10 == 0: + main_lines.append((p0, p1)) + else: + minor_lines.append((p0, p1)) + + # Horizontal grid lines + k_y_start = int(np.round(grid_min_y / minor_step)) + k_y_end = int(np.round(grid_max_y / minor_step)) + if abs(k_y_end - k_y_start) <= max_grid_steps: + for k_y in range(k_y_start, k_y_end + 1): + gy = k_y * minor_step + p0 = to_pdf_pt((grid_min_x, gy)) + p1 = to_pdf_pt((grid_max_x, gy)) + if k_y % 10 == 0: + main_lines.append((p0, p1)) + else: + minor_lines.append((p0, p1)) + + stream_cmds: list[str] = [] + + # 1. Solid background fill + br, bg, bb = bg_color + stream_cmds.append(f"{br:.3f} {bg:.3f} {bb:.3f} rg") + stream_cmds.append(f"0 0 {page_w_pt:.2f} {page_h_pt:.2f} re f") + + # 2. Wireframe stroke color + sr, sg, sb = stroke_color + stream_cmds.append(f"{sr:.3f} {sg:.3f} {sb:.3f} RG") + + # 3. Minor Grid Lines (25% Opacity) + if minor_lines: + stream_cmds.append("/GS25 gs") + minor_grid_pt = min(0.3 * MM_TO_PT, line_width_pt) + stream_cmds.append(f"{minor_grid_pt:.2f} w") + lines_str = [f"{p0[0]:.2f} {p0[1]:.2f} m {p1[0]:.2f} {p1[1]:.2f} l" for p0, p1 in minor_lines] + for start_idx in range(0, len(lines_str), 5000): + stream_cmds.extend(lines_str[start_idx:start_idx + 5000]) + stream_cmds.append("S") + + # 4. Main Grid Lines (50% Opacity) + if main_lines: + stream_cmds.append("/GS50 gs") + main_grid_pt = min(0.4 * MM_TO_PT, line_width_pt) + stream_cmds.append(f"{main_grid_pt:.2f} w") + lines_str = [f"{p0[0]:.2f} {p0[1]:.2f} m {p1[0]:.2f} {p1[1]:.2f} l" for p0, p1 in main_lines] + for start_idx in range(0, len(lines_str), 5000): + stream_cmds.extend(lines_str[start_idx:start_idx + 5000]) + stream_cmds.append("S") + + # 5. Object Wireframe (100% Opacity, exact physical line_width_mm) + if len(edges_2d) > 0: + stream_cmds.append("/GS100 gs") + stream_cmds.append(f"{line_width_pt:.2f} w") + stream_cmds.append("1 j 1 J") # round join and cap + + lines_str = [] + for i in range(len(edges_2d)): + p0 = to_pdf_pt(edges_2d[i, 0]) + p1 = to_pdf_pt(edges_2d[i, 1]) + lines_str.append(f"{p0[0]:.2f} {p0[1]:.2f} m {p1[0]:.2f} {p1[1]:.2f} l") + + for start_idx in range(0, len(lines_str), 5000): + stream_cmds.extend(lines_str[start_idx:start_idx + 5000]) + stream_cmds.append("S") + + stream_content = "\n".join(stream_cmds) + "\n" + stream_bytes = stream_content.encode("utf-8") + + 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 {page_w_pt:.2f} {page_h_pt:.2f}] " + f"/Resources << /ExtGState << /GS25 5 0 R /GS50 6 0 R /GS100 7 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") + + header = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n" + + parent_dir = os.path.dirname(filepath) + if parent_dir: + os.makedirs(parent_dir, exist_ok=True) + + with open(filepath, "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") diff --git a/doc/NODES.md b/doc/NODES.md index e2ae202..cf96c91 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -115,6 +115,13 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Load Gcode**: Reads a G-code file from disk. - **Gcode to File**: Writes G-code string to a file on disk. - **Automatic Binary Conversion**: If the G-code contains the comment `; binary_gcode = 1`, the node automatically converts the output to Prusa's binary G-code format (`.bgcode`) using MeatPack encoding and Heatshrink compression. +- **Wireframe to PDF**: Generates a 1:1 true-scale 2D top-view (+Z orthographic) wireframe PDF from a `SlicingGroup` or `SlicingCollection` with a custom grid and background color. + - **Geometry**: Input socket accepting a `SlicingGroup`, `SlicingCollection`, or `SlicingObject`. + - **Folder**: Target folder or PDF file path on disk. + - **Line Width (mm)**: Stroke width in millimeters for the wireframe lines. + - **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. - **Baserow Submit**: Sends G-code and metadata to a Baserow database. - **Host**: Baserow host URL. - **Token File**: Path to a file containing the Baserow API token. diff --git a/tests/test_pdf_exporter.py b/tests/test_pdf_exporter.py new file mode 100644 index 0000000..065e960 --- /dev/null +++ b/tests/test_pdf_exporter.py @@ -0,0 +1,134 @@ +import os +import tempfile +import numpy as np +import unittest +from unittest.mock import MagicMock +import sys +from pathlib import Path + +# Add workspace directory to sys.path +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: pass +class DummyMesh: pass + +mock_types = MagicMock() +mock_types.NodeSocket = DummyNodeSocket +mock_types.Node = DummyNode +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() + +from NodeSlicer.infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject +from NodeSlicer.core.pdf_exporter import extract_unique_top_view_edges, export_wireframe_to_pdf, MM_TO_PT + + +class TestPdfExporter(unittest.TestCase): + + def test_extract_edges_from_slicing_object(self): + so = SlicingObject() + # Create a simple triangle in 3D: (0,0,0), (10,0,0), (0,10,0) with normal (0,0,1) + tri = np.array([ + [0.0, 0.0, 0.0], + [10.0, 0.0, 0.0], + [0.0, 10.0, 0.0], + [0.0, 0.0, 1.0] # Normal + ], dtype=np.float64) + so.mesh = np.array([tri]) + + edges = extract_unique_top_view_edges(so) + self.assertEqual(len(edges), 3) + + def test_extract_edges_deduplication(self): + so = SlicingObject() + # Two triangles sharing an edge (10,0,0) - (0,10,0) + tri1 = np.array([ + [0.0, 0.0, 0.0], + [10.0, 0.0, 0.0], + [0.0, 10.0, 0.0], + [0.0, 0.0, 1.0] + ], dtype=np.float64) + tri2 = np.array([ + [10.0, 0.0, 0.0], + [10.0, 10.0, 0.0], + [0.0, 10.0, 0.0], + [0.0, 0.0, 1.0] + ], dtype=np.float64) + so.mesh = np.array([tri1, tri2]) + + edges = extract_unique_top_view_edges(so) + # Total edges: 3 + 3 - 1 shared = 5 unique edges + self.assertEqual(len(edges), 5) + + def test_extract_edges_filters_vertical_lines(self): + so = SlicingObject() + # Vertical wall in top view (x, y remain constant, z changes) -> degenerate 2D line segment + tri = np.array([ + [0.0, 0.0, 0.0], + [0.0, 0.0, 10.0], + [10.0, 0.0, 0.0], + [0.0, 1.0, 0.0] + ], dtype=np.float64) + so.mesh = np.array([tri]) + + edges = extract_unique_top_view_edges(so) + # Vertical edge (0,0,0)-(0,0,10) becomes 2D length 0 and should be filtered out + self.assertTrue(len(edges) < 3) + + def test_export_pdf_true_1_to_1_scale(self): + # Setup 100mm x 100mm 2D square + edges_2d = np.array([ + [[0.0, 0.0], [100.0, 0.0]], + [[100.0, 0.0], [100.0, 100.0]], + [[100.0, 100.0], [0.0, 100.0]], + [[0.0, 100.0], [0.0, 0.0]] + ], dtype=np.float64) + + margin_mm = 10.0 + # Main grid step for 100mm is 10mm. Bounds: [0, 100], [0, 100]. + # Total width = 100 + 2*10 = 120mm. Expected pt = 120 * MM_TO_PT = 340.16 pt. + + with tempfile.TemporaryDirectory() as tmpdir: + pdf_path = os.path.join(tmpdir, "test_true_scale.pdf") + export_wireframe_to_pdf( + edges_2d, + pdf_path, + margin_mm=margin_mm, + stroke_color=(0.1, 0.2, 0.3), + bg_color=(0.9, 0.95, 1.0) + ) + + self.assertTrue(os.path.exists(pdf_path)) + with open(pdf_path, "rb") as f: + content = f.read() + + expected_w = (100.0 + 2 * margin_mm) * MM_TO_PT + expected_h = (100.0 + 2 * margin_mm) * MM_TO_PT + mediabox_str = f"/MediaBox [0 0 {expected_w:.2f} {expected_h:.2f}]".encode("utf-8") + + self.assertTrue(content.startswith(b"%PDF-1.4")) + self.assertIn(mediabox_str, content) + self.assertIn(b"0.900 0.950 1.000 rg", content) # Background color + self.assertIn(b"0.100 0.200 0.300 RG", content) # Wireframe color + self.assertIn(b"/GS25 gs", content) + self.assertIn(b"/GS50 gs", content) + self.assertIn(b"/GS100 gs", content) + self.assertTrue(content.endswith(b"%%EOF\n")) + + +if __name__ == '__main__': + unittest.main()