Spaces:
Running
Running
Add nozzle spacing controls and visualization
Browse files- README.md +3 -2
- app.py +298 -35
- gcode_viewer.py +108 -14
- tests/test_nozzle_spacing.py +105 -0
README.md
CHANGED
|
@@ -50,10 +50,11 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
|
|
| 50 |
- Combines generated stacks into a reference TIFF stack
|
| 51 |
- Converts generated TIFF ZIPs into G-code files with pressure, valve, and port settings per shape
|
| 52 |
- Offers two G-code generation options: **Use G1 for all moves** (no rapid travel command) and **Use Reference Stack for motion** (all shapes share one nozzle path; each dispenses only its own geometry)
|
|
|
|
| 53 |
- Previews each shape's generated G-code inline (text boxes under the downloads)
|
| 54 |
- Visualizes generated or uploaded G-code tool paths, with the source selectable from Shape 1/2/3 or an uploaded file
|
| 55 |
- Renders the tool path as a fast line plot or an animated 3D tube plot (play/pause, speed, scrub, frame-step, nozzle marker)
|
| 56 |
-
- Plots all three shapes
|
| 57 |
|
| 58 |
## Behavior and Implementation Notes
|
| 59 |
|
|
@@ -99,7 +100,7 @@ The G-code visualization tab renders the generated Shape 1/2/3 G-code or an uplo
|
|
| 99 |
|
| 100 |
### Parallel Printing Visualization
|
| 101 |
|
| 102 |
-
The fourth tab plots all three shapes' G-code at once
|
| 103 |
|
| 104 |
It can also **export the animation as a GIF**, rendered server-side with Matplotlib (the `Agg` CPU backend — no WebGL, no headless browser, and no `ffmpeg`, so it works locally and on Hugging Face). The GIF is line-style with faint grey travel and white, black-outlined nozzle markers drawn on top; controls cover duration, frames per second, elevation/azimuth viewing angle, and travel opacity (0 hides travel).
|
| 105 |
|
|
|
|
| 50 |
- Combines generated stacks into a reference TIFF stack
|
| 51 |
- Converts generated TIFF ZIPs into G-code files with pressure, valve, and port settings per shape
|
| 52 |
- Offers two G-code generation options: **Use G1 for all moves** (no rapid travel command) and **Use Reference Stack for motion** (all shapes share one nozzle path; each dispenses only its own geometry)
|
| 53 |
+
- Calculates X/Y nozzle spacing from shared or separate Shape 1->2 and Shape 2->3 spacing values, then visualizes the resulting nozzle layout
|
| 54 |
- Previews each shape's generated G-code inline (text boxes under the downloads)
|
| 55 |
- Visualizes generated or uploaded G-code tool paths, with the source selectable from Shape 1/2/3 or an uploaded file
|
| 56 |
- Renders the tool path as a fast line plot or an animated 3D tube plot (play/pause, speed, scrub, frame-step, nozzle marker)
|
| 57 |
+
- Plots all three shapes using the configured nozzle spacing and animates them printing in parallel, with a server-side GIF export of that animation
|
| 58 |
|
| 59 |
## Behavior and Implementation Notes
|
| 60 |
|
|
|
|
| 100 |
|
| 101 |
### Parallel Printing Visualization
|
| 102 |
|
| 103 |
+
The fourth tab plots all three shapes' G-code at once using the nozzle spacing configured on the TIFF-to-G-code tab, each in its own color. Like the visualization tab it has a fast **Line Plot** and an animated **Tube Plot**; the animation advances all parts on a shared cumulative-path-length timeline, so a shorter part finishes first.
|
| 104 |
|
| 105 |
It can also **export the animation as a GIF**, rendered server-side with Matplotlib (the `Agg` CPU backend — no WebGL, no headless browser, and no `ffmpeg`, so it works locally and on Hugging Face). The GIF is line-style with faint grey travel and white, black-outlined nozzle markers drawn on top; controls cover duration, frames per second, elevation/azimuth viewing angle, and travel opacity (0 hides travel).
|
| 106 |
|
app.py
CHANGED
|
@@ -22,6 +22,7 @@ from PIL import Image, ImageDraw, ImageFont
|
|
| 22 |
import trimesh
|
| 23 |
|
| 24 |
from gcode_viewer import (
|
|
|
|
| 25 |
build_parallel_figure,
|
| 26 |
build_parallel_gif,
|
| 27 |
build_toolpath_figure,
|
|
@@ -91,20 +92,26 @@ APP_CSS = """
|
|
| 91 |
}
|
| 92 |
|
| 93 |
#load-sample-stls-button,
|
| 94 |
-
#load-sample-stls-button button
|
|
|
|
|
|
|
| 95 |
background: #f97316 !important;
|
| 96 |
border-color: #ea580c !important;
|
| 97 |
color: #ffffff !important;
|
| 98 |
}
|
| 99 |
|
| 100 |
#load-sample-stls-button:hover,
|
| 101 |
-
#load-sample-stls-button button:hover
|
|
|
|
|
|
|
| 102 |
background: #ea580c !important;
|
| 103 |
border-color: #c2410c !important;
|
| 104 |
}
|
| 105 |
|
| 106 |
#load-sample-stls-button:focus-visible,
|
| 107 |
-
#load-sample-stls-button button:focus-visible
|
|
|
|
|
|
|
| 108 |
box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.35) !important;
|
| 109 |
}
|
| 110 |
|
|
@@ -744,7 +751,8 @@ def _format_model_details(
|
|
| 744 |
lines = [
|
| 745 |
"### Model Details",
|
| 746 |
f"- Source: `{source_name}`",
|
| 747 |
-
f"-
|
|
|
|
| 748 |
]
|
| 749 |
|
| 750 |
if scale_factors and not all(math.isclose(value, 1.0) for value in scale_factors):
|
|
@@ -1740,21 +1748,17 @@ PARALLEL_COLOR_CHOICES = [
|
|
| 1740 |
("Red", "#d62728"), ("Purple", "#9467bd"), ("Pink", "#e377c2"),
|
| 1741 |
("Teal", "#17becf"), ("Black", "#000000"),
|
| 1742 |
]
|
|
|
|
| 1743 |
|
| 1744 |
|
| 1745 |
-
def
|
| 1746 |
path1: str | None,
|
| 1747 |
path2: str | None,
|
| 1748 |
path3: str | None,
|
| 1749 |
color1: str,
|
| 1750 |
color2: str,
|
| 1751 |
color3: str,
|
| 1752 |
-
|
| 1753 |
-
filament_width: float,
|
| 1754 |
-
travel_width: float,
|
| 1755 |
-
gap: float,
|
| 1756 |
-
tube: bool = True,
|
| 1757 |
-
) -> tuple[Any, str]:
|
| 1758 |
specs = [(1, path1, color1), (2, path2, color2), (3, path3, color3)]
|
| 1759 |
parts: list[dict] = []
|
| 1760 |
messages: list[str] = []
|
|
@@ -1774,17 +1778,188 @@ def render_parallel(
|
|
| 1774 |
parts.append({"idx": idx, "color": color, "parsed": parsed})
|
| 1775 |
messages.append(f"Shape {idx}: {parsed['point_count']} moves, {parsed.get('layer_count', 0)} layer(s).")
|
| 1776 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1777 |
if not parts:
|
| 1778 |
return None, "No shape G-code available. Generate G-code on the TIFF Slices to GCode tab first."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1779 |
|
| 1780 |
figure = build_parallel_figure(
|
| 1781 |
parts,
|
| 1782 |
-
|
| 1783 |
filament_width=float(filament_width),
|
| 1784 |
travel_width=float(travel_width),
|
| 1785 |
travel_opacity=float(travel_opacity),
|
| 1786 |
tube=tube,
|
| 1787 |
)
|
|
|
|
| 1788 |
return figure, " \n".join(messages)
|
| 1789 |
|
| 1790 |
|
|
@@ -1824,7 +1999,11 @@ def export_parallel_gif(
|
|
| 1824 |
color2: str,
|
| 1825 |
color3: str,
|
| 1826 |
travel_opacity: float,
|
| 1827 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1828 |
duration: float,
|
| 1829 |
fps: float,
|
| 1830 |
elev: float,
|
|
@@ -1836,20 +2015,18 @@ def export_parallel_gif(
|
|
| 1836 |
CPU-only (Agg backend) — no WebGL or headless browser — so it works locally
|
| 1837 |
and on Hugging Face. Each shape grows as colored lines on a shared timeline.
|
| 1838 |
"""
|
| 1839 |
-
|
| 1840 |
-
parts: list[dict] = []
|
| 1841 |
-
for idx, (path, color) in enumerate(specs, start=1):
|
| 1842 |
-
if not path:
|
| 1843 |
-
continue
|
| 1844 |
-
try:
|
| 1845 |
-
parsed = parse_gcode_path(Path(path).read_text())
|
| 1846 |
-
except OSError:
|
| 1847 |
-
continue
|
| 1848 |
-
if parsed.get("point_count"):
|
| 1849 |
-
parts.append({"idx": idx, "color": color, "parsed": parsed})
|
| 1850 |
|
| 1851 |
if not parts:
|
| 1852 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1853 |
|
| 1854 |
def report(frame: int, total: int) -> None:
|
| 1855 |
progress(frame / total, desc=f"Rendering frame {frame + 1}/{total}")
|
|
@@ -1858,7 +2035,7 @@ def export_parallel_gif(
|
|
| 1858 |
result = build_parallel_gif(
|
| 1859 |
parts,
|
| 1860 |
out_path=out_path,
|
| 1861 |
-
|
| 1862 |
duration=float(duration),
|
| 1863 |
fps=int(fps),
|
| 1864 |
travel_opacity=float(travel_opacity),
|
|
@@ -2104,9 +2281,9 @@ def build_demo() -> gr.Blocks:
|
|
| 2104 |
|
| 2105 |
# --- Shared slicing controls ---
|
| 2106 |
with gr.Row():
|
| 2107 |
-
layer_height = gr.Number(label="Layer Height", value=0.8, minimum=0.0001, step=0.01)
|
| 2108 |
pixel_size = gr.Number(
|
| 2109 |
-
label="Pixel Size/Fill Width",
|
| 2110 |
value=0.8,
|
| 2111 |
minimum=0.0001,
|
| 2112 |
step=0.01,
|
|
@@ -2504,6 +2681,7 @@ def build_demo() -> gr.Blocks:
|
|
| 2504 |
),
|
| 2505 |
value=True,
|
| 2506 |
)
|
|
|
|
| 2507 |
gcode_button = gr.Button("Generate G-Code", variant="primary")
|
| 2508 |
|
| 2509 |
with gr.Row():
|
|
@@ -2529,6 +2707,47 @@ def build_demo() -> gr.Blocks:
|
|
| 2529 |
|
| 2530 |
gcode_status = gr.Markdown("")
|
| 2531 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2532 |
gcode_button.click(
|
| 2533 |
fn=run_all_tiff_to_gcode,
|
| 2534 |
inputs=[
|
|
@@ -2588,6 +2807,27 @@ def build_demo() -> gr.Blocks:
|
|
| 2588 |
return [];
|
| 2589 |
}""",
|
| 2590 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2591 |
|
| 2592 |
with gr.Tab("G-Code Visualization"):
|
| 2593 |
gr.Markdown(
|
|
@@ -2862,10 +3102,6 @@ def build_demo() -> gr.Blocks:
|
|
| 2862 |
label="Travel width (mm)", minimum=0.05, maximum=3.0,
|
| 2863 |
value=0.2, step=0.05, min_width=150,
|
| 2864 |
)
|
| 2865 |
-
pp_gap = gr.Slider(
|
| 2866 |
-
label="Gap between parts (mm)",
|
| 2867 |
-
minimum=0.0, maximum=50.0, value=5.0, step=0.5,
|
| 2868 |
-
)
|
| 2869 |
parallel_status = gr.Markdown("")
|
| 2870 |
|
| 2871 |
with gr.Group(visible=False) as pp_export_group:
|
|
@@ -2906,7 +3142,10 @@ def build_demo() -> gr.Blocks:
|
|
| 2906 |
parallel_render_inputs = [
|
| 2907 |
gcode_file_1, gcode_file_2, gcode_file_3,
|
| 2908 |
pp_color_1, pp_color_2, pp_color_3,
|
| 2909 |
-
pp_travel_opacity, pp_filament_width, pp_travel_width,
|
|
|
|
|
|
|
|
|
|
| 2910 |
]
|
| 2911 |
parallel_outputs = [
|
| 2912 |
parallel_plot, parallel_status, parallel_mode,
|
|
@@ -2922,13 +3161,33 @@ def build_demo() -> gr.Blocks:
|
|
| 2922 |
inputs=parallel_render_inputs,
|
| 2923 |
outputs=parallel_outputs,
|
| 2924 |
)
|
| 2925 |
-
# Width
|
| 2926 |
-
for _slider in (
|
|
|
|
|
|
|
|
|
|
| 2927 |
_slider.release(
|
| 2928 |
fn=rerender_parallel_current_mode,
|
| 2929 |
inputs=[parallel_mode] + parallel_render_inputs,
|
| 2930 |
outputs=[parallel_plot, parallel_status],
|
| 2931 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2932 |
# Color changes recolor a part's print/travel/nozzle traces client-side.
|
| 2933 |
for _picker, _idx in ((pp_color_1, 1), (pp_color_2, 2), (pp_color_3, 3)):
|
| 2934 |
_picker.change(
|
|
@@ -2974,7 +3233,11 @@ def build_demo() -> gr.Blocks:
|
|
| 2974 |
inputs=[
|
| 2975 |
gcode_file_1, gcode_file_2, gcode_file_3,
|
| 2976 |
pp_color_1, pp_color_2, pp_color_3,
|
| 2977 |
-
pp_gif_travel_opacity,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2978 |
pp_elev, pp_azim,
|
| 2979 |
],
|
| 2980 |
outputs=[pp_gif_file],
|
|
|
|
| 22 |
import trimesh
|
| 23 |
|
| 24 |
from gcode_viewer import (
|
| 25 |
+
build_nozzle_spacing_figure,
|
| 26 |
build_parallel_figure,
|
| 27 |
build_parallel_gif,
|
| 28 |
build_toolpath_figure,
|
|
|
|
| 92 |
}
|
| 93 |
|
| 94 |
#load-sample-stls-button,
|
| 95 |
+
#load-sample-stls-button button,
|
| 96 |
+
#visualize-nozzle-spacing-button,
|
| 97 |
+
#visualize-nozzle-spacing-button button {
|
| 98 |
background: #f97316 !important;
|
| 99 |
border-color: #ea580c !important;
|
| 100 |
color: #ffffff !important;
|
| 101 |
}
|
| 102 |
|
| 103 |
#load-sample-stls-button:hover,
|
| 104 |
+
#load-sample-stls-button button:hover,
|
| 105 |
+
#visualize-nozzle-spacing-button:hover,
|
| 106 |
+
#visualize-nozzle-spacing-button button:hover {
|
| 107 |
background: #ea580c !important;
|
| 108 |
border-color: #c2410c !important;
|
| 109 |
}
|
| 110 |
|
| 111 |
#load-sample-stls-button:focus-visible,
|
| 112 |
+
#load-sample-stls-button button:focus-visible,
|
| 113 |
+
#visualize-nozzle-spacing-button:focus-visible,
|
| 114 |
+
#visualize-nozzle-spacing-button button:focus-visible {
|
| 115 |
box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.35) !important;
|
| 116 |
}
|
| 117 |
|
|
|
|
| 751 |
lines = [
|
| 752 |
"### Model Details",
|
| 753 |
f"- Source: `{source_name}`",
|
| 754 |
+
f"- Dimensions (mm): `X {extents[0]:.3f}, Y {extents[1]:.3f}, Z {extents[2]:.3f}`",
|
| 755 |
+
f"- Footprint (mm): `X {extents[0]:.3f} x Y {extents[1]:.3f}`",
|
| 756 |
]
|
| 757 |
|
| 758 |
if scale_factors and not all(math.isclose(value, 1.0) for value in scale_factors):
|
|
|
|
| 1748 |
("Red", "#d62728"), ("Purple", "#9467bd"), ("Pink", "#e377c2"),
|
| 1749 |
("Teal", "#17becf"), ("Black", "#000000"),
|
| 1750 |
]
|
| 1751 |
+
DEFAULT_PARALLEL_COLORS = ("#ff7f0e", "#1f77b4", "#2ca02c")
|
| 1752 |
|
| 1753 |
|
| 1754 |
+
def _load_parallel_parts(
|
| 1755 |
path1: str | None,
|
| 1756 |
path2: str | None,
|
| 1757 |
path3: str | None,
|
| 1758 |
color1: str,
|
| 1759 |
color2: str,
|
| 1760 |
color3: str,
|
| 1761 |
+
) -> tuple[list[dict], list[str]]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1762 |
specs = [(1, path1, color1), (2, path2, color2), (3, path3, color3)]
|
| 1763 |
parts: list[dict] = []
|
| 1764 |
messages: list[str] = []
|
|
|
|
| 1778 |
parts.append({"idx": idx, "color": color, "parsed": parsed})
|
| 1779 |
messages.append(f"Shape {idx}: {parsed['point_count']} moves, {parsed.get('layer_count', 0)} layer(s).")
|
| 1780 |
|
| 1781 |
+
return parts, messages
|
| 1782 |
+
|
| 1783 |
+
|
| 1784 |
+
def _resolve_nozzle_layout(
|
| 1785 |
+
parts: list[dict],
|
| 1786 |
+
same_spacing: bool | None,
|
| 1787 |
+
part_gap_12_x: float | None,
|
| 1788 |
+
part_gap_12_y: float | None,
|
| 1789 |
+
part_gap_23_x: float | None,
|
| 1790 |
+
part_gap_23_y: float | None,
|
| 1791 |
+
) -> tuple[dict[int, tuple[float, float]], list[dict]]:
|
| 1792 |
+
offsets: dict[int, tuple[float, float]] = {}
|
| 1793 |
+
spacings: list[dict] = []
|
| 1794 |
+
if not parts:
|
| 1795 |
+
return offsets, spacings
|
| 1796 |
+
|
| 1797 |
+
ordered = sorted(parts, key=lambda part: part["idx"])
|
| 1798 |
+
offsets[ordered[0]["idx"]] = (0.0, 0.0)
|
| 1799 |
+
|
| 1800 |
+
pair_spacing = {
|
| 1801 |
+
(1, 2): (float(part_gap_12_x or 0.0), float(part_gap_12_y or 0.0)),
|
| 1802 |
+
(2, 3): (
|
| 1803 |
+
float(part_gap_12_x or 0.0) if same_spacing else float(part_gap_23_x or 0.0),
|
| 1804 |
+
float(part_gap_12_y or 0.0) if same_spacing else float(part_gap_23_y or 0.0),
|
| 1805 |
+
),
|
| 1806 |
+
}
|
| 1807 |
+
|
| 1808 |
+
def spacing_between(prev_idx: int, cur_idx: int) -> tuple[float, float]:
|
| 1809 |
+
if (prev_idx, cur_idx) in pair_spacing:
|
| 1810 |
+
return pair_spacing[(prev_idx, cur_idx)]
|
| 1811 |
+
if prev_idx == 1 and cur_idx == 3:
|
| 1812 |
+
x12, y12 = pair_spacing[(1, 2)]
|
| 1813 |
+
x23, y23 = pair_spacing[(2, 3)]
|
| 1814 |
+
return x12 + x23, y12 + y23
|
| 1815 |
+
return 0.0, 0.0
|
| 1816 |
+
|
| 1817 |
+
for previous, current in zip(ordered, ordered[1:]):
|
| 1818 |
+
prev_idx = previous["idx"]
|
| 1819 |
+
cur_idx = current["idx"]
|
| 1820 |
+
gap_x, y_step = spacing_between(prev_idx, cur_idx)
|
| 1821 |
+
prev_offset_x, prev_offset_y = offsets[prev_idx]
|
| 1822 |
+
(_, _, _), (prev_xmax, _prev_ymax, _) = previous["parsed"]["bounds"]
|
| 1823 |
+
(cur_xmin, _cur_ymin, _), (_, _, _) = current["parsed"]["bounds"]
|
| 1824 |
+
dx = (prev_xmax + prev_offset_x + gap_x) - cur_xmin
|
| 1825 |
+
dy = prev_offset_y + y_step
|
| 1826 |
+
offsets[cur_idx] = (dx, dy)
|
| 1827 |
+
spacings.append({
|
| 1828 |
+
"from": prev_idx,
|
| 1829 |
+
"to": cur_idx,
|
| 1830 |
+
"dx": dx - prev_offset_x,
|
| 1831 |
+
"dy": y_step,
|
| 1832 |
+
})
|
| 1833 |
+
|
| 1834 |
+
return offsets, spacings
|
| 1835 |
+
|
| 1836 |
+
|
| 1837 |
+
def _same_spacing_from_individual(use_individual_spacing: bool | None) -> bool:
|
| 1838 |
+
return not bool(use_individual_spacing)
|
| 1839 |
+
|
| 1840 |
+
|
| 1841 |
+
def update_individual_nozzle_spacing_visibility(
|
| 1842 |
+
use_individual_spacing: bool | None,
|
| 1843 |
+
) -> dict[str, Any]:
|
| 1844 |
+
return gr.update(visible=bool(use_individual_spacing))
|
| 1845 |
+
|
| 1846 |
+
|
| 1847 |
+
def _format_shape_dimensions(parts: list[dict]) -> list[str]:
|
| 1848 |
+
lines = ["**Shape dimensions from generated G-code:**"]
|
| 1849 |
+
for part in sorted(parts, key=lambda item: item["idx"]):
|
| 1850 |
+
(xmin, ymin, zmin), (xmax, ymax, zmax) = part["parsed"]["bounds"]
|
| 1851 |
+
lines.append(
|
| 1852 |
+
f"Shape {part['idx']}: X {xmax - xmin:.2f} mm, "
|
| 1853 |
+
f"Y {ymax - ymin:.2f} mm, Z {zmax - zmin:.2f} mm."
|
| 1854 |
+
)
|
| 1855 |
+
return lines
|
| 1856 |
+
|
| 1857 |
+
|
| 1858 |
+
def _format_nozzle_spacing_status(
|
| 1859 |
+
parts: list[dict],
|
| 1860 |
+
offsets: dict[int, tuple[float, float]],
|
| 1861 |
+
spacings: list[dict],
|
| 1862 |
+
) -> str:
|
| 1863 |
+
lines = _format_shape_dimensions(parts)
|
| 1864 |
+
ordered_nozzles = sorted(offsets)
|
| 1865 |
+
if ordered_nozzles:
|
| 1866 |
+
lines.append("**Nozzle coordinates:**")
|
| 1867 |
+
for idx in ordered_nozzles:
|
| 1868 |
+
x, y = offsets[idx]
|
| 1869 |
+
lines.append(f"Nozzle {idx}: X {x:.2f} mm, Y {y:.2f} mm.")
|
| 1870 |
+
|
| 1871 |
+
if not spacings:
|
| 1872 |
+
lines.append("Generate G-code for at least two shapes to calculate nozzle spacing.")
|
| 1873 |
+
return " \n".join(lines)
|
| 1874 |
+
|
| 1875 |
+
lines.append("**Nozzle-to-nozzle distances:**")
|
| 1876 |
+
for first_pos, first_idx in enumerate(ordered_nozzles):
|
| 1877 |
+
for second_idx in ordered_nozzles[first_pos + 1:]:
|
| 1878 |
+
x0, y0 = offsets[first_idx]
|
| 1879 |
+
x1, y1 = offsets[second_idx]
|
| 1880 |
+
dx = x1 - x0
|
| 1881 |
+
dy = y1 - y0
|
| 1882 |
+
distance = math.hypot(dx, dy)
|
| 1883 |
+
angle = math.degrees(math.atan2(dy, dx)) if distance else 0.0
|
| 1884 |
+
lines.append(
|
| 1885 |
+
f"Nozzle {first_idx} -> {second_idx}: "
|
| 1886 |
+
f"Delta X {dx:.2f} mm, Delta Y {dy:.2f} mm; "
|
| 1887 |
+
f"distance {distance:.2f} mm at {angle:.1f} deg."
|
| 1888 |
+
)
|
| 1889 |
+
|
| 1890 |
+
lines.append("**Adjacent spacing inputs:**")
|
| 1891 |
+
for spacing in spacings:
|
| 1892 |
+
lines.append(
|
| 1893 |
+
f"Nozzle {spacing['from']} -> {spacing['to']}: "
|
| 1894 |
+
f"X {spacing['dx']:.2f} mm, Y {spacing['dy']:.2f} mm."
|
| 1895 |
+
)
|
| 1896 |
+
return " \n".join(lines)
|
| 1897 |
+
|
| 1898 |
+
|
| 1899 |
+
def render_nozzle_spacing_preview(
|
| 1900 |
+
path1: str | None,
|
| 1901 |
+
path2: str | None,
|
| 1902 |
+
path3: str | None,
|
| 1903 |
+
use_individual_spacing: bool,
|
| 1904 |
+
part_gap_12_x: float,
|
| 1905 |
+
part_gap_12_y: float,
|
| 1906 |
+
part_gap_23_x: float,
|
| 1907 |
+
part_gap_23_y: float,
|
| 1908 |
+
) -> tuple[Any, str]:
|
| 1909 |
+
parts, _messages = _load_parallel_parts(path1, path2, path3, *DEFAULT_PARALLEL_COLORS)
|
| 1910 |
+
if not parts:
|
| 1911 |
+
return None, "No shape G-code available. Generate G-code first."
|
| 1912 |
+
offsets, spacings = _resolve_nozzle_layout(
|
| 1913 |
+
parts,
|
| 1914 |
+
_same_spacing_from_individual(use_individual_spacing),
|
| 1915 |
+
part_gap_12_x,
|
| 1916 |
+
part_gap_12_y,
|
| 1917 |
+
part_gap_23_x,
|
| 1918 |
+
part_gap_23_y,
|
| 1919 |
+
)
|
| 1920 |
+
figure = build_nozzle_spacing_figure(parts, offsets, spacings)
|
| 1921 |
+
return figure, _format_nozzle_spacing_status(parts, offsets, spacings)
|
| 1922 |
+
|
| 1923 |
+
|
| 1924 |
+
def render_parallel(
|
| 1925 |
+
path1: str | None,
|
| 1926 |
+
path2: str | None,
|
| 1927 |
+
path3: str | None,
|
| 1928 |
+
color1: str,
|
| 1929 |
+
color2: str,
|
| 1930 |
+
color3: str,
|
| 1931 |
+
travel_opacity: float,
|
| 1932 |
+
filament_width: float,
|
| 1933 |
+
travel_width: float,
|
| 1934 |
+
use_individual_spacing: bool,
|
| 1935 |
+
part_gap_12_x: float,
|
| 1936 |
+
part_gap_12_y: float,
|
| 1937 |
+
part_gap_23_x: float,
|
| 1938 |
+
part_gap_23_y: float,
|
| 1939 |
+
tube: bool = True,
|
| 1940 |
+
) -> tuple[Any, str]:
|
| 1941 |
+
parts, messages = _load_parallel_parts(path1, path2, path3, color1, color2, color3)
|
| 1942 |
+
|
| 1943 |
if not parts:
|
| 1944 |
return None, "No shape G-code available. Generate G-code on the TIFF Slices to GCode tab first."
|
| 1945 |
+
offsets, spacings = _resolve_nozzle_layout(
|
| 1946 |
+
parts,
|
| 1947 |
+
_same_spacing_from_individual(use_individual_spacing),
|
| 1948 |
+
part_gap_12_x,
|
| 1949 |
+
part_gap_12_y,
|
| 1950 |
+
part_gap_23_x,
|
| 1951 |
+
part_gap_23_y,
|
| 1952 |
+
)
|
| 1953 |
|
| 1954 |
figure = build_parallel_figure(
|
| 1955 |
parts,
|
| 1956 |
+
part_offsets=offsets,
|
| 1957 |
filament_width=float(filament_width),
|
| 1958 |
travel_width=float(travel_width),
|
| 1959 |
travel_opacity=float(travel_opacity),
|
| 1960 |
tube=tube,
|
| 1961 |
)
|
| 1962 |
+
messages.append(_format_nozzle_spacing_status(parts, offsets, spacings))
|
| 1963 |
return figure, " \n".join(messages)
|
| 1964 |
|
| 1965 |
|
|
|
|
| 1999 |
color2: str,
|
| 2000 |
color3: str,
|
| 2001 |
travel_opacity: float,
|
| 2002 |
+
use_individual_spacing: bool,
|
| 2003 |
+
part_gap_12_x: float,
|
| 2004 |
+
part_gap_12_y: float,
|
| 2005 |
+
part_gap_23_x: float,
|
| 2006 |
+
part_gap_23_y: float,
|
| 2007 |
duration: float,
|
| 2008 |
fps: float,
|
| 2009 |
elev: float,
|
|
|
|
| 2015 |
CPU-only (Agg backend) — no WebGL or headless browser — so it works locally
|
| 2016 |
and on Hugging Face. Each shape grows as colored lines on a shared timeline.
|
| 2017 |
"""
|
| 2018 |
+
parts, _messages = _load_parallel_parts(path1, path2, path3, color1, color2, color3)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2019 |
|
| 2020 |
if not parts:
|
| 2021 |
return None
|
| 2022 |
+
offsets, _spacings = _resolve_nozzle_layout(
|
| 2023 |
+
parts,
|
| 2024 |
+
_same_spacing_from_individual(use_individual_spacing),
|
| 2025 |
+
part_gap_12_x,
|
| 2026 |
+
part_gap_12_y,
|
| 2027 |
+
part_gap_23_x,
|
| 2028 |
+
part_gap_23_y,
|
| 2029 |
+
)
|
| 2030 |
|
| 2031 |
def report(frame: int, total: int) -> None:
|
| 2032 |
progress(frame / total, desc=f"Rendering frame {frame + 1}/{total}")
|
|
|
|
| 2035 |
result = build_parallel_gif(
|
| 2036 |
parts,
|
| 2037 |
out_path=out_path,
|
| 2038 |
+
part_offsets=offsets,
|
| 2039 |
duration=float(duration),
|
| 2040 |
fps=int(fps),
|
| 2041 |
travel_opacity=float(travel_opacity),
|
|
|
|
| 2281 |
|
| 2282 |
# --- Shared slicing controls ---
|
| 2283 |
with gr.Row():
|
| 2284 |
+
layer_height = gr.Number(label="Layer Height (mm)", value=0.8, minimum=0.0001, step=0.01)
|
| 2285 |
pixel_size = gr.Number(
|
| 2286 |
+
label="Pixel Size/Fill Width (mm)",
|
| 2287 |
value=0.8,
|
| 2288 |
minimum=0.0001,
|
| 2289 |
step=0.01,
|
|
|
|
| 2681 |
),
|
| 2682 |
value=True,
|
| 2683 |
)
|
| 2684 |
+
|
| 2685 |
gcode_button = gr.Button("Generate G-Code", variant="primary")
|
| 2686 |
|
| 2687 |
with gr.Row():
|
|
|
|
| 2707 |
|
| 2708 |
gcode_status = gr.Markdown("")
|
| 2709 |
|
| 2710 |
+
with gr.Group():
|
| 2711 |
+
gr.Markdown("### Nozzle Spacing")
|
| 2712 |
+
nozzle_use_individual_spacing = gr.Checkbox(
|
| 2713 |
+
label="Use Individual Spacing",
|
| 2714 |
+
value=False,
|
| 2715 |
+
)
|
| 2716 |
+
with gr.Row():
|
| 2717 |
+
nozzle_part_gap_12_x = gr.Number(
|
| 2718 |
+
label="Shared / Shape 1 -> 2 X edge spacing (mm)",
|
| 2719 |
+
value=5.0,
|
| 2720 |
+
step=0.5,
|
| 2721 |
+
)
|
| 2722 |
+
nozzle_part_gap_12_y = gr.Number(
|
| 2723 |
+
label="Shared / Nozzle 1 -> 2 Y spacing (mm)",
|
| 2724 |
+
value=0.0,
|
| 2725 |
+
step=0.5,
|
| 2726 |
+
)
|
| 2727 |
+
with gr.Row(visible=False) as individual_nozzle_spacing_row:
|
| 2728 |
+
nozzle_part_gap_23_x = gr.Number(
|
| 2729 |
+
label="Shape 2 -> 3 X edge spacing (mm)",
|
| 2730 |
+
value=5.0,
|
| 2731 |
+
step=0.5,
|
| 2732 |
+
)
|
| 2733 |
+
nozzle_part_gap_23_y = gr.Number(
|
| 2734 |
+
label="Nozzle 2 -> 3 Y spacing (mm)",
|
| 2735 |
+
value=0.0,
|
| 2736 |
+
step=0.5,
|
| 2737 |
+
)
|
| 2738 |
+
nozzle_preview_button = gr.Button(
|
| 2739 |
+
"Visualize Nozzle Spacing",
|
| 2740 |
+
variant="secondary",
|
| 2741 |
+
elem_id="visualize-nozzle-spacing-button",
|
| 2742 |
+
)
|
| 2743 |
+
with gr.Row():
|
| 2744 |
+
with gr.Column(scale=3, min_width=420):
|
| 2745 |
+
nozzle_spacing_plot = gr.Plot(label="Nozzle Spacing")
|
| 2746 |
+
with gr.Column(scale=1, min_width=260):
|
| 2747 |
+
nozzle_spacing_status = gr.Markdown(
|
| 2748 |
+
"Generate G-code, then visualize nozzle spacing."
|
| 2749 |
+
)
|
| 2750 |
+
|
| 2751 |
gcode_button.click(
|
| 2752 |
fn=run_all_tiff_to_gcode,
|
| 2753 |
inputs=[
|
|
|
|
| 2807 |
return [];
|
| 2808 |
}""",
|
| 2809 |
)
|
| 2810 |
+
nozzle_spacing_inputs = [
|
| 2811 |
+
gcode_file_1,
|
| 2812 |
+
gcode_file_2,
|
| 2813 |
+
gcode_file_3,
|
| 2814 |
+
nozzle_use_individual_spacing,
|
| 2815 |
+
nozzle_part_gap_12_x,
|
| 2816 |
+
nozzle_part_gap_12_y,
|
| 2817 |
+
nozzle_part_gap_23_x,
|
| 2818 |
+
nozzle_part_gap_23_y,
|
| 2819 |
+
]
|
| 2820 |
+
nozzle_use_individual_spacing.change(
|
| 2821 |
+
fn=update_individual_nozzle_spacing_visibility,
|
| 2822 |
+
inputs=[nozzle_use_individual_spacing],
|
| 2823 |
+
outputs=[individual_nozzle_spacing_row],
|
| 2824 |
+
queue=False,
|
| 2825 |
+
)
|
| 2826 |
+
nozzle_preview_button.click(
|
| 2827 |
+
fn=render_nozzle_spacing_preview,
|
| 2828 |
+
inputs=nozzle_spacing_inputs,
|
| 2829 |
+
outputs=[nozzle_spacing_plot, nozzle_spacing_status],
|
| 2830 |
+
)
|
| 2831 |
|
| 2832 |
with gr.Tab("G-Code Visualization"):
|
| 2833 |
gr.Markdown(
|
|
|
|
| 3102 |
label="Travel width (mm)", minimum=0.05, maximum=3.0,
|
| 3103 |
value=0.2, step=0.05, min_width=150,
|
| 3104 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3105 |
parallel_status = gr.Markdown("")
|
| 3106 |
|
| 3107 |
with gr.Group(visible=False) as pp_export_group:
|
|
|
|
| 3142 |
parallel_render_inputs = [
|
| 3143 |
gcode_file_1, gcode_file_2, gcode_file_3,
|
| 3144 |
pp_color_1, pp_color_2, pp_color_3,
|
| 3145 |
+
pp_travel_opacity, pp_filament_width, pp_travel_width,
|
| 3146 |
+
nozzle_use_individual_spacing,
|
| 3147 |
+
nozzle_part_gap_12_x, nozzle_part_gap_12_y,
|
| 3148 |
+
nozzle_part_gap_23_x, nozzle_part_gap_23_y,
|
| 3149 |
]
|
| 3150 |
parallel_outputs = [
|
| 3151 |
parallel_plot, parallel_status, parallel_mode,
|
|
|
|
| 3161 |
inputs=parallel_render_inputs,
|
| 3162 |
outputs=parallel_outputs,
|
| 3163 |
)
|
| 3164 |
+
# Width changes rebuild the figure in the last-used mode after the slider is released.
|
| 3165 |
+
for _slider in (
|
| 3166 |
+
pp_filament_width,
|
| 3167 |
+
pp_travel_width,
|
| 3168 |
+
):
|
| 3169 |
_slider.release(
|
| 3170 |
fn=rerender_parallel_current_mode,
|
| 3171 |
inputs=[parallel_mode] + parallel_render_inputs,
|
| 3172 |
outputs=[parallel_plot, parallel_status],
|
| 3173 |
)
|
| 3174 |
+
# Number inputs do not support release() in this Gradio build.
|
| 3175 |
+
nozzle_use_individual_spacing.change(
|
| 3176 |
+
fn=rerender_parallel_current_mode,
|
| 3177 |
+
inputs=[parallel_mode] + parallel_render_inputs,
|
| 3178 |
+
outputs=[parallel_plot, parallel_status],
|
| 3179 |
+
)
|
| 3180 |
+
for _number in (
|
| 3181 |
+
nozzle_part_gap_12_x,
|
| 3182 |
+
nozzle_part_gap_12_y,
|
| 3183 |
+
nozzle_part_gap_23_x,
|
| 3184 |
+
nozzle_part_gap_23_y,
|
| 3185 |
+
):
|
| 3186 |
+
_number.change(
|
| 3187 |
+
fn=rerender_parallel_current_mode,
|
| 3188 |
+
inputs=[parallel_mode] + parallel_render_inputs,
|
| 3189 |
+
outputs=[parallel_plot, parallel_status],
|
| 3190 |
+
)
|
| 3191 |
# Color changes recolor a part's print/travel/nozzle traces client-side.
|
| 3192 |
for _picker, _idx in ((pp_color_1, 1), (pp_color_2, 2), (pp_color_3, 3)):
|
| 3193 |
_picker.change(
|
|
|
|
| 3233 |
inputs=[
|
| 3234 |
gcode_file_1, gcode_file_2, gcode_file_3,
|
| 3235 |
pp_color_1, pp_color_2, pp_color_3,
|
| 3236 |
+
pp_gif_travel_opacity,
|
| 3237 |
+
nozzle_use_individual_spacing,
|
| 3238 |
+
nozzle_part_gap_12_x, nozzle_part_gap_12_y,
|
| 3239 |
+
nozzle_part_gap_23_x, nozzle_part_gap_23_y,
|
| 3240 |
+
pp_gif_duration, pp_gif_fps,
|
| 3241 |
pp_elev, pp_azim,
|
| 3242 |
],
|
| 3243 |
outputs=[pp_gif_file],
|
gcode_viewer.py
CHANGED
|
@@ -515,14 +515,15 @@ def build_toolpath_figure(
|
|
| 515 |
def build_parallel_figure(
|
| 516 |
parts: list[dict],
|
| 517 |
gap: float = 5.0,
|
|
|
|
| 518 |
filament_width: float = 0.8,
|
| 519 |
travel_width: float = 0.2,
|
| 520 |
travel_opacity: float = 0.2,
|
| 521 |
print_opacity: float = 1.0,
|
| 522 |
tube: bool = True,
|
| 523 |
) -> go.Figure:
|
| 524 |
-
"""Render several parsed shapes
|
| 525 |
-
|
| 526 |
timeline; False draws fast thin scatter lines (no animation).
|
| 527 |
|
| 528 |
`parts` is a list of {"idx": int, "color": str, "parsed": dict}. Each part's
|
|
@@ -548,8 +549,12 @@ def build_parallel_figure(
|
|
| 548 |
|
| 549 |
(pxmin, pymin, pzmin), (pxmax, pymax, pzmax) = parsed["bounds"]
|
| 550 |
width = pxmax - pxmin
|
| 551 |
-
|
| 552 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
|
| 554 |
if tube:
|
| 555 |
print_tube = _build_path_tube(moves, radius=max(filament_width, 0.05) / 2.0, kind="print")
|
|
@@ -557,13 +562,16 @@ def build_parallel_figure(
|
|
| 557 |
path = _path_arrays(moves)
|
| 558 |
|
| 559 |
px = [v + x_off for v in print_tube["x"]]
|
|
|
|
| 560 |
tx = [v + x_off for v in travel_tube["x"]]
|
|
|
|
| 561 |
path_x = [v + x_off for v in path["x"]]
|
|
|
|
| 562 |
|
| 563 |
if travel_tube["i"]:
|
| 564 |
fig.add_trace(
|
| 565 |
go.Mesh3d(
|
| 566 |
-
x=tx, y=
|
| 567 |
i=travel_tube["i"], j=travel_tube["j"], k=travel_tube["k"],
|
| 568 |
color=color, opacity=travel_opacity, name=f"Travel {idx}",
|
| 569 |
showlegend=False, hoverinfo="skip",
|
|
@@ -573,7 +581,7 @@ def build_parallel_figure(
|
|
| 573 |
if print_tube["i"]:
|
| 574 |
fig.add_trace(
|
| 575 |
go.Mesh3d(
|
| 576 |
-
x=px, y=
|
| 577 |
i=print_tube["i"], j=print_tube["j"], k=print_tube["k"],
|
| 578 |
color=color, opacity=print_opacity, name=f"Shape {idx}",
|
| 579 |
showlegend=True, hoverinfo="skip",
|
|
@@ -582,7 +590,7 @@ def build_parallel_figure(
|
|
| 582 |
)
|
| 583 |
fig.add_trace(
|
| 584 |
go.Scatter3d(
|
| 585 |
-
x=[path_x[-1]], y=[
|
| 586 |
mode="markers", name=f"Nozzle {idx}",
|
| 587 |
marker=dict(size=4, color=color), showlegend=False, hoverinfo="skip",
|
| 588 |
)
|
|
@@ -596,13 +604,15 @@ def build_parallel_figure(
|
|
| 596 |
"nozzleName": f"Nozzle {idx}",
|
| 597 |
"print_face_t": print_tube["face_t"],
|
| 598 |
"travel_face_t": travel_tube["face_t"],
|
| 599 |
-
"path_x": path_x, "path_y":
|
| 600 |
})
|
| 601 |
else:
|
| 602 |
t_xs, t_ys, t_zs = _segments_to_xyz(parsed["travel_segments"])
|
| 603 |
p_xs, p_ys, p_zs = _segments_to_xyz(parsed["print_segments"])
|
| 604 |
t_xs = [v + x_off if v is not None else None for v in t_xs]
|
| 605 |
p_xs = [v + x_off if v is not None else None for v in p_xs]
|
|
|
|
|
|
|
| 606 |
if t_xs:
|
| 607 |
fig.add_trace(
|
| 608 |
go.Scatter3d(
|
|
@@ -623,7 +633,7 @@ def build_parallel_figure(
|
|
| 623 |
rendered = True
|
| 624 |
n_parts += 1
|
| 625 |
bx0 = min(bx0, pxmin + x_off); bx1 = max(bx1, pxmax + x_off)
|
| 626 |
-
by0 = min(by0, pymin); by1 = max(by1, pymax)
|
| 627 |
bz0 = min(bz0, pzmin); bz1 = max(bz1, pzmax)
|
| 628 |
|
| 629 |
if not rendered:
|
|
@@ -650,10 +660,90 @@ def build_parallel_figure(
|
|
| 650 |
return fig
|
| 651 |
|
| 652 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 653 |
def build_parallel_gif(
|
| 654 |
parts: list[dict],
|
| 655 |
out_path: str | Path,
|
| 656 |
gap: float = 5.0,
|
|
|
|
| 657 |
duration: float = 6.0,
|
| 658 |
fps: int = 10,
|
| 659 |
travel_opacity: float = 0.15,
|
|
@@ -689,14 +779,18 @@ def build_parallel_gif(
|
|
| 689 |
if not moves:
|
| 690 |
continue
|
| 691 |
(pxmin, pymin, pzmin), (pxmax, pymax, pzmax) = parsed["bounds"]
|
| 692 |
-
|
| 693 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 694 |
|
| 695 |
cum = 0.0
|
| 696 |
mlist: list[tuple] = []
|
| 697 |
for m in moves:
|
| 698 |
-
s = (m["start"][0] + x_off, m["start"][1], m["start"][2])
|
| 699 |
-
e = (m["end"][0] + x_off, m["end"][1], m["end"][2])
|
| 700 |
seg_len = math.dist(s, e)
|
| 701 |
mlist.append((m["kind"], s, e, cum, cum + seg_len))
|
| 702 |
cum += seg_len
|
|
@@ -707,7 +801,7 @@ def build_parallel_gif(
|
|
| 707 |
})
|
| 708 |
|
| 709 |
bx0 = min(bx0, pxmin + x_off); bx1 = max(bx1, pxmax + x_off)
|
| 710 |
-
by0 = min(by0, pymin); by1 = max(by1, pymax)
|
| 711 |
bz0 = min(bz0, pzmin); bz1 = max(bz1, pzmax)
|
| 712 |
|
| 713 |
if not pdata or total_length <= 0:
|
|
|
|
| 515 |
def build_parallel_figure(
|
| 516 |
parts: list[dict],
|
| 517 |
gap: float = 5.0,
|
| 518 |
+
part_offsets: dict[int, tuple[float, float]] | None = None,
|
| 519 |
filament_width: float = 0.8,
|
| 520 |
travel_width: float = 0.2,
|
| 521 |
travel_opacity: float = 0.2,
|
| 522 |
print_opacity: float = 1.0,
|
| 523 |
tube: bool = True,
|
| 524 |
) -> go.Figure:
|
| 525 |
+
"""Render several parsed shapes with optional explicit X/Y nozzle offsets.
|
| 526 |
+
`tube` True draws filament tubes with a shared-time animation
|
| 527 |
timeline; False draws fast thin scatter lines (no animation).
|
| 528 |
|
| 529 |
`parts` is a list of {"idx": int, "color": str, "parsed": dict}. Each part's
|
|
|
|
| 549 |
|
| 550 |
(pxmin, pymin, pzmin), (pxmax, pymax, pzmax) = parsed["bounds"]
|
| 551 |
width = pxmax - pxmin
|
| 552 |
+
if part_offsets is None:
|
| 553 |
+
x_off = running_x - pxmin
|
| 554 |
+
y_off = 0.0
|
| 555 |
+
running_x += width + gap
|
| 556 |
+
else:
|
| 557 |
+
x_off, y_off = part_offsets.get(idx, (0.0, 0.0))
|
| 558 |
|
| 559 |
if tube:
|
| 560 |
print_tube = _build_path_tube(moves, radius=max(filament_width, 0.05) / 2.0, kind="print")
|
|
|
|
| 562 |
path = _path_arrays(moves)
|
| 563 |
|
| 564 |
px = [v + x_off for v in print_tube["x"]]
|
| 565 |
+
py = [v + y_off for v in print_tube["y"]]
|
| 566 |
tx = [v + x_off for v in travel_tube["x"]]
|
| 567 |
+
ty = [v + y_off for v in travel_tube["y"]]
|
| 568 |
path_x = [v + x_off for v in path["x"]]
|
| 569 |
+
path_y = [v + y_off for v in path["y"]]
|
| 570 |
|
| 571 |
if travel_tube["i"]:
|
| 572 |
fig.add_trace(
|
| 573 |
go.Mesh3d(
|
| 574 |
+
x=tx, y=ty, z=travel_tube["z"],
|
| 575 |
i=travel_tube["i"], j=travel_tube["j"], k=travel_tube["k"],
|
| 576 |
color=color, opacity=travel_opacity, name=f"Travel {idx}",
|
| 577 |
showlegend=False, hoverinfo="skip",
|
|
|
|
| 581 |
if print_tube["i"]:
|
| 582 |
fig.add_trace(
|
| 583 |
go.Mesh3d(
|
| 584 |
+
x=px, y=py, z=print_tube["z"],
|
| 585 |
i=print_tube["i"], j=print_tube["j"], k=print_tube["k"],
|
| 586 |
color=color, opacity=print_opacity, name=f"Shape {idx}",
|
| 587 |
showlegend=True, hoverinfo="skip",
|
|
|
|
| 590 |
)
|
| 591 |
fig.add_trace(
|
| 592 |
go.Scatter3d(
|
| 593 |
+
x=[path_x[-1]], y=[path_y[-1]], z=[path["z"][-1]],
|
| 594 |
mode="markers", name=f"Nozzle {idx}",
|
| 595 |
marker=dict(size=4, color=color), showlegend=False, hoverinfo="skip",
|
| 596 |
)
|
|
|
|
| 604 |
"nozzleName": f"Nozzle {idx}",
|
| 605 |
"print_face_t": print_tube["face_t"],
|
| 606 |
"travel_face_t": travel_tube["face_t"],
|
| 607 |
+
"path_x": path_x, "path_y": path_y, "path_z": path["z"], "path_t": path["t"],
|
| 608 |
})
|
| 609 |
else:
|
| 610 |
t_xs, t_ys, t_zs = _segments_to_xyz(parsed["travel_segments"])
|
| 611 |
p_xs, p_ys, p_zs = _segments_to_xyz(parsed["print_segments"])
|
| 612 |
t_xs = [v + x_off if v is not None else None for v in t_xs]
|
| 613 |
p_xs = [v + x_off if v is not None else None for v in p_xs]
|
| 614 |
+
t_ys = [v + y_off if v is not None else None for v in t_ys]
|
| 615 |
+
p_ys = [v + y_off if v is not None else None for v in p_ys]
|
| 616 |
if t_xs:
|
| 617 |
fig.add_trace(
|
| 618 |
go.Scatter3d(
|
|
|
|
| 633 |
rendered = True
|
| 634 |
n_parts += 1
|
| 635 |
bx0 = min(bx0, pxmin + x_off); bx1 = max(bx1, pxmax + x_off)
|
| 636 |
+
by0 = min(by0, pymin + y_off); by1 = max(by1, pymax + y_off)
|
| 637 |
bz0 = min(bz0, pzmin); bz1 = max(bz1, pzmax)
|
| 638 |
|
| 639 |
if not rendered:
|
|
|
|
| 660 |
return fig
|
| 661 |
|
| 662 |
|
| 663 |
+
def build_nozzle_spacing_figure(
|
| 664 |
+
parts: list[dict],
|
| 665 |
+
part_offsets: dict[int, tuple[float, float]],
|
| 666 |
+
spacings: list[dict],
|
| 667 |
+
) -> go.Figure:
|
| 668 |
+
fig = go.Figure()
|
| 669 |
+
bx0 = by0 = float("inf")
|
| 670 |
+
bx1 = by1 = float("-inf")
|
| 671 |
+
|
| 672 |
+
for part in parts:
|
| 673 |
+
idx = part["idx"]
|
| 674 |
+
color = part["color"]
|
| 675 |
+
parsed = part["parsed"]
|
| 676 |
+
(pxmin, pymin, _), (pxmax, pymax, _) = parsed["bounds"]
|
| 677 |
+
x_off, y_off = part_offsets.get(idx, (0.0, 0.0))
|
| 678 |
+
xs = [pxmin + x_off, pxmax + x_off, pxmax + x_off, pxmin + x_off, pxmin + x_off]
|
| 679 |
+
ys = [pymin + y_off, pymin + y_off, pymax + y_off, pymax + y_off, pymin + y_off]
|
| 680 |
+
fig.add_trace(
|
| 681 |
+
go.Scatter(
|
| 682 |
+
x=xs,
|
| 683 |
+
y=ys,
|
| 684 |
+
mode="lines",
|
| 685 |
+
name=f"Shape {idx} bounds",
|
| 686 |
+
line=dict(color=color, width=2),
|
| 687 |
+
)
|
| 688 |
+
)
|
| 689 |
+
fig.add_trace(
|
| 690 |
+
go.Scatter(
|
| 691 |
+
x=[x_off],
|
| 692 |
+
y=[y_off],
|
| 693 |
+
mode="markers+text",
|
| 694 |
+
name=f"Nozzle {idx}",
|
| 695 |
+
marker=dict(color=color, size=12),
|
| 696 |
+
text=[f"N{idx}"],
|
| 697 |
+
textposition="top center",
|
| 698 |
+
)
|
| 699 |
+
)
|
| 700 |
+
bx0 = min(bx0, min(xs), x_off)
|
| 701 |
+
bx1 = max(bx1, max(xs), x_off)
|
| 702 |
+
by0 = min(by0, min(ys), y_off)
|
| 703 |
+
by1 = max(by1, max(ys), y_off)
|
| 704 |
+
|
| 705 |
+
for spacing in spacings:
|
| 706 |
+
start = spacing["from"]
|
| 707 |
+
end = spacing["to"]
|
| 708 |
+
x0, y0 = part_offsets.get(start, (0.0, 0.0))
|
| 709 |
+
x1, y1 = part_offsets.get(end, (0.0, 0.0))
|
| 710 |
+
fig.add_trace(
|
| 711 |
+
go.Scatter(
|
| 712 |
+
x=[x0, x1],
|
| 713 |
+
y=[y0, y1],
|
| 714 |
+
mode="lines+markers+text",
|
| 715 |
+
name=f"Nozzle {start}->{end}",
|
| 716 |
+
line=dict(color="#444444", width=2, dash="dash"),
|
| 717 |
+
marker=dict(color="#444444", size=6),
|
| 718 |
+
text=["", f"dx {spacing['dx']:.2f}, dy {spacing['dy']:.2f}"],
|
| 719 |
+
textposition="middle right",
|
| 720 |
+
)
|
| 721 |
+
)
|
| 722 |
+
|
| 723 |
+
if bx0 == float("inf"):
|
| 724 |
+
bx0 = by0 = -1.0
|
| 725 |
+
bx1 = by1 = 1.0
|
| 726 |
+
|
| 727 |
+
pad = max(bx1 - bx0, by1 - by0, 1.0) * 0.08
|
| 728 |
+
fig.update_layout(
|
| 729 |
+
height=420,
|
| 730 |
+
uirevision="nozzle-spacing",
|
| 731 |
+
xaxis_title="X (mm)",
|
| 732 |
+
yaxis_title="Y (mm)",
|
| 733 |
+
xaxis=dict(range=[bx0 - pad, bx1 + pad], scaleanchor="y", scaleratio=1),
|
| 734 |
+
yaxis=dict(range=[by0 - pad, by1 + pad]),
|
| 735 |
+
margin=dict(l=0, r=0, t=30, b=0),
|
| 736 |
+
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0.0),
|
| 737 |
+
title="Nozzle spacing layout",
|
| 738 |
+
)
|
| 739 |
+
return fig
|
| 740 |
+
|
| 741 |
+
|
| 742 |
def build_parallel_gif(
|
| 743 |
parts: list[dict],
|
| 744 |
out_path: str | Path,
|
| 745 |
gap: float = 5.0,
|
| 746 |
+
part_offsets: dict[int, tuple[float, float]] | None = None,
|
| 747 |
duration: float = 6.0,
|
| 748 |
fps: int = 10,
|
| 749 |
travel_opacity: float = 0.15,
|
|
|
|
| 779 |
if not moves:
|
| 780 |
continue
|
| 781 |
(pxmin, pymin, pzmin), (pxmax, pymax, pzmax) = parsed["bounds"]
|
| 782 |
+
if part_offsets is None:
|
| 783 |
+
x_off = running_x - pxmin
|
| 784 |
+
y_off = 0.0
|
| 785 |
+
running_x += (pxmax - pxmin) + gap
|
| 786 |
+
else:
|
| 787 |
+
x_off, y_off = part_offsets.get(part["idx"], (0.0, 0.0))
|
| 788 |
|
| 789 |
cum = 0.0
|
| 790 |
mlist: list[tuple] = []
|
| 791 |
for m in moves:
|
| 792 |
+
s = (m["start"][0] + x_off, m["start"][1] + y_off, m["start"][2])
|
| 793 |
+
e = (m["end"][0] + x_off, m["end"][1] + y_off, m["end"][2])
|
| 794 |
seg_len = math.dist(s, e)
|
| 795 |
mlist.append((m["kind"], s, e, cum, cum + seg_len))
|
| 796 |
cum += seg_len
|
|
|
|
| 801 |
})
|
| 802 |
|
| 803 |
bx0 = min(bx0, pxmin + x_off); bx1 = max(bx1, pxmax + x_off)
|
| 804 |
+
by0 = min(by0, pymin + y_off); by1 = max(by1, pymax + y_off)
|
| 805 |
bz0 = min(bz0, pzmin); bz1 = max(bz1, pzmax)
|
| 806 |
|
| 807 |
if not pdata or total_length <= 0:
|
tests/test_nozzle_spacing.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
from app import (
|
| 6 |
+
_format_nozzle_spacing_status,
|
| 7 |
+
_resolve_nozzle_layout,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _part(idx: int, bounds: tuple[tuple[float, float, float], tuple[float, float, float]]) -> dict:
|
| 12 |
+
return {
|
| 13 |
+
"idx": idx,
|
| 14 |
+
"color": "#000000",
|
| 15 |
+
"parsed": {"bounds": bounds, "moves": [{"kind": "print"}], "point_count": 1},
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_calculated_nozzle_layout_uses_requested_part_gap() -> None:
|
| 20 |
+
parts = [
|
| 21 |
+
_part(1, ((0.0, 0.0, 0.0), (10.0, 20.0, 1.0))),
|
| 22 |
+
_part(2, ((0.0, 0.0, 0.0), (5.0, 6.0, 1.0))),
|
| 23 |
+
_part(3, ((-2.0, -1.0, 0.0), (2.0, 3.0, 1.0))),
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
offsets, spacings = _resolve_nozzle_layout(
|
| 27 |
+
parts,
|
| 28 |
+
same_spacing=False,
|
| 29 |
+
part_gap_12_x=2.5,
|
| 30 |
+
part_gap_12_y=-1.0,
|
| 31 |
+
part_gap_23_x=8.0,
|
| 32 |
+
part_gap_23_y=4.0,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
np.testing.assert_allclose(offsets[1], (0.0, 0.0))
|
| 36 |
+
np.testing.assert_allclose(offsets[2], (12.5, -1.0))
|
| 37 |
+
np.testing.assert_allclose(offsets[3], (24.5, 3.0))
|
| 38 |
+
assert spacings[0] == {"from": 1, "to": 2, "dx": 12.5, "dy": -1.0}
|
| 39 |
+
assert spacings[1] == {"from": 2, "to": 3, "dx": 12.0, "dy": 4.0}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_calculated_nozzle_layout_allows_negative_x_overlap() -> None:
|
| 43 |
+
parts = [
|
| 44 |
+
_part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
|
| 45 |
+
_part(2, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
offsets, spacings = _resolve_nozzle_layout(
|
| 49 |
+
parts,
|
| 50 |
+
same_spacing=True,
|
| 51 |
+
part_gap_12_x=-2.0,
|
| 52 |
+
part_gap_12_y=3.0,
|
| 53 |
+
part_gap_23_x=0.0,
|
| 54 |
+
part_gap_23_y=0.0,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
np.testing.assert_allclose(offsets[1], (0.0, 0.0))
|
| 58 |
+
np.testing.assert_allclose(offsets[2], (8.0, 3.0))
|
| 59 |
+
assert spacings == [{"from": 1, "to": 2, "dx": 8.0, "dy": 3.0}]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_same_spacing_reuses_first_pair_values_for_second_pair() -> None:
|
| 63 |
+
parts = [
|
| 64 |
+
_part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
|
| 65 |
+
_part(2, ((0.0, 0.0, 0.0), (4.0, 10.0, 1.0))),
|
| 66 |
+
_part(3, ((0.0, 0.0, 0.0), (6.0, 10.0, 1.0))),
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
offsets, spacings = _resolve_nozzle_layout(
|
| 70 |
+
parts,
|
| 71 |
+
same_spacing=True,
|
| 72 |
+
part_gap_12_x=3.0,
|
| 73 |
+
part_gap_12_y=2.0,
|
| 74 |
+
part_gap_23_x=20.0,
|
| 75 |
+
part_gap_23_y=10.0,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
np.testing.assert_allclose(offsets[1], (0.0, 0.0))
|
| 79 |
+
np.testing.assert_allclose(offsets[2], (13.0, 2.0))
|
| 80 |
+
np.testing.assert_allclose(offsets[3], (20.0, 4.0))
|
| 81 |
+
assert spacings[0] == {"from": 1, "to": 2, "dx": 13.0, "dy": 2.0}
|
| 82 |
+
assert spacings[1] == {"from": 2, "to": 3, "dx": 7.0, "dy": 2.0}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_spacing_status_lists_all_nozzle_pair_distances() -> None:
|
| 86 |
+
parts = [
|
| 87 |
+
_part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
|
| 88 |
+
_part(2, ((0.0, 0.0, 0.0), (4.0, 10.0, 1.0))),
|
| 89 |
+
_part(3, ((0.0, 0.0, 0.0), (6.0, 10.0, 1.0))),
|
| 90 |
+
]
|
| 91 |
+
offsets, spacings = _resolve_nozzle_layout(
|
| 92 |
+
parts,
|
| 93 |
+
same_spacing=True,
|
| 94 |
+
part_gap_12_x=3.0,
|
| 95 |
+
part_gap_12_y=2.0,
|
| 96 |
+
part_gap_23_x=20.0,
|
| 97 |
+
part_gap_23_y=10.0,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
status = _format_nozzle_spacing_status(parts, offsets, spacings)
|
| 101 |
+
|
| 102 |
+
assert "Nozzle 1: X 0.00 mm, Y 0.00 mm." in status
|
| 103 |
+
assert "Nozzle 1 -> 2: Delta X 13.00 mm, Delta Y 2.00 mm" in status
|
| 104 |
+
assert "Nozzle 1 -> 3: Delta X 20.00 mm, Delta Y 4.00 mm" in status
|
| 105 |
+
assert "Nozzle 2 -> 3: Delta X 7.00 mm, Delta Y 2.00 mm" in status
|