diff --git "a/app.py" "b/app.py"
new file mode 100644--- /dev/null
+++ "b/app.py"
@@ -0,0 +1,2840 @@
+from __future__ import annotations
+
+import tempfile
+import math
+import warnings
+from pathlib import Path
+from typing import Any
+
+# css/head are intentionally passed to gr.Blocks rather than launch(): we run
+# via `gradio app.py` (hot-reload), which ignores the __main__ launch() call,
+# so moving them there would drop our CSS and head scripts. Silence the Gradio
+# 6.0 deprecation notice about that.
+warnings.filterwarnings(
+ "ignore",
+ message=r".*parameters have been moved from the Blocks constructor.*",
+ category=UserWarning,
+)
+
+import gradio as gr
+import numpy as np
+from PIL import Image, ImageDraw, ImageFont
+import trimesh
+
+from gcode_viewer import (
+ build_parallel_figure,
+ build_parallel_gif,
+ build_toolpath_figure,
+ parse_gcode_path,
+)
+from stl_slicer import (
+ SliceStack,
+ load_mesh,
+ scale_factors_for_target_extents,
+ scale_mesh,
+ slice_stl_to_tiffs,
+)
+from tiff_to_gcode import generate_snake_path_gcode
+
+
+ViewerState = dict[str, Any]
+SAMPLE_STL_FILENAMES = ("Hollow_Pyramid.stl", "Rounded_Cube_Through_Holes.stl", "halfsphere.stl")
+SAMPLE_STL_DIR = Path(__file__).resolve().parent / "sample_stls"
+DEFAULT_TARGET_EXTENTS = (20.0, 20.0, 20.0)
+DEFAULT_UNIFORM_SCALE = 1.0
+SCALE_MODE_TARGET_DIMENSIONS = "Fit X/Y/Z Targets"
+SCALE_MODE_UNIFORM_FACTOR = "Uniform Scale Factor"
+FRONT_CAMERA = (90, 80, None)
+APP_CSS = """
+.gradio-container {
+ font-size: 90%;
+ padding-top: 0.5rem !important;
+ padding-bottom: 0.5rem !important;
+}
+
+.gradio-container .gr-row {
+ gap: 0.5rem !important;
+}
+
+.gradio-container .gr-form,
+.gradio-container .gr-box,
+.gradio-container .block {
+ padding: 0.4rem !important;
+}
+
+.gradio-container .prose {
+ margin-bottom: 0.4rem !important;
+}
+
+.gcode-shape-card {
+ border: 1px solid var(--border-color-primary);
+ border-radius: 0.5rem;
+ padding: 0.5rem !important;
+ min-height: 220px;
+}
+
+.gcode-shape-card .prose {
+ margin-bottom: 0.25rem !important;
+}
+
+.gcode-param-label {
+ font-size: 0.8rem;
+ font-weight: 600;
+ line-height: 1.15;
+ margin-bottom: 0.2rem !important;
+}
+
+.model3D button[aria-label="Undo"] {
+ color: var(--block-label-text-color) !important;
+ cursor: pointer !important;
+ opacity: 1 !important;
+}
+
+#load-sample-stls-button,
+#load-sample-stls-button button {
+ background: #f97316 !important;
+ border-color: #ea580c !important;
+ color: #ffffff !important;
+}
+
+#load-sample-stls-button:hover,
+#load-sample-stls-button button:hover {
+ background: #ea580c !important;
+ border-color: #c2410c !important;
+}
+
+#load-sample-stls-button:focus-visible,
+#load-sample-stls-button button:focus-visible {
+ box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.35) !important;
+}
+
+#toolpath-anim-controls {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ flex-wrap: wrap;
+ padding: 0.4rem 0.2rem;
+}
+
+#toolpath-anim-controls button,
+#toolpath-anim-controls select {
+ background: var(--button-secondary-background-fill);
+ color: var(--button-secondary-text-color);
+ border: 1px solid var(--border-color-primary);
+ border-radius: 0.4rem;
+ padding: 0.25rem 0.7rem;
+ font-size: 0.85rem;
+ cursor: pointer;
+}
+
+#toolpath-anim-controls button:hover,
+#toolpath-anim-controls select:hover {
+ border-color: #f97316;
+}
+
+#tp-scrub {
+ flex: 1 1 140px;
+ min-width: 120px;
+ accent-color: #f97316;
+}
+
+#tp-readout {
+ font-size: 0.85rem;
+ color: var(--body-text-color-subdued);
+ flex-basis: 100%;
+}
+
+#tp-hint {
+ font-size: 0.78rem;
+ color: var(--body-text-color-subdued);
+ padding: 0 0.2rem 0.3rem;
+}
+
+#tube-render-warning {
+ font-size: 0.8rem;
+ color: var(--body-text-color-subdued);
+ margin-top: -0.3rem !important;
+}
+
+/* Keep the per-shape G-code previews at a fixed height with internal scroll
+ (gr.Code's max_lines does not constrain the editor in this Gradio build). */
+.gcode-view .cm-editor {
+ max-height: 320px;
+}
+.gcode-view .cm-scroller {
+ overflow: auto;
+}
+
+/* Shrink the empty-state placeholder of the G-code download boxes so they are
+ the same compact height before and after Generate G-Code is pressed. */
+.gcode-download .empty {
+ min-height: 0 !important;
+ height: 35px !important;
+ padding: 0 !important;
+ overflow: hidden !important;
+}
+.gcode-download .empty svg {
+ width: 22px !important;
+ height: 22px !important;
+}
+
+/* The G-code upload box starts hidden; it is shown client-side only when
+ "Upload G-Code file" is selected (see gcode_source.change). */
+#gcode-upload-col {
+ display: none;
+}
+
+/* Parallel-printing animation controls share the look of the single-plot bar. */
+#parallel-anim-controls {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ flex-wrap: wrap;
+ padding: 0.4rem 0.2rem;
+}
+
+#parallel-anim-controls button,
+#parallel-anim-controls select {
+ background: var(--button-secondary-background-fill);
+ color: var(--button-secondary-text-color);
+ border: 1px solid var(--border-color-primary);
+ border-radius: 0.4rem;
+ padding: 0.25rem 0.7rem;
+ font-size: 0.85rem;
+ cursor: pointer;
+}
+
+#parallel-anim-controls button:hover,
+#parallel-anim-controls select:hover {
+ border-color: #f97316;
+}
+
+#pp-scrub {
+ flex: 1 1 140px;
+ min-width: 120px;
+ accent-color: #f97316;
+}
+
+#pp-readout {
+ font-size: 0.85rem;
+ color: var(--body-text-color-subdued);
+ flex-basis: 100%;
+}
+"""
+
+# Gradio 6.10's gr.Model3D leaves the Undo (reset view) button permanently
+# disabled when the value is supplied programmatically — its `has_change_history`
+# state only flips on uploads through Model3D's own upload widget. This script
+# strips the disabled attribute so clicks reach Svelte's handle_undo, which
+# calls reset_camera_position on the underlying canvas.
+APP_HEAD = """
+
+"""
+
+# Client-side build animation for the G-Code Visualization tab. The rendered
+# Plotly figure carries per-point timestamps (cumulative path length) in
+# layout.meta.animation; this script reveals the print/travel traces up to a
+# moving time cutoff via Plotly.restyle, entirely in the browser. Event
+# listeners are delegated from document so they survive Gradio re-renders.
+TOOLPATH_ANIM_HEAD = """
+
+"""
+
+TOOLPATH_CONTROLS_HTML = """
+
+ ⏮
+ ⏴
+ ▶ Play
+ ⏵
+
+ 0.25×
+ 0.5×
+ 1×
+ 2×
+ 5×
+ 10×
+
+
+ Render a tool path, then press Play.
+
+
+ Tip: drag the plot to rotate and scroll to zoom — easiest while the
+ animation is paused. The ⏴ / ⏵ buttons step through the path
+ one move at a time.
+
+"""
+
+
+# Parallel-printing animation engine: drives multiple parts in one plot off a
+# shared cumulative-length time axis. Independent of the single-plot engine so
+# the G-Code Visualization tab is unaffected. Targets #parallel_plot / #pp-*.
+PARALLEL_ANIM_HEAD = """
+
+"""
+
+PARALLEL_CONTROLS_HTML = """
+
+ ⏮
+ ⏴
+ ▶ Play
+ ⏵
+
+ 0.25×
+ 0.5×
+ 1×
+ 2×
+ 5×
+ 10×
+
+
+ Render the parallel print, then press Play.
+
+"""
+
+
+def _read_slice_preview(path: str) -> Image.Image:
+ with Image.open(path) as image:
+ preview = image.copy()
+
+ # Upscale low-resolution TIFF previews so they fill the viewer area better.
+ min_display_side = 480
+ width, height = preview.size
+ max_dim = max(width, height)
+ if max_dim > 0 and max_dim < min_display_side:
+ scale = min_display_side / max_dim
+ new_size = (
+ max(1, int(round(width * scale))),
+ max(1, int(round(height * scale))),
+ )
+ preview = preview.resize(new_size, resample=Image.Resampling.NEAREST)
+
+ return preview
+
+
+def _empty_state() -> ViewerState:
+ return {
+ "tiff_paths": [],
+ "z_values": [],
+ "pixel_size": 0.0,
+ "x_min": 0.0,
+ "y_min": 0.0,
+ "image_width": 0,
+ "image_height": 0,
+ }
+
+
+def _reset_slider() -> dict[str, Any]:
+ return gr.update(minimum=0, maximum=0, value=0, step=1, interactive=False)
+
+
+def _stack_to_state(stack: SliceStack) -> ViewerState:
+ (x_min, y_min, _z_min), (_x_max, _y_max, _z_max) = stack.bounds
+ return {
+ "tiff_paths": [str(path) for path in stack.tiff_paths],
+ "z_values": stack.z_values,
+ "pixel_size": stack.pixel_size,
+ "x_min": x_min,
+ "y_min": y_min,
+ "image_width": stack.image_size[0],
+ "image_height": stack.image_size[1],
+ }
+
+
+def _format_model_details(
+ source_name: str,
+ mesh,
+ scale_factors: tuple[float, float, float] | None = None,
+) -> str:
+ extents = mesh.extents
+ watertight_status = "yes" if mesh.is_watertight else "no"
+ watertight_explanation = (
+ "closed solid with no holes or open edges"
+ if mesh.is_watertight
+ else "mesh has holes or open edges"
+ )
+ lines = [
+ "### Model Details",
+ f"- Source: `{source_name}`",
+ f"- Extents: `{extents[0]:.3f} x {extents[1]:.3f} x {extents[2]:.3f}`",
+ ]
+
+ if scale_factors and not all(math.isclose(value, 1.0) for value in scale_factors):
+ lines.append(
+ f"- Scale Factors: `X {scale_factors[0]:.4g}, Y {scale_factors[1]:.4g}, Z {scale_factors[2]:.4g}`"
+ )
+
+ lines.extend(
+ [
+ f"- Faces: `{len(mesh.faces)}`",
+ f"- Vertices: `{len(mesh.vertices)}`",
+ f"- Watertight ({watertight_explanation}): `{watertight_status}`",
+ ]
+ )
+ return "\n".join(lines)
+
+
+def _slice_label(state: ViewerState, index: int) -> str:
+ path = Path(state["tiff_paths"][index]).name
+ z_value = state["z_values"][index]
+ total = len(state["tiff_paths"])
+ return f"Slice {index + 1} / {total} | z = {z_value:.4f} | {path}"
+
+
+def _annotate_preview(
+ image: Image.Image,
+ pixel_size: float,
+ x_min: float,
+ y_min: float,
+ orig_width: int,
+ orig_height: int,
+) -> Image.Image:
+ """Draw a blue origin crosshair with axis labels and a scale bar."""
+ rgb = image.convert("RGB")
+ draw = ImageDraw.Draw(rgb)
+
+ preview_w, preview_h = rgb.size
+ scale_x = preview_w / orig_width if orig_width else 1.0
+ scale_y = preview_h / orig_height if orig_height else 1.0
+
+ BLUE = (50, 120, 255)
+
+ try:
+ font = ImageFont.load_default(size=14)
+ except TypeError:
+ font = ImageFont.load_default()
+ try:
+ small_font = ImageFont.load_default(size=12)
+ except TypeError:
+ small_font = font
+
+ # --- Origin crosshair & axis indicators ---
+ origin_px = (0.0 - x_min) / pixel_size
+ origin_py_from_bottom = (0.0 - y_min) / pixel_size
+ origin_img_y = orig_height - 1 - origin_py_from_bottom
+
+ ox = int(round(origin_px * scale_x))
+ oy = int(round(origin_img_y * scale_y))
+
+ arm = 20
+ margin_edge = 8 # inset from image border for off-screen indicators
+ on_screen = 0 <= ox < preview_w and 0 <= oy < preview_h
+
+ if on_screen:
+ # +X axis (rightward)
+ x_start = max(0, ox)
+ x_end = min(preview_w - 1, ox + arm)
+ if x_end > x_start:
+ draw.line([(x_start, oy), (x_end, oy)], fill=BLUE, width=2)
+ draw.polygon(
+ [(x_end, oy), (x_end - 5, oy - 4), (x_end - 5, oy + 4)],
+ fill=BLUE,
+ )
+ if x_end + 4 < preview_w:
+ draw.text((x_end + 4, oy - 7), "X", fill=BLUE, font=small_font)
+
+ # +Y axis (upward in world = upward in image)
+ y_end = max(0, oy - arm)
+ y_start = min(preview_h - 1, oy)
+ if y_start > y_end:
+ draw.line([(ox, y_start), (ox, y_end)], fill=BLUE, width=2)
+ draw.polygon(
+ [(ox, y_end), (ox - 4, y_end + 5), (ox + 4, y_end + 5)],
+ fill=BLUE,
+ )
+ if y_end - 16 >= 0:
+ draw.text((ox + 5, y_end - 16), "Y", fill=BLUE, font=small_font)
+
+ # -X stub (leftward from origin)
+ stub = min(8, max(0, ox))
+ if stub > 0:
+ draw.line([(ox - stub, oy), (ox, oy)], fill=BLUE, width=2)
+
+ # -Y stub (downward from origin in image)
+ stub_y = min(8, max(0, preview_h - 1 - oy))
+ if stub_y > 0:
+ draw.line([(ox, oy), (ox, oy + stub_y)], fill=BLUE, width=2)
+
+ # Origin label
+ lx = ox + arm + 4 if ox + arm + 40 < preview_w else ox - 45
+ ly = oy + 6
+ if 0 <= ly < preview_h:
+ draw.text((max(0, lx), ly), "(0, 0)", fill=BLUE, font=small_font)
+
+ else:
+ # Origin is off-screen — draw edge indicator(s) pointing toward it.
+ arrow_len = 14
+ arrow_half = 5
+
+ # Compute direction label text showing approximate origin coordinates
+ origin_x_mm = x_min
+ origin_y_mm = y_min
+ coord_text = f"Origin ({-origin_x_mm:+.1f}, {-origin_y_mm:+.1f})"
+
+ if ox < 0:
+ # Origin is to the LEFT — draw left-pointing arrow on left edge
+ ay = max(margin_edge + arrow_half, min(preview_h - margin_edge - arrow_half, oy))
+ draw.polygon(
+ [(margin_edge, ay), (margin_edge + arrow_len, ay - arrow_half), (margin_edge + arrow_len, ay + arrow_half)],
+ fill=BLUE,
+ )
+ draw.text((margin_edge + arrow_len + 4, ay - 7), coord_text, fill=BLUE, font=small_font)
+ elif ox >= preview_w:
+ # Origin is to the RIGHT
+ ay = max(margin_edge + arrow_half, min(preview_h - margin_edge - arrow_half, oy))
+ rx = preview_w - margin_edge
+ draw.polygon(
+ [(rx, ay), (rx - arrow_len, ay - arrow_half), (rx - arrow_len, ay + arrow_half)],
+ fill=BLUE,
+ )
+ tw = len(coord_text) * 7
+ draw.text((max(0, rx - arrow_len - tw - 4), ay - 7), coord_text, fill=BLUE, font=small_font)
+
+ if oy < 0:
+ # Origin is ABOVE — draw upward-pointing arrow on top edge
+ ax = max(margin_edge + arrow_half, min(preview_w - margin_edge - arrow_half, ox))
+ draw.polygon(
+ [(ax, margin_edge), (ax - arrow_half, margin_edge + arrow_len), (ax + arrow_half, margin_edge + arrow_len)],
+ fill=BLUE,
+ )
+ elif oy >= preview_h:
+ # Origin is BELOW — draw downward-pointing arrow on bottom edge
+ ax = max(margin_edge + arrow_half, min(preview_w - margin_edge - arrow_half, ox))
+ by = preview_h - margin_edge
+ draw.polygon(
+ [(ax, by), (ax - arrow_half, by - arrow_len), (ax + arrow_half, by - arrow_len)],
+ fill=BLUE,
+ )
+ # If we didn't already draw a left/right label, label here
+ if 0 <= ox < preview_w:
+ draw.text((ax + arrow_half + 4, by - arrow_len - 2), coord_text, fill=BLUE, font=small_font)
+
+ # --- Scale bar (bottom-left) ---
+ image_width_mm = orig_width * pixel_size
+ target_bar_mm = image_width_mm * 0.2
+ nice = [0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500]
+ bar_mm = min(nice, key=lambda v: abs(v - target_bar_mm))
+
+ bar_px = (bar_mm / pixel_size) * scale_x
+ margin = 12
+ bar_y = preview_h - margin
+ bar_x0 = margin
+ bar_x1 = bar_x0 + bar_px
+ cap = 5
+
+ draw.line([(int(bar_x0), int(bar_y)), (int(bar_x1), int(bar_y))], fill=BLUE, width=3)
+ draw.line([(int(bar_x0), int(bar_y - cap)), (int(bar_x0), int(bar_y + cap))], fill=BLUE, width=2)
+ draw.line([(int(bar_x1), int(bar_y - cap)), (int(bar_x1), int(bar_y + cap))], fill=BLUE, width=2)
+
+ bar_label = f"{bar_mm:g} mm"
+ draw.text((int(bar_x0), int(bar_y - 20)), bar_label, fill=BLUE, font=font)
+
+ return rgb
+
+
+def _render_selected_slice(state: ViewerState, index: int) -> tuple[str, Image.Image | None]:
+ tiff_paths = state.get("tiff_paths", [])
+ if not tiff_paths:
+ return "No slice stack loaded yet.", None
+
+ bounded_index = max(0, min(int(index), len(tiff_paths) - 1))
+ selected_path = tiff_paths[bounded_index]
+ preview = _read_slice_preview(selected_path)
+
+ pixel_size = state.get("pixel_size", 0.0)
+ if pixel_size and pixel_size > 0:
+ preview = _annotate_preview(
+ preview,
+ pixel_size=pixel_size,
+ x_min=state.get("x_min", 0.0),
+ y_min=state.get("y_min", 0.0),
+ orig_width=state.get("image_width", 0) or preview.size[0],
+ orig_height=state.get("image_height", 0) or preview.size[1],
+ )
+
+ return (
+ _slice_label(state, bounded_index),
+ preview,
+ )
+
+
+def _opacity_to_alpha(opacity: float) -> int:
+ bounded = max(0.05, min(float(opacity), 1.0))
+ return int(round(255 * bounded))
+
+
+def _resolve_model_opacity(setting: float | bool | None) -> float:
+ if isinstance(setting, bool):
+ return 0.75 if setting else 1.0
+ if setting is None:
+ return 1.0
+ return max(0.05, min(float(setting), 1.0))
+
+
+def _resolve_target_extents(
+ scale_to_target: bool | None,
+ target_x: float | None,
+ target_y: float | None,
+ target_z: float | None,
+) -> tuple[float, float, float] | None:
+ if not scale_to_target:
+ return None
+
+ values = (target_x, target_y, target_z)
+ if any(value is None for value in values):
+ raise ValueError("Target X, Y, and Z dimensions are required when STL scaling is enabled.")
+
+ target = tuple(float(value) for value in values)
+ if any(not math.isfinite(value) or value <= 0 for value in target):
+ raise ValueError("Target X, Y, and Z dimensions must be greater than zero.")
+
+ return (target[0], target[1], target[2])
+
+
+def _resolve_uniform_scale(
+ scale_to_target: bool | None,
+ uniform_scale: float | None,
+) -> float | None:
+ if not scale_to_target:
+ return None
+
+ if uniform_scale is None:
+ raise ValueError("Uniform scale factor is required when uniform STL scaling is enabled.")
+
+ scale = float(uniform_scale)
+ if not math.isfinite(scale) or scale <= 0:
+ raise ValueError("Uniform scale factor must be greater than zero.")
+
+ return scale
+
+
+def _normalize_scale_mode(scale_mode: str | None) -> str:
+ if scale_mode == SCALE_MODE_UNIFORM_FACTOR:
+ return SCALE_MODE_UNIFORM_FACTOR
+ return SCALE_MODE_TARGET_DIMENSIONS
+
+
+def _shape_target_values(
+ target1_x: float | None,
+ target1_y: float | None,
+ target1_z: float | None,
+ target2_x: float | None,
+ target2_y: float | None,
+ target2_z: float | None,
+ target3_x: float | None,
+ target3_y: float | None,
+ target3_z: float | None,
+) -> tuple[tuple[float | None, float | None, float | None], ...]:
+ return (
+ (target1_x, target1_y, target1_z),
+ (target2_x, target2_y, target2_z),
+ (target3_x, target3_y, target3_z),
+ )
+
+
+def _shape_uniform_values(
+ uniform1: float | None,
+ uniform2: float | None,
+ uniform3: float | None,
+) -> tuple[float | None, float | None, float | None]:
+ return (uniform1, uniform2, uniform3)
+
+
+def _resolve_mesh_scale_factors(
+ mesh: trimesh.Trimesh,
+ scale_to_target: bool | None,
+ scale_mode: str | None,
+ target_x: float | None,
+ target_y: float | None,
+ target_z: float | None,
+ uniform_scale: float | None,
+) -> tuple[float, float, float] | None:
+ if not scale_to_target:
+ return None
+
+ if _normalize_scale_mode(scale_mode) == SCALE_MODE_UNIFORM_FACTOR:
+ scale = _resolve_uniform_scale(True, uniform_scale)
+ return (scale, scale, scale)
+
+ target_extents = _resolve_target_extents(True, target_x, target_y, target_z)
+ if target_extents is None:
+ return None
+ return scale_factors_for_target_extents(mesh, target_extents)
+
+
+def _load_model_mesh(
+ stl_file: str | Path,
+ scale_to_target: bool | None = False,
+ scale_mode: str | None = SCALE_MODE_TARGET_DIMENSIONS,
+ target_x: float | None = DEFAULT_TARGET_EXTENTS[0],
+ target_y: float | None = DEFAULT_TARGET_EXTENTS[1],
+ target_z: float | None = DEFAULT_TARGET_EXTENTS[2],
+ uniform_scale: float | None = DEFAULT_UNIFORM_SCALE,
+) -> tuple[trimesh.Trimesh, tuple[float, float, float]]:
+ mesh = load_mesh(stl_file)
+ scale_factors = _resolve_mesh_scale_factors(
+ mesh,
+ scale_to_target,
+ scale_mode,
+ target_x,
+ target_y,
+ target_z,
+ uniform_scale,
+ )
+ if scale_factors is None:
+ return mesh, (1.0, 1.0, 1.0)
+ return scale_mesh(mesh, scale_factors), scale_factors
+
+
+def _viewer_update(model_path: str | None) -> dict[str, Any]:
+ return gr.update(value=model_path, camera_position=FRONT_CAMERA)
+
+
+def _build_annotated_scene(mesh: trimesh.Trimesh, opacity: float = 1.0) -> str:
+ """Export a GLB containing the mesh, origin axes, and a Z=0 grid plane."""
+ scene = trimesh.Scene()
+ display_transform = trimesh.transformations.rotation_matrix(-np.pi / 2, [1, 0, 0])
+
+ # --- Model (muted orange to match the Gradio theme accent) ---
+ model_copy = mesh.copy()
+ model_copy.apply_transform(display_transform)
+ bounded_opacity = _resolve_model_opacity(opacity)
+ mat = trimesh.visual.material.PBRMaterial(
+ baseColorFactor=[230, 150, 90, _opacity_to_alpha(bounded_opacity)],
+ alphaMode="OPAQUE" if bounded_opacity >= 0.999 else "BLEND",
+ metallicFactor=0.0,
+ roughnessFactor=0.6,
+ )
+ model_copy.visual = trimesh.visual.TextureVisuals(material=mat)
+ scene.add_geometry(model_copy, geom_name="model")
+
+ bounds = mesh.bounds
+ (x_min, y_min, z_min), (x_max, y_max, z_max) = bounds
+ extent = max(x_max - x_min, y_max - y_min, z_max - z_min)
+
+ # --- Origin axes (coloured cylinders + cones) ---
+ axis_len = extent * 0.4
+ axis_radius = extent * 0.008
+ cone_radius = axis_radius * 3.5
+ cone_height = axis_len * 0.12
+
+ axis_defs = [
+ ("X", [1, 0, 0], [255, 50, 50, 255]),
+ ("Y", [0, 1, 0], [50, 200, 50, 255]),
+ ("Z", [0, 0, 1], [50, 120, 255, 255]),
+ ]
+
+ for name, direction, color in axis_defs:
+ d = np.array(direction, dtype=float)
+
+ # Cylinder from origin along axis
+ cyl = trimesh.creation.cylinder(
+ radius=axis_radius, height=axis_len, sections=12
+ )
+ # Default cylinder is along Z; rotate to desired axis
+ midpoint = d * axis_len / 2
+ if name == "X":
+ cyl.apply_transform(trimesh.transformations.rotation_matrix(
+ np.pi / 2, [0, 1, 0]
+ ))
+ elif name == "Y":
+ cyl.apply_transform(trimesh.transformations.rotation_matrix(
+ -np.pi / 2, [1, 0, 0]
+ ))
+ cyl.apply_translation(midpoint)
+ cyl.apply_transform(display_transform)
+ cyl.visual = trimesh.visual.ColorVisuals(
+ mesh=cyl,
+ face_colors=np.tile(color, (len(cyl.faces), 1)),
+ )
+ scene.add_geometry(cyl, geom_name=f"axis_{name}")
+
+ # Cone arrowhead at tip
+ cone = trimesh.creation.cone(
+ radius=cone_radius, height=cone_height, sections=12
+ )
+ if name == "X":
+ cone.apply_transform(trimesh.transformations.rotation_matrix(
+ np.pi / 2, [0, 1, 0]
+ ))
+ elif name == "Y":
+ cone.apply_transform(trimesh.transformations.rotation_matrix(
+ -np.pi / 2, [1, 0, 0]
+ ))
+ cone.apply_translation(d * (axis_len + cone_height / 2))
+ cone.apply_transform(display_transform)
+ cone.visual = trimesh.visual.ColorVisuals(
+ mesh=cone,
+ face_colors=np.tile(color, (len(cone.faces), 1)),
+ )
+ scene.add_geometry(cone, geom_name=f"cone_{name}")
+
+ # --- Grid plane at z=0 ---
+ nice_spacings = [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100]
+ target_spacing = extent * 0.1
+ grid_spacing = min(nice_spacings, key=lambda v: abs(v - target_spacing))
+
+ # Grid extends to cover model footprint plus some margin
+ margin = grid_spacing * 2
+ gx_min = math.floor((x_min - margin) / grid_spacing) * grid_spacing
+ gx_max = math.ceil((x_max + margin) / grid_spacing) * grid_spacing
+ gy_min = math.floor((y_min - margin) / grid_spacing) * grid_spacing
+ gy_max = math.ceil((y_max + margin) / grid_spacing) * grid_spacing
+
+ grid_color = [160, 160, 160, 100]
+ grid_segments: list[list[list[float]]] = []
+
+ # Lines parallel to Y
+ x = gx_min
+ while x <= gx_max:
+ grid_segments.append([[x, gy_min, 0], [x, gy_max, 0]])
+ x += grid_spacing
+ # Lines parallel to X
+ y = gy_min
+ while y <= gy_max:
+ grid_segments.append([[gx_min, y, 0], [gx_max, y, 0]])
+ y += grid_spacing
+
+ if grid_segments:
+ grid_path = trimesh.load_path(grid_segments)
+ grid_path.apply_transform(display_transform)
+ grid_path.colors = np.tile(grid_color, (len(grid_path.entities), 1))
+ scene.add_geometry(grid_path, geom_name="grid")
+
+ # Export to GLB (camera angle is set via gr.Model3D camera_position)
+ out_path = Path(tempfile.mkdtemp(prefix="model3d_")) / "scene.glb"
+ scene.export(str(out_path), file_type="glb")
+ return str(out_path)
+
+
+def load_single_model(
+ stl_file: str | None,
+ opacity: float = 1.0,
+ scale_to_target: bool | None = False,
+ scale_mode: str | None = SCALE_MODE_TARGET_DIMENSIONS,
+ target_x: float | None = DEFAULT_TARGET_EXTENTS[0],
+ target_y: float | None = DEFAULT_TARGET_EXTENTS[1],
+ target_z: float | None = DEFAULT_TARGET_EXTENTS[2],
+ uniform_scale: float | None = DEFAULT_UNIFORM_SCALE,
+) -> tuple[str | None, str]:
+ if not stl_file:
+ return _viewer_update(None), "No model loaded."
+ mesh, scale_factors = _load_model_mesh(
+ stl_file,
+ scale_to_target=scale_to_target,
+ scale_mode=scale_mode,
+ target_x=target_x,
+ target_y=target_y,
+ target_z=target_z,
+ uniform_scale=uniform_scale,
+ )
+ glb_path = _build_annotated_scene(mesh, opacity=_resolve_model_opacity(opacity))
+ return _viewer_update(glb_path), _format_model_details(Path(stl_file).name, mesh, scale_factors)
+
+
+def preload_sample_models(
+ opacity: float = 1.0,
+ scale_to_target: bool | None = False,
+ scale_mode: str | None = SCALE_MODE_TARGET_DIMENSIONS,
+ target1_x: float | None = DEFAULT_TARGET_EXTENTS[0],
+ target1_y: float | None = DEFAULT_TARGET_EXTENTS[1],
+ target1_z: float | None = DEFAULT_TARGET_EXTENTS[2],
+ uniform1: float | None = DEFAULT_UNIFORM_SCALE,
+ target2_x: float | None = DEFAULT_TARGET_EXTENTS[0],
+ target2_y: float | None = DEFAULT_TARGET_EXTENTS[1],
+ target2_z: float | None = DEFAULT_TARGET_EXTENTS[2],
+ uniform2: float | None = DEFAULT_UNIFORM_SCALE,
+ target3_x: float | None = DEFAULT_TARGET_EXTENTS[0],
+ target3_y: float | None = DEFAULT_TARGET_EXTENTS[1],
+ target3_z: float | None = DEFAULT_TARGET_EXTENTS[2],
+ uniform3: float | None = DEFAULT_UNIFORM_SCALE,
+) -> tuple:
+ outputs: list[Any] = []
+ resolved_opacity = _resolve_model_opacity(opacity)
+ target_values = _shape_target_values(
+ target1_x,
+ target1_y,
+ target1_z,
+ target2_x,
+ target2_y,
+ target2_z,
+ target3_x,
+ target3_y,
+ target3_z,
+ )
+ uniform_values = _shape_uniform_values(uniform1, uniform2, uniform3)
+
+ for index, filename in enumerate(SAMPLE_STL_FILENAMES):
+ stl_path = SAMPLE_STL_DIR / filename
+ if not stl_path.exists():
+ outputs.extend([
+ None,
+ _viewer_update(None),
+ f"Sample file not found: {stl_path}",
+ ])
+ continue
+
+ try:
+ mesh, scale_factors = _load_model_mesh(
+ stl_path,
+ scale_to_target=scale_to_target,
+ scale_mode=scale_mode,
+ target_x=target_values[index][0],
+ target_y=target_values[index][1],
+ target_z=target_values[index][2],
+ uniform_scale=uniform_values[index],
+ )
+ except Exception as exc:
+ outputs.extend([
+ str(stl_path),
+ _viewer_update(None),
+ f"Failed to load sample model: {stl_path.name} ({exc})",
+ ])
+ continue
+
+ outputs.extend([
+ str(stl_path),
+ _viewer_update(_build_annotated_scene(mesh, opacity=resolved_opacity)),
+ _format_model_details(stl_path.name, mesh, scale_factors),
+ ])
+
+ return tuple(outputs)
+
+
+def refresh_all_model_viewers(
+ stl1: str | None,
+ stl2: str | None,
+ stl3: str | None,
+ opacity: float,
+ scale_to_target: bool | None = False,
+ scale_mode: str | None = SCALE_MODE_TARGET_DIMENSIONS,
+ target1_x: float | None = DEFAULT_TARGET_EXTENTS[0],
+ target1_y: float | None = DEFAULT_TARGET_EXTENTS[1],
+ target1_z: float | None = DEFAULT_TARGET_EXTENTS[2],
+ uniform1: float | None = DEFAULT_UNIFORM_SCALE,
+ target2_x: float | None = DEFAULT_TARGET_EXTENTS[0],
+ target2_y: float | None = DEFAULT_TARGET_EXTENTS[1],
+ target2_z: float | None = DEFAULT_TARGET_EXTENTS[2],
+ uniform2: float | None = DEFAULT_UNIFORM_SCALE,
+ target3_x: float | None = DEFAULT_TARGET_EXTENTS[0],
+ target3_y: float | None = DEFAULT_TARGET_EXTENTS[1],
+ target3_z: float | None = DEFAULT_TARGET_EXTENTS[2],
+ uniform3: float | None = DEFAULT_UNIFORM_SCALE,
+) -> tuple:
+ outputs: list[Any] = []
+ resolved_opacity = _resolve_model_opacity(opacity)
+ target_values = _shape_target_values(
+ target1_x,
+ target1_y,
+ target1_z,
+ target2_x,
+ target2_y,
+ target2_z,
+ target3_x,
+ target3_y,
+ target3_z,
+ )
+ uniform_values = _shape_uniform_values(uniform1, uniform2, uniform3)
+
+ for stl_file, values, uniform_scale in zip((stl1, stl2, stl3), target_values, uniform_values):
+ if not stl_file:
+ outputs.extend([_viewer_update(None), "No model loaded."])
+ continue
+ outputs.extend(
+ load_single_model(
+ stl_file,
+ resolved_opacity,
+ scale_to_target,
+ scale_mode,
+ values[0],
+ values[1],
+ values[2],
+ uniform_scale,
+ )
+ )
+ return tuple(outputs)
+
+
+def generate_all_stacks(
+ stl1: str | None,
+ stl2: str | None,
+ stl3: str | None,
+ layer_height: float,
+ pixel_size: float,
+ scale_to_target: bool | None = False,
+ scale_mode: str | None = SCALE_MODE_TARGET_DIMENSIONS,
+ target1_x: float | None = DEFAULT_TARGET_EXTENTS[0],
+ target1_y: float | None = DEFAULT_TARGET_EXTENTS[1],
+ target1_z: float | None = DEFAULT_TARGET_EXTENTS[2],
+ uniform1: float | None = DEFAULT_UNIFORM_SCALE,
+ target2_x: float | None = DEFAULT_TARGET_EXTENTS[0],
+ target2_y: float | None = DEFAULT_TARGET_EXTENTS[1],
+ target2_z: float | None = DEFAULT_TARGET_EXTENTS[2],
+ uniform2: float | None = DEFAULT_UNIFORM_SCALE,
+ target3_x: float | None = DEFAULT_TARGET_EXTENTS[0],
+ target3_y: float | None = DEFAULT_TARGET_EXTENTS[1],
+ target3_z: float | None = DEFAULT_TARGET_EXTENTS[2],
+ uniform3: float | None = DEFAULT_UNIFORM_SCALE,
+ progress: gr.Progress = gr.Progress(),
+):
+ files = [stl1, stl2, stl3]
+ target_values = _shape_target_values(
+ target1_x,
+ target1_y,
+ target1_z,
+ target2_x,
+ target2_y,
+ target2_z,
+ target3_x,
+ target3_y,
+ target3_z,
+ )
+ uniform_values = _shape_uniform_values(uniform1, uniform2, uniform3)
+ valid_count = max(1, sum(1 for f in files if f))
+ results: list = []
+ completed = 0
+
+ for stl_file, values, uniform_scale in zip(files, target_values, uniform_values):
+ if not stl_file:
+ results.extend([
+ _empty_state(),
+ _reset_slider(),
+ "No slice stack loaded yet.",
+ None,
+ None,
+ ])
+ continue
+
+ slot_offset = completed
+
+ def report_progress(cur: int, tot: int, offset: int = slot_offset) -> None:
+ progress(
+ (offset + cur / tot) / valid_count,
+ desc=f"Slicing object {offset + 1} of {valid_count}\u2026",
+ )
+
+ scale_factors = None
+ if scale_to_target:
+ mesh = load_mesh(stl_file)
+ scale_factors = _resolve_mesh_scale_factors(
+ mesh,
+ scale_to_target,
+ scale_mode,
+ values[0],
+ values[1],
+ values[2],
+ uniform_scale,
+ )
+
+ stack = slice_stl_to_tiffs(
+ stl_file,
+ layer_height=layer_height,
+ pixel_size=pixel_size,
+ progress_callback=report_progress,
+ scale_factors=scale_factors,
+ )
+ state = _stack_to_state(stack)
+ label, preview = _render_selected_slice(state, 0)
+ slider = gr.update(
+ minimum=0,
+ maximum=max(0, len(stack.tiff_paths) - 1),
+ value=0,
+ step=1,
+ interactive=len(stack.tiff_paths) > 1,
+ )
+ results.extend([
+ state,
+ slider,
+ label,
+ preview,
+ str(stack.zip_path),
+ ])
+ completed += 1
+
+ return tuple(results)
+
+
+def jump_to_slice(state: ViewerState, index: float) -> tuple[str, Image.Image | None]:
+ return _render_selected_slice(state, int(index))
+
+
+def run_all_tiff_to_gcode(
+ zip1: str | None,
+ zip2: str | None,
+ zip3: str | None,
+ pressure1: float,
+ valve1: float,
+ port1: float,
+ pressure2: float,
+ valve2: float,
+ port2: float,
+ pressure3: float,
+ valve3: float,
+ port3: float,
+ all_g1: bool = False,
+ use_reference_motion: bool = False,
+ ref_state: ViewerState | None = None,
+ layer_height: float = 0.8,
+ pixel_size: float = 0.8,
+) -> tuple[str | None, str | None, str | None, str]:
+ specs = [
+ (1, zip1, pressure1, valve1, port1),
+ (2, zip2, pressure2, valve2, port2),
+ (3, zip3, pressure3, valve3, port3),
+ ]
+
+ # When reference-driven motion is requested, every shape's nozzle path comes
+ # from the combined Reference TIFF Stack; the valve still follows each shape.
+ motion_tiffs: list[str] | None = None
+ if use_reference_motion:
+ motion_tiffs = (ref_state or {}).get("tiff_paths") or None
+
+ outputs: list[str | None] = [None, None, None]
+ messages: list[str] = []
+
+ for idx, zip_path, pressure, valve, port in specs:
+ if not zip_path:
+ messages.append(f"Shape {idx}: skipped (no TIFF ZIP available).")
+ continue
+
+ if use_reference_motion and not motion_tiffs:
+ messages.append(
+ f"Shape {idx}: skipped (Reference motion selected, but no Reference "
+ f"TIFF Stack has been generated on the first tab)."
+ )
+ continue
+
+ zip_name = Path(zip_path).stem
+ default_shape_name = f"shape{idx}"
+ shape_name = zip_name.replace("_tiff_slices", "") or default_shape_name
+
+ try:
+ gcode_path = generate_snake_path_gcode(
+ zip_path=zip_path,
+ shape_name=shape_name,
+ pressure=float(pressure),
+ valve=int(valve),
+ port=int(port),
+ layer_height=float(layer_height),
+ fil_width=float(pixel_size),
+ all_g1=bool(all_g1),
+ motion_tiffs=motion_tiffs,
+ )
+ outputs[idx - 1] = str(gcode_path)
+ messages.append(f"Shape {idx}: wrote `{gcode_path.name}`.")
+ except Exception as exc: # surface errors in the UI
+ outputs[idx - 1] = None
+ messages.append(f"Shape {idx}: failed ({exc}).")
+
+ return outputs[0], outputs[1], outputs[2], "\n".join(messages)
+
+
+def load_all_gcode_text(
+ path1: str | None,
+ path2: str | None,
+ path3: str | None,
+) -> tuple[str, str, str]:
+ """Return the raw text of each shape's generated G-code file for display."""
+ def read(path: str | None, shape_num: int) -> str:
+ if not path:
+ return f"# No G-code generated for Shape {shape_num} yet."
+ try:
+ return Path(path).read_text()
+ except OSError as exc:
+ return f"# Failed to read G-code file: {exc}"
+
+ return read(path1, 1), read(path2, 2), read(path3, 3)
+
+
+GCODE_SOURCE_SHAPE1 = "Use Shape 1 G-Code"
+GCODE_SOURCE_SHAPE2 = "Use Shape 2 G-Code"
+GCODE_SOURCE_SHAPE3 = "Use Shape 3 G-Code"
+GCODE_SOURCE_UPLOAD = "Upload G-Code file"
+
+
+def render_toolpath(
+ source: str,
+ uploaded_path: str | None,
+ shape1_path: str | None,
+ shape2_path: str | None,
+ shape3_path: str | None,
+ travel_opacity: float = 0.2,
+ print_opacity: float = 1.0,
+ travel_color: str = "#969696",
+ print_color: str = "#ff7f0e",
+ print_width: float = 0.8,
+ travel_width: float = 0.2,
+ tube: bool = True,
+) -> tuple[Any, str, dict]:
+ if source == GCODE_SOURCE_UPLOAD:
+ path = uploaded_path
+ if not path:
+ return None, "No G-code file uploaded yet.", {}
+ else:
+ shape_paths = {
+ GCODE_SOURCE_SHAPE1: (shape1_path, 1),
+ GCODE_SOURCE_SHAPE2: (shape2_path, 2),
+ GCODE_SOURCE_SHAPE3: (shape3_path, 3),
+ }
+ path, shape_num = shape_paths.get(source, (shape1_path, 1))
+ if not path:
+ return None, f"No Shape {shape_num} G-code available yet. Generate it on the TIFF Slices to GCode tab first.", {}
+
+ try:
+ text = Path(path).read_text()
+ except OSError as exc:
+ return None, f"Failed to read G-code file: {exc}", {}
+
+ parsed = parse_gcode_path(text)
+ if parsed["point_count"] == 0:
+ return None, "No G0/G1 movement lines found in the file.", {}
+
+ figure = build_toolpath_figure(parsed, travel_opacity=travel_opacity, print_opacity=print_opacity, travel_color=travel_color, print_color=print_color, print_width=print_width, travel_width=travel_width, tube=tube)
+ (x_min, y_min, z_min), (x_max, y_max, z_max) = parsed["bounds"]
+ summary = (
+ f"**{parsed['point_count']} moves parsed** — "
+ f"{len(parsed['print_segments'])} print segment(s), "
+ f"{len(parsed['travel_segments'])} travel segment(s). \n"
+ f"Bounds: X ∈ [{x_min:.2f}, {x_max:.2f}], "
+ f"Y ∈ [{y_min:.2f}, {y_max:.2f}], "
+ f"Z ∈ [{z_min:.2f}, {z_max:.2f}] mm."
+ )
+ return figure, summary, parsed
+
+
+def render_toolpath_lines(
+ source: str,
+ uploaded_path: str | None,
+ shape1_path: str | None,
+ shape2_path: str | None,
+ shape3_path: str | None,
+ travel_opacity: float,
+ print_opacity: float,
+ travel_color: str,
+ print_color: str,
+ print_width: float,
+ travel_width: float,
+) -> tuple[Any, str, dict, str, dict[str, Any], dict[str, Any]]:
+ figure, status, parsed = render_toolpath(
+ source, uploaded_path, shape1_path, shape2_path, shape3_path,
+ travel_opacity, print_opacity, travel_color, print_color,
+ print_width, travel_width, tube=False,
+ )
+ return figure, status, parsed, "line", gr.update(visible=False), gr.update(visible=False)
+
+
+def render_toolpath_tubes(
+ source: str,
+ uploaded_path: str | None,
+ shape1_path: str | None,
+ shape2_path: str | None,
+ shape3_path: str | None,
+ travel_opacity: float,
+ print_opacity: float,
+ travel_color: str,
+ print_color: str,
+ print_width: float,
+ travel_width: float,
+) -> tuple[Any, str, dict, str, dict[str, Any], dict[str, Any]]:
+ figure, status, parsed = render_toolpath(
+ source, uploaded_path, shape1_path, shape2_path, shape3_path,
+ travel_opacity, print_opacity, travel_color, print_color,
+ print_width, travel_width, tube=True,
+ )
+ # Playback controls and mm width sliders only apply to the tube figure.
+ has_animation = bool(parsed.get("point_count"))
+ return figure, status, parsed, "tube", gr.update(visible=has_animation), gr.update(visible=has_animation)
+
+
+def rerender_toolpath_current_mode(
+ mode: str,
+ source: str,
+ uploaded_path: str | None,
+ shape1_path: str | None,
+ shape2_path: str | None,
+ shape3_path: str | None,
+ travel_opacity: float,
+ print_opacity: float,
+ travel_color: str,
+ print_color: str,
+ print_width: float,
+ travel_width: float,
+) -> tuple[Any, str, dict]:
+ return render_toolpath(
+ source, uploaded_path, shape1_path, shape2_path, shape3_path,
+ travel_opacity, print_opacity, travel_color, print_color,
+ print_width, travel_width, tube=(mode != "line"),
+ )
+
+
+PARALLEL_COLOR_CHOICES = [
+ ("Orange", "#ff7f0e"), ("Blue", "#1f77b4"), ("Green", "#2ca02c"),
+ ("Red", "#d62728"), ("Purple", "#9467bd"), ("Pink", "#e377c2"),
+ ("Teal", "#17becf"), ("Black", "#000000"),
+]
+
+
+def render_parallel(
+ path1: str | None,
+ path2: str | None,
+ path3: str | None,
+ color1: str,
+ color2: str,
+ color3: str,
+ travel_opacity: float,
+ filament_width: float,
+ travel_width: float,
+ gap: float,
+ tube: bool = True,
+) -> tuple[Any, str]:
+ specs = [(1, path1, color1), (2, path2, color2), (3, path3, color3)]
+ parts: list[dict] = []
+ messages: list[str] = []
+
+ for idx, path, color in specs:
+ if not path:
+ messages.append(f"Shape {idx}: no G-code (generate it on the TIFF Slices to GCode tab).")
+ continue
+ try:
+ parsed = parse_gcode_path(Path(path).read_text())
+ except OSError as exc:
+ messages.append(f"Shape {idx}: failed to read ({exc}).")
+ continue
+ if not parsed.get("point_count"):
+ messages.append(f"Shape {idx}: no G0/G1 moves found.")
+ continue
+ parts.append({"idx": idx, "color": color, "parsed": parsed})
+ messages.append(f"Shape {idx}: {parsed['point_count']} moves, {parsed.get('layer_count', 0)} layer(s).")
+
+ if not parts:
+ return None, "No shape G-code available. Generate G-code on the TIFF Slices to GCode tab first."
+
+ figure = build_parallel_figure(
+ parts,
+ gap=float(gap),
+ filament_width=float(filament_width),
+ travel_width=float(travel_width),
+ travel_opacity=float(travel_opacity),
+ tube=tube,
+ )
+ return figure, " \n".join(messages)
+
+
+def render_parallel_lines(
+ *args: Any,
+) -> tuple[Any, str, str, dict[str, Any], dict[str, Any], dict[str, Any]]:
+ figure, status = render_parallel(*args, tube=False)
+ # Line mode: no playback animation and no mm width sliders, but the GIF
+ # export (server-side, line-style) is available whenever there's data.
+ has_data = figure is not None
+ return (
+ figure, status, "line",
+ gr.update(visible=False), gr.update(visible=False), gr.update(visible=has_data),
+ )
+
+
+def render_parallel_tubes(
+ *args: Any,
+) -> tuple[Any, str, str, dict[str, Any], dict[str, Any], dict[str, Any]]:
+ figure, status = render_parallel(*args, tube=True)
+ has_anim = figure is not None
+ return (
+ figure, status, "tube",
+ gr.update(visible=has_anim), gr.update(visible=has_anim), gr.update(visible=has_anim),
+ )
+
+
+def rerender_parallel_current_mode(mode: str, *args: Any) -> tuple[Any, str]:
+ return render_parallel(*args, tube=(mode != "line"))
+
+
+def export_parallel_gif(
+ path1: str | None,
+ path2: str | None,
+ path3: str | None,
+ color1: str,
+ color2: str,
+ color3: str,
+ travel_opacity: float,
+ gap: float,
+ duration: float,
+ fps: float,
+ elev: float,
+ azim: float,
+ progress: gr.Progress = gr.Progress(),
+) -> str | None:
+ """Render the parallel print to an animated GIF server-side via Matplotlib.
+
+ CPU-only (Agg backend) — no WebGL or headless browser — so it works locally
+ and on Hugging Face. Each shape grows as colored lines on a shared timeline.
+ """
+ specs = [(path1, color1), (path2, color2), (path3, color3)]
+ parts: list[dict] = []
+ for idx, (path, color) in enumerate(specs, start=1):
+ if not path:
+ continue
+ try:
+ parsed = parse_gcode_path(Path(path).read_text())
+ except OSError:
+ continue
+ if parsed.get("point_count"):
+ parts.append({"idx": idx, "color": color, "parsed": parsed})
+
+ if not parts:
+ return None
+
+ def report(frame: int, total: int) -> None:
+ progress(frame / total, desc=f"Rendering frame {frame + 1}/{total}")
+
+ out_path = Path(tempfile.mkdtemp(prefix="parallel_gif_")) / "parallel_print.gif"
+ result = build_parallel_gif(
+ parts,
+ out_path=out_path,
+ gap=float(gap),
+ duration=float(duration),
+ fps=int(fps),
+ travel_opacity=float(travel_opacity),
+ elev=float(elev),
+ azim=float(azim),
+ progress_cb=report,
+ )
+ return str(result) if result else None
+
+
+def update_toolpath_opacity(
+ parsed: dict,
+ travel_opacity: float,
+ print_opacity: float,
+) -> Any:
+ if not parsed or not parsed.get("point_count"):
+ return None
+ return build_toolpath_figure(parsed, travel_opacity=travel_opacity, print_opacity=print_opacity)
+
+
+def shift_slice(state: ViewerState, index: float, delta: int) -> tuple[int, str, Image.Image | None]:
+ tiff_paths = state.get("tiff_paths", [])
+ if not tiff_paths:
+ return 0, "No slice stack loaded yet.", None
+
+ new_index = max(0, min(int(index) + delta, len(tiff_paths) - 1))
+ label, preview = _render_selected_slice(state, new_index)
+ return new_index, label, preview
+
+
+def generate_reference_stack(
+ state1: ViewerState,
+ state2: ViewerState,
+ state3: ViewerState,
+ progress: gr.Progress = gr.Progress(),
+) -> tuple:
+ """Combine all available TIFF stacks into a single reference stack.
+
+ For each pixel in each layer the result is black (0) when *any* source
+ stack has a black pixel at that position, and white (255) only when *all*
+ sources are white. Images of different sizes are centred on a canvas
+ sized to the largest dimensions.
+ """
+ active_states = [s for s in [state1, state2, state3] if s.get("tiff_paths")]
+
+ if not active_states:
+ return (
+ _empty_state(),
+ _reset_slider(),
+ "No TIFF stacks available. Generate TIFF stacks first.",
+ None,
+ )
+
+ max_layers = max(len(s["tiff_paths"]) for s in active_states)
+
+ # Determine the largest image dimensions across all stacks.
+ max_width = 0
+ max_height = 0
+ source_sizes: list[tuple[int, int]] = []
+ for state in active_states:
+ w = state.get("image_width", 0)
+ h = state.get("image_height", 0)
+ if not w or not h:
+ with Image.open(state["tiff_paths"][0]) as img:
+ w, h = img.size
+ source_sizes.append((w, h))
+ max_width = max(max_width, w)
+ max_height = max(max_height, h)
+
+ # Compute annotation metadata from the first active state, accounting for
+ # the centering offset applied to its image on the larger canvas.
+ first = active_states[0]
+ first_w, first_h = source_sizes[0]
+ ref_pixel_size = first.get("pixel_size", 0.0)
+ x_off_first = (max_width - first_w) // 2
+ y_off_first = (max_height - first_h) // 2
+ ref_x_min = first.get("x_min", 0.0) - x_off_first * ref_pixel_size
+ ref_y_min = first.get("y_min", 0.0) - y_off_first * ref_pixel_size
+
+ output_dir = Path(tempfile.mkdtemp(prefix="reference_stack_"))
+ slices_dir = output_dir / "tiff_slices"
+ slices_dir.mkdir(parents=True, exist_ok=True)
+
+ tiff_paths: list[Path] = []
+ z_values: list[float] = []
+
+ for layer_idx in range(max_layers):
+ progress(
+ layer_idx / max_layers,
+ desc=f"Compositing reference layer {layer_idx + 1}/{max_layers}",
+ )
+
+ # Start with an all-white canvas.
+ ref_array = np.full((max_height, max_width), 255, dtype=np.uint8)
+
+ for state in active_states:
+ paths = state["tiff_paths"]
+ if layer_idx >= len(paths):
+ continue # Stack exhausted – contributes white.
+
+ with Image.open(paths[layer_idx]) as img:
+ arr = np.asarray(img)
+
+ h, w = arr.shape[:2]
+ y_off = (max_height - h) // 2
+ x_off = (max_width - w) // 2
+
+ # Black (0) wins: pixel-wise minimum keeps any black pixel.
+ region = ref_array[y_off : y_off + h, x_off : x_off + w]
+ ref_array[y_off : y_off + h, x_off : x_off + w] = np.minimum(region, arr)
+
+ ref_image = Image.fromarray(ref_array, mode="L")
+ tiff_path = slices_dir / f"ref_slice_{layer_idx:04d}.tif"
+ ref_image.save(tiff_path, compression="tiff_deflate")
+ tiff_paths.append(tiff_path)
+
+ # Use z-value from the first active state that covers this layer.
+ z_val = 0.0
+ for state in active_states:
+ if layer_idx < len(state["z_values"]):
+ z_val = state["z_values"][layer_idx]
+ break
+ z_values.append(z_val)
+
+ ref_state: ViewerState = {
+ "tiff_paths": [str(p) for p in tiff_paths],
+ "z_values": z_values,
+ "pixel_size": ref_pixel_size,
+ "x_min": ref_x_min,
+ "y_min": ref_y_min,
+ "image_width": max_width,
+ "image_height": max_height,
+ }
+
+ label, preview = _render_selected_slice(ref_state, 0)
+ slider = gr.update(
+ minimum=0,
+ maximum=max(0, len(tiff_paths) - 1),
+ value=0,
+ step=1,
+ interactive=len(tiff_paths) > 1,
+ )
+
+ return ref_state, slider, label, preview
+
+def build_demo() -> gr.Blocks:
+ with gr.Blocks(title="STL TIFF Slicer", css=APP_CSS, head=APP_HEAD + TOOLPATH_ANIM_HEAD + PARALLEL_ANIM_HEAD) as demo:
+ with gr.Tab("STL to TIFF Slicer"):
+ gr.Markdown(
+ """
+ # STL to TIFF Slicer
+ Upload up to three STL files, choose per-shape STL dimensions, layer height, and XY pixel size, then generate TIFF stacks for all uploaded models.
+ """
+ )
+
+ with gr.Row():
+ load_samples_button = gr.Button(
+ "Load Sample STLs",
+ variant="secondary",
+ size="sm",
+ min_width=140,
+ scale=0,
+ elem_id="load-sample-stls-button",
+ )
+ with gr.Column(scale=0, min_width=240):
+ model_opacity = gr.Checkbox(
+ label="Use 75% 3D Model Opacity",
+ value=False,
+ )
+ with gr.Column(scale=0, min_width=260):
+ scale_to_target = gr.Checkbox(
+ label="Apply STL Scaling",
+ value=False,
+ )
+ with gr.Column(scale=0, min_width=260):
+ scale_mode = gr.Radio(
+ choices=[SCALE_MODE_TARGET_DIMENSIONS, SCALE_MODE_UNIFORM_FACTOR],
+ value=SCALE_MODE_TARGET_DIMENSIONS,
+ label="Scaling Mode",
+ )
+
+ # --- Upload + 3D viewer row ---
+ stl_files: list[gr.File] = []
+ model_viewers: list[gr.Model3D] = []
+ model_details_list: list[gr.Markdown] = []
+ target_xs: list[gr.Number] = []
+ target_ys: list[gr.Number] = []
+ target_zs: list[gr.Number] = []
+ uniform_scales: list[gr.Number] = []
+
+ with gr.Row():
+ for i in range(3):
+ with gr.Column(min_width=250):
+ stl_file = gr.File(
+ label=f"STL File {i + 1}",
+ file_types=[".stl"],
+ type="filepath",
+ )
+ model_viewer = gr.Model3D(
+ label=f"3D Viewer {i + 1}",
+ display_mode="solid",
+ clear_color=(0.94, 0.95, 0.97, 1.0),
+ camera_position=FRONT_CAMERA,
+ height=270,
+ )
+ model_details = gr.Markdown(f"No model {i + 1} loaded.")
+ with gr.Row():
+ target_x = gr.Number(
+ label="Target X (mm)",
+ value=DEFAULT_TARGET_EXTENTS[0],
+ minimum=0.0001,
+ step=0.1,
+ )
+ target_y = gr.Number(
+ label="Target Y (mm)",
+ value=DEFAULT_TARGET_EXTENTS[1],
+ minimum=0.0001,
+ step=0.1,
+ )
+ target_z = gr.Number(
+ label="Target Z (mm)",
+ value=DEFAULT_TARGET_EXTENTS[2],
+ minimum=0.0001,
+ step=0.1,
+ )
+ uniform_scale = gr.Number(
+ label="Uniform Scale",
+ value=DEFAULT_UNIFORM_SCALE,
+ minimum=0.0001,
+ step=0.01,
+ )
+ stl_files.append(stl_file)
+ model_viewers.append(model_viewer)
+ model_details_list.append(model_details)
+ target_xs.append(target_x)
+ target_ys.append(target_y)
+ target_zs.append(target_z)
+ uniform_scales.append(uniform_scale)
+
+ # --- Shared slicing controls ---
+ with gr.Row():
+ layer_height = gr.Number(label="Layer Height", value=0.8, minimum=0.0001, step=0.01)
+ pixel_size = gr.Number(
+ label="Pixel Size/Fill Width",
+ value=0.8,
+ minimum=0.0001,
+ step=0.01,
+ )
+ generate_button = gr.Button("Generate TIFF Stacks", variant="primary")
+
+ # --- Per-object slice browsers ---
+ states: list[gr.State] = []
+ sliders: list[gr.Slider] = []
+ slice_labels: list[gr.Markdown] = []
+ slice_previews: list[gr.Image] = []
+ download_zips: list[gr.File] = []
+
+ with gr.Row():
+ for i in range(3):
+ with gr.Column(min_width=250):
+ slice_label = gr.Markdown("No slice stack loaded yet.")
+ slice_preview = gr.Image(
+ label=f"Slice Preview {i + 1}",
+ type="pil",
+ image_mode="RGB",
+ height=270,
+ )
+ with gr.Row():
+ prev_button = gr.Button("\u25c4 Prev", scale=1, min_width=90, size="sm")
+ next_button = gr.Button("Next \u25ba", scale=1, min_width=90, size="sm")
+ slice_slider = gr.Slider(
+ label="Slice Index",
+ minimum=0,
+ maximum=0,
+ value=0,
+ step=1,
+ interactive=False,
+ )
+ download_zip = gr.File(label=f"Download TIFF ZIP {i + 1}", interactive=False)
+ state = gr.State(_empty_state())
+
+ slice_labels.append(slice_label)
+ slice_previews.append(slice_preview)
+ sliders.append(slice_slider)
+ download_zips.append(download_zip)
+ states.append(state)
+
+ slice_slider.release(
+ fn=jump_to_slice,
+ inputs=[state, slice_slider],
+ outputs=[slice_label, slice_preview],
+ queue=False,
+ )
+ prev_button.click(
+ fn=lambda sv, idx: shift_slice(sv, idx, -1),
+ inputs=[state, slice_slider],
+ outputs=[slice_slider, slice_label, slice_preview],
+ queue=False,
+ )
+ next_button.click(
+ fn=lambda sv, idx: shift_slice(sv, idx, 1),
+ inputs=[state, slice_slider],
+ outputs=[slice_slider, slice_label, slice_preview],
+ queue=False,
+ )
+
+ # --- Reference TIFF Stack ---
+ gr.Markdown("---")
+ gr.Markdown("### Reference TIFF Stack")
+
+ with gr.Row():
+ with gr.Column(scale=1, min_width=200):
+ ref_generate_button = gr.Button(
+ "Generate Reference TIFF Stack",
+ variant="primary",
+ )
+ with gr.Column(scale=3, min_width=250):
+ ref_slice_label = gr.Markdown("No reference stack generated yet.")
+ ref_slice_preview = gr.Image(
+ label="Reference Slice Preview",
+ type="pil",
+ image_mode="RGB",
+ height=270,
+ )
+ with gr.Row():
+ ref_prev_button = gr.Button("\u25c4 Prev", scale=1, min_width=90, size="sm")
+ ref_next_button = gr.Button("Next \u25ba", scale=1, min_width=90, size="sm")
+ ref_slice_slider = gr.Slider(
+ label="Slice Index",
+ minimum=0,
+ maximum=0,
+ value=0,
+ step=1,
+ interactive=False,
+ )
+ ref_state = gr.State(_empty_state())
+
+ ref_slice_slider.release(
+ fn=jump_to_slice,
+ inputs=[ref_state, ref_slice_slider],
+ outputs=[ref_slice_label, ref_slice_preview],
+ queue=False,
+ )
+ ref_prev_button.click(
+ fn=lambda sv, idx: shift_slice(sv, idx, -1),
+ inputs=[ref_state, ref_slice_slider],
+ outputs=[ref_slice_slider, ref_slice_label, ref_slice_preview],
+ queue=False,
+ )
+ ref_next_button.click(
+ fn=lambda sv, idx: shift_slice(sv, idx, 1),
+ inputs=[ref_state, ref_slice_slider],
+ outputs=[ref_slice_slider, ref_slice_label, ref_slice_preview],
+ queue=False,
+ )
+
+ # --- File upload handlers ---
+ all_scale_inputs = [
+ target_xs[0],
+ target_ys[0],
+ target_zs[0],
+ uniform_scales[0],
+ target_xs[1],
+ target_ys[1],
+ target_zs[1],
+ uniform_scales[1],
+ target_xs[2],
+ target_ys[2],
+ target_zs[2],
+ uniform_scales[2],
+ ]
+
+ for i in range(3):
+ stl_files[i].change(
+ fn=load_single_model,
+ inputs=[
+ stl_files[i],
+ model_opacity,
+ scale_to_target,
+ scale_mode,
+ target_xs[i],
+ target_ys[i],
+ target_zs[i],
+ uniform_scales[i],
+ ],
+ outputs=[model_viewers[i], model_details_list[i]],
+ )
+
+ # --- Generate button ---
+ generate_outputs: list = []
+ for i in range(3):
+ generate_outputs.extend([
+ states[i],
+ sliders[i],
+ slice_labels[i],
+ slice_previews[i],
+ download_zips[i],
+ ])
+
+ preload_outputs: list = []
+ for i in range(3):
+ preload_outputs.extend([
+ stl_files[i],
+ model_viewers[i],
+ model_details_list[i],
+ ])
+
+ load_samples_button.click(
+ fn=preload_sample_models,
+ inputs=[model_opacity, scale_to_target, scale_mode, *all_scale_inputs],
+ outputs=preload_outputs,
+ )
+
+ refresh_outputs: list = []
+ for i in range(3):
+ refresh_outputs.extend([model_viewers[i], model_details_list[i]])
+
+ refresh_inputs = [
+ stl_files[0],
+ stl_files[1],
+ stl_files[2],
+ model_opacity,
+ scale_to_target,
+ scale_mode,
+ *all_scale_inputs,
+ ]
+
+ model_opacity.change(
+ fn=refresh_all_model_viewers,
+ inputs=refresh_inputs,
+ outputs=refresh_outputs,
+ )
+ for scale_control in (scale_to_target, scale_mode, *all_scale_inputs):
+ scale_control.change(
+ fn=refresh_all_model_viewers,
+ inputs=refresh_inputs,
+ outputs=refresh_outputs,
+ )
+
+ generate_button.click(
+ fn=generate_all_stacks,
+ inputs=[
+ stl_files[0],
+ stl_files[1],
+ stl_files[2],
+ layer_height,
+ pixel_size,
+ scale_to_target,
+ scale_mode,
+ *all_scale_inputs,
+ ],
+ outputs=generate_outputs,
+ )
+
+ ref_generate_button.click(
+ fn=generate_reference_stack,
+ inputs=[states[0], states[1], states[2]],
+ outputs=[ref_state, ref_slice_slider, ref_slice_label, ref_slice_preview],
+ )
+
+ with gr.Tab("TIFF Slices to GCode"):
+ gr.Markdown(
+ """
+ # TIFF Slices to GCode
+ Uses TIFF ZIP outputs from the first tab. Set pressure, valve,
+ and port for each shape, then generate G-code files in one run.
+ """
+ )
+
+ with gr.Row():
+ with gr.Column(min_width=250):
+ with gr.Group(elem_classes=["gcode-shape-card"]):
+ gr.Markdown("### Shape 1")
+ with gr.Row():
+ with gr.Column(min_width=70):
+ gr.Markdown("Pressure (psi)", elem_classes=["gcode-param-label"])
+ gcode_pressure_1 = gr.Number(
+ show_label=False,
+ value=25.0,
+ minimum=0.0,
+ step=0.5,
+ )
+ with gr.Column(min_width=70):
+ gr.Markdown("Valve", elem_classes=["gcode-param-label"])
+ gcode_valve_1 = gr.Number(
+ show_label=False,
+ value=4,
+ minimum=0,
+ step=1,
+ precision=0,
+ )
+ with gr.Column(min_width=70):
+ gr.Markdown("Port", elem_classes=["gcode-param-label"])
+ gcode_port_1 = gr.Number(
+ show_label=False,
+ value=1,
+ minimum=1,
+ step=1,
+ precision=0,
+ )
+ with gr.Column(min_width=250):
+ with gr.Group(elem_classes=["gcode-shape-card"]):
+ gr.Markdown("### Shape 2")
+ with gr.Row():
+ with gr.Column(min_width=70):
+ gr.Markdown("Pressure (psi)", elem_classes=["gcode-param-label"])
+ gcode_pressure_2 = gr.Number(
+ show_label=False,
+ value=25.0,
+ minimum=0.0,
+ step=0.5,
+ )
+ with gr.Column(min_width=70):
+ gr.Markdown("Valve", elem_classes=["gcode-param-label"])
+ gcode_valve_2 = gr.Number(
+ show_label=False,
+ value=4,
+ minimum=0,
+ step=1,
+ precision=0,
+ )
+ with gr.Column(min_width=70):
+ gr.Markdown("Port", elem_classes=["gcode-param-label"])
+ gcode_port_2 = gr.Number(
+ show_label=False,
+ value=1,
+ minimum=1,
+ step=1,
+ precision=0,
+ )
+ with gr.Column(min_width=250):
+ with gr.Group(elem_classes=["gcode-shape-card"]):
+ gr.Markdown("### Shape 3")
+ with gr.Row():
+ with gr.Column(min_width=70):
+ gr.Markdown("Pressure (psi)", elem_classes=["gcode-param-label"])
+ gcode_pressure_3 = gr.Number(
+ show_label=False,
+ value=25.0,
+ minimum=0.0,
+ step=0.5,
+ )
+ with gr.Column(min_width=70):
+ gr.Markdown("Valve", elem_classes=["gcode-param-label"])
+ gcode_valve_3 = gr.Number(
+ show_label=False,
+ value=4,
+ minimum=0,
+ step=1,
+ precision=0,
+ )
+ with gr.Column(min_width=70):
+ gr.Markdown("Port", elem_classes=["gcode-param-label"])
+ gcode_port_3 = gr.Number(
+ show_label=False,
+ value=1,
+ minimum=1,
+ step=1,
+ precision=0,
+ )
+
+ gcode_use_ref_motion = gr.Checkbox(
+ label="Use Reference Stack for motion (all shapes share one nozzle path; each dispenses only its own geometry). Generate the Reference TIFF Stack on the first tab first.",
+ value=True,
+ )
+ gcode_all_g1 = gr.Checkbox(
+ label="Move at one constant speed (no fast travel moves)",
+ info=(
+ "Every move — including repositioning between deposits — uses the "
+ "slower printing-speed command (G1) instead of a rapid travel command "
+ "(G0). The valve still controls where material is actually dispensed. "
+ "Applies to all shapes."
+ ),
+ value=True,
+ )
+ gcode_button = gr.Button("Generate G-Code", variant="primary")
+
+ with gr.Row():
+ gcode_file_1 = gr.File(label="Download G-Code Shape 1", interactive=False, elem_classes=["gcode-download"])
+ gcode_file_2 = gr.File(label="Download G-Code Shape 2", interactive=False, elem_classes=["gcode-download"])
+ gcode_file_3 = gr.File(label="Download G-Code Shape 3", interactive=False, elem_classes=["gcode-download"])
+
+ # Per-shape G-code preview, aligned under each download box. Fixed
+ # height with internal scrolling so the files don't fill the page.
+ with gr.Row():
+ gcode_view_1 = gr.Code(
+ label="Shape 1 G-Code", language=None, lines=15, max_lines=15,
+ interactive=False, elem_classes=["gcode-view"], elem_id="gcode-view-1",
+ )
+ gcode_view_2 = gr.Code(
+ label="Shape 2 G-Code", language=None, lines=15, max_lines=15,
+ interactive=False, elem_classes=["gcode-view"], elem_id="gcode-view-2",
+ )
+ gcode_view_3 = gr.Code(
+ label="Shape 3 G-Code", language=None, lines=15, max_lines=15,
+ interactive=False, elem_classes=["gcode-view"], elem_id="gcode-view-3",
+ )
+
+ gcode_status = gr.Markdown("")
+
+ gcode_button.click(
+ fn=run_all_tiff_to_gcode,
+ inputs=[
+ download_zips[0],
+ download_zips[1],
+ download_zips[2],
+ gcode_pressure_1,
+ gcode_valve_1,
+ gcode_port_1,
+ gcode_pressure_2,
+ gcode_valve_2,
+ gcode_port_2,
+ gcode_pressure_3,
+ gcode_valve_3,
+ gcode_port_3,
+ gcode_all_g1,
+ gcode_use_ref_motion,
+ ref_state,
+ layer_height,
+ pixel_size,
+ ],
+ outputs=[gcode_file_1, gcode_file_2, gcode_file_3, gcode_status],
+ ).then(
+ fn=load_all_gcode_text,
+ inputs=[gcode_file_1, gcode_file_2, gcode_file_3],
+ outputs=[gcode_view_1, gcode_view_2, gcode_view_3],
+ ).then(
+ # gr.Code's built-in download button hardcodes "file.txt"; stamp
+ # the real filename (from each File value's orig_name) onto the
+ # download link so it matches the Download G-Code Shape box.
+ fn=None,
+ inputs=[gcode_file_1, gcode_file_2, gcode_file_3],
+ outputs=[],
+ js="""(f1, f2, f3) => {
+ const files = [f1, f2, f3];
+ const nameOf = (f) => {
+ if (!f) return null;
+ if (f.orig_name) return f.orig_name;
+ const p = f.path || f.url || "";
+ return p ? p.split(/[\\\\/]/).pop() : null;
+ };
+ const apply = (tries) => {
+ let pending = false;
+ files.forEach((f, i) => {
+ const name = nameOf(f);
+ if (!name) return;
+ const a = document.querySelector(`#gcode-view-${i + 1} a[download]`);
+ if (a) {
+ if (a.getAttribute("download") !== name) a.setAttribute("download", name);
+ } else {
+ pending = true;
+ }
+ });
+ if (pending && tries < 20) setTimeout(() => apply(tries + 1), 150);
+ };
+ apply(0);
+ return [];
+ }""",
+ )
+
+ with gr.Tab("G-Code Visualization"):
+ gr.Markdown(
+ "### 3D Tool-Path Viewer\n"
+ "Choose a G-code source, then click **Render Tool Path** to visualize the nozzle path."
+ )
+ # --- G-code source selection, above the controls/chart area ---
+ with gr.Row():
+ gcode_source = gr.Radio(
+ choices=[
+ GCODE_SOURCE_SHAPE1,
+ GCODE_SOURCE_SHAPE2,
+ GCODE_SOURCE_SHAPE3,
+ GCODE_SOURCE_UPLOAD,
+ ],
+ value=GCODE_SOURCE_SHAPE1,
+ label="G-Code source",
+ )
+ # Shown only when "Upload G-Code file" is selected. Visibility is
+ # toggled client-side (see gcode_source.change below) instead of
+ # via a server round-trip, which raced and intermittently left
+ # the box hidden. Hidden initially by CSS (#gcode-upload-col).
+ with gr.Column(elem_id="gcode-upload-col") as upload_col:
+ gcode_upload = gr.File(
+ label="Upload G-Code",
+ file_types=[".txt", ".gcode", ".nc"],
+ interactive=True,
+ height=110,
+ )
+
+ with gr.Row():
+ # --- Left column: render buttons and plot controls ---
+ with gr.Column(scale=1, min_width=340):
+ render_line_button = gr.Button(
+ "Render Tool Path - Line Plot", variant="primary"
+ )
+ render_tube_button = gr.Button(
+ "Render Tool Path - Tube Plot with Animation", variant="primary"
+ )
+ gr.Markdown(
+ "⚠️ For high-resolution models (small layer "
+ "heights), the tube plot can take a while to build and render.",
+ elem_id="tube-render-warning",
+ )
+ anim_controls = gr.HTML(TOOLPATH_CONTROLS_HTML, visible=False)
+ with gr.Row():
+ travel_opacity_slider = gr.Slider(
+ label="Travel (G0) opacity",
+ minimum=0.0,
+ maximum=1.0,
+ value=0.2,
+ step=0.05,
+ min_width=150,
+ )
+ print_opacity_slider = gr.Slider(
+ label="Print (G1) opacity",
+ minimum=0.0,
+ maximum=1.0,
+ value=1.0,
+ step=0.05,
+ min_width=150,
+ )
+ with gr.Row():
+ travel_color_picker = gr.Dropdown(
+ label="Travel (G0) color",
+ choices=[("Grey", "#969696"), ("Orange", "#ff7f0e"), ("Green", "#2ca02c"), ("Red", "#d62728"), ("Purple", "#9467bd"), ("Pink", "#e377c2"), ("Black", "#000000"), ("White", "#ffffff")],
+ value="#969696",
+ allow_custom_value=False,
+ min_width=150,
+ )
+ print_color_picker = gr.Dropdown(
+ label="Print (G1) color",
+ choices=[("Blue", "#1f77b4"), ("Orange", "#ff7f0e"), ("Green", "#2ca02c"), ("Red", "#d62728"), ("Purple", "#9467bd"), ("Pink", "#e377c2"), ("Black", "#000000"), ("White", "#ffffff")],
+ value="#ff7f0e",
+ allow_custom_value=False,
+ min_width=150,
+ )
+ with gr.Row(visible=False) as width_row:
+ travel_width_slider = gr.Slider(
+ label="Travel width (mm)",
+ minimum=0.05,
+ maximum=1.2,
+ value=0.2,
+ step=0.05,
+ min_width=150,
+ )
+ print_width_slider = gr.Slider(
+ label="Filament width (mm)",
+ minimum=0.1,
+ maximum=1.2,
+ value=0.8,
+ step=0.05,
+ min_width=150,
+ )
+ toolpath_status = gr.Markdown("")
+
+ # --- Right column: the chart ---
+ with gr.Column(scale=3, min_width=500):
+ toolpath_plot = gr.Plot(label="Tool Path", elem_id="toolpath_plot")
+
+ parsed_state = gr.State({})
+ render_mode = gr.State("tube")
+
+ gcode_source.change(
+ fn=None,
+ inputs=[gcode_source],
+ outputs=[],
+ js="""(src) => {
+ const col = document.getElementById('gcode-upload-col');
+ if (col) col.style.display = (src === '"""
+ + GCODE_SOURCE_UPLOAD
+ + """') ? 'flex' : 'none';
+ return [];
+ }""",
+ )
+ render_inputs = [gcode_source, gcode_upload, gcode_file_1, gcode_file_2, gcode_file_3, travel_opacity_slider, print_opacity_slider, travel_color_picker, print_color_picker, print_width_slider, travel_width_slider]
+ render_line_button.click(
+ fn=render_toolpath_lines,
+ inputs=render_inputs,
+ outputs=[toolpath_plot, toolpath_status, parsed_state, render_mode, anim_controls, width_row],
+ )
+ render_tube_button.click(
+ fn=render_toolpath_tubes,
+ inputs=render_inputs,
+ outputs=[toolpath_plot, toolpath_status, parsed_state, render_mode, anim_controls, width_row],
+ )
+ # Changing the travel width rebuilds the figure in the last-used mode.
+ travel_width_slider.release(
+ fn=rerender_toolpath_current_mode,
+ inputs=[render_mode] + render_inputs,
+ outputs=[toolpath_plot, toolpath_status, parsed_state],
+ )
+ # Changing the filament width rebuilds the figure in the last-used mode.
+ print_width_slider.release(
+ fn=rerender_toolpath_current_mode,
+ inputs=[render_mode] + render_inputs,
+ outputs=[toolpath_plot, toolpath_status, parsed_state],
+ )
+ # Keep the filament and travel width sliders in sync with the Layer
+ # Height chosen on the slicing tab: filament width equals the layer
+ # height, travel width is a quarter of it, both capped 50% above the
+ # layer height.
+ def sync_width_sliders(v: float):
+ height = float(v or 0.8)
+ travel = height / 4
+ print_update = gr.update(
+ value=height, minimum=min(0.1, height), maximum=height * 1.5
+ )
+ travel_update = gr.update(
+ value=travel, minimum=min(0.05, travel), maximum=height * 1.5
+ )
+ return print_update, travel_update
+
+ layer_height.change(
+ fn=sync_width_sliders,
+ inputs=[layer_height],
+ outputs=[print_width_slider, travel_width_slider],
+ queue=False,
+ )
+ travel_opacity_slider.release(
+ fn=None,
+ inputs=[travel_opacity_slider],
+ outputs=[],
+ js="""(opacity_val) => {
+ const container = document.getElementById("toolpath_plot");
+ if (!container) return [];
+ const plotDiv = container.querySelector(".js-plotly-plot");
+ if (!plotDiv || !plotDiv.data) return [];
+ const indices = plotDiv.data
+ .map((t, i) => t.name === "Travel (G0)" ? i : -1)
+ .filter(i => i >= 0);
+ if (indices.length > 0) Plotly.restyle(plotDiv, {opacity: opacity_val}, indices);
+ return [];
+ }"""
+ )
+ print_opacity_slider.release(
+ fn=None,
+ inputs=[print_opacity_slider],
+ outputs=[],
+ js="""(opacity_val) => {
+ const container = document.getElementById("toolpath_plot");
+ if (!container) return [];
+ const plotDiv = container.querySelector(".js-plotly-plot");
+ if (!plotDiv || !plotDiv.data) return [];
+ const indices = plotDiv.data
+ .map((t, i) => t.name === "Print (G1)" ? i : -1)
+ .filter(i => i >= 0);
+ if (indices.length > 0) Plotly.restyle(plotDiv, {opacity: opacity_val}, indices);
+ return [];
+ }"""
+ )
+ travel_color_picker.change(
+ fn=None,
+ inputs=[travel_color_picker],
+ outputs=[],
+ js="""(color) => {
+ const container = document.getElementById("toolpath_plot");
+ if (!container) return [];
+ const plotDiv = container.querySelector(".js-plotly-plot");
+ if (!plotDiv || !plotDiv.data) return [];
+ plotDiv.data.forEach((t, i) => {
+ if (t.name !== "Travel (G0)") return;
+ const attr = t.type === "mesh3d" ? {"color": color} : {"line.color": color};
+ Plotly.restyle(plotDiv, attr, [i]);
+ });
+ return [];
+ }"""
+ )
+ print_color_picker.change(
+ fn=None,
+ inputs=[print_color_picker],
+ outputs=[],
+ js="""(color) => {
+ const container = document.getElementById("toolpath_plot");
+ if (!container) return [];
+ const plotDiv = container.querySelector(".js-plotly-plot");
+ if (!plotDiv || !plotDiv.data) return [];
+ plotDiv.data.forEach((t, i) => {
+ if (t.name !== "Print (G1)") return;
+ const attr = t.type === "mesh3d" ? {"color": color} : {"line.color": color};
+ Plotly.restyle(plotDiv, attr, [i]);
+ });
+ return [];
+ }"""
+ )
+
+ with gr.Tab("Parallel Printing Visualization"):
+ gr.Markdown(
+ "### Parallel Printing Visualization\n"
+ "Plots all three shapes side by side (offset in X) and animates "
+ "them printing in parallel. Uses the G-code generated on the "
+ "**TIFF Slices to GCode** tab."
+ )
+ with gr.Row():
+ # --- Left column: controls ---
+ with gr.Column(scale=1, min_width=340):
+ parallel_line_button = gr.Button(
+ "Render Parallel Print - Line Plot", variant="primary"
+ )
+ parallel_render_button = gr.Button(
+ "Render Parallel Print - Tube Plot with Animation", variant="primary"
+ )
+ gr.Markdown(
+ "⚠️ Building three tube plots can take a while "
+ "for high-resolution models.",
+ elem_id="parallel-render-warning",
+ )
+ parallel_anim_controls = gr.HTML(PARALLEL_CONTROLS_HTML, visible=False)
+ with gr.Row():
+ pp_color_1 = gr.Dropdown(
+ label="Shape 1 color", choices=PARALLEL_COLOR_CHOICES,
+ value="#ff7f0e", allow_custom_value=False, min_width=120,
+ )
+ pp_color_2 = gr.Dropdown(
+ label="Shape 2 color", choices=PARALLEL_COLOR_CHOICES,
+ value="#1f77b4", allow_custom_value=False, min_width=120,
+ )
+ pp_color_3 = gr.Dropdown(
+ label="Shape 3 color", choices=PARALLEL_COLOR_CHOICES,
+ value="#2ca02c", allow_custom_value=False, min_width=120,
+ )
+ pp_travel_opacity = gr.Slider(
+ label="Travel opacity (0 = hidden)",
+ minimum=0.0, maximum=1.0, value=0.2, step=0.05,
+ )
+ with gr.Row(visible=False) as pp_width_row:
+ pp_filament_width = gr.Slider(
+ label="Filament width (mm)", minimum=0.1, maximum=3.0,
+ value=0.8, step=0.05, min_width=150,
+ )
+ pp_travel_width = gr.Slider(
+ label="Travel width (mm)", minimum=0.05, maximum=3.0,
+ value=0.2, step=0.05, min_width=150,
+ )
+ pp_gap = gr.Slider(
+ label="Gap between parts (mm)",
+ minimum=0.0, maximum=50.0, value=5.0, step=0.5,
+ )
+ parallel_status = gr.Markdown("")
+
+ with gr.Group(visible=False) as pp_export_group:
+ gr.Markdown(
+ "**Export animation (GIF)** — a server-side line "
+ "animation of the parallel print. Set the viewing "
+ "angle below."
+ )
+ with gr.Row():
+ pp_gif_duration = gr.Slider(
+ label="Duration (s)", minimum=2.0, maximum=20.0,
+ value=6.0, step=1.0, min_width=150,
+ )
+ pp_gif_fps = gr.Slider(
+ label="Frames per second", minimum=5, maximum=30,
+ value=10, step=1, min_width=150,
+ )
+ with gr.Row():
+ pp_elev = gr.Slider(
+ label="Elevation angle", minimum=0, maximum=90,
+ value=22, step=1, min_width=150,
+ )
+ pp_azim = gr.Slider(
+ label="Azimuth angle", minimum=-180, maximum=180,
+ value=-60, step=5, min_width=150,
+ )
+ pp_gif_travel_opacity = gr.Slider(
+ label="Travel opacity in GIF (0 = hidden)",
+ minimum=0.0, maximum=1.0, value=0.15, step=0.05,
+ )
+ pp_export_button = gr.Button("Export Animation as GIF", variant="primary")
+ pp_gif_file = gr.File(label="Download GIF", interactive=False)
+ # --- Right column: the plot ---
+ with gr.Column(scale=3, min_width=500):
+ parallel_plot = gr.Plot(label="Parallel Tool Paths", elem_id="parallel_plot")
+
+ parallel_mode = gr.State("tube")
+ parallel_render_inputs = [
+ gcode_file_1, gcode_file_2, gcode_file_3,
+ pp_color_1, pp_color_2, pp_color_3,
+ pp_travel_opacity, pp_filament_width, pp_travel_width, pp_gap,
+ ]
+ parallel_outputs = [
+ parallel_plot, parallel_status, parallel_mode,
+ parallel_anim_controls, pp_width_row, pp_export_group,
+ ]
+ parallel_line_button.click(
+ fn=render_parallel_lines,
+ inputs=parallel_render_inputs,
+ outputs=parallel_outputs,
+ )
+ parallel_render_button.click(
+ fn=render_parallel_tubes,
+ inputs=parallel_render_inputs,
+ outputs=parallel_outputs,
+ )
+ # Width and gap changes rebuild the figure in the last-used mode.
+ for _slider in (pp_filament_width, pp_travel_width, pp_gap):
+ _slider.release(
+ fn=rerender_parallel_current_mode,
+ inputs=[parallel_mode] + parallel_render_inputs,
+ outputs=[parallel_plot, parallel_status],
+ )
+ # Color changes recolor a part's print/travel/nozzle traces client-side.
+ for _picker, _idx in ((pp_color_1, 1), (pp_color_2, 2), (pp_color_3, 3)):
+ _picker.change(
+ fn=None,
+ inputs=[_picker],
+ outputs=[],
+ js="""(color) => {
+ const c = document.getElementById("parallel_plot");
+ if (!c) return [];
+ const pd = c.querySelector(".js-plotly-plot");
+ if (!pd || !pd.data) return [];
+ pd.data.forEach((t, i) => {
+ if (t.name === "Shape %d" || t.name === "Travel %d") {
+ const attr = t.type === "mesh3d" ? {"color": color} : {"line.color": color};
+ Plotly.restyle(pd, attr, [i]);
+ } else if (t.name === "Nozzle %d") {
+ Plotly.restyle(pd, {"marker.color": color}, [i]);
+ }
+ });
+ return [];
+ }""" % (_idx, _idx, _idx),
+ )
+ # Travel opacity changes apply to all travel meshes client-side.
+ pp_travel_opacity.release(
+ fn=None,
+ inputs=[pp_travel_opacity],
+ outputs=[],
+ js="""(op) => {
+ const c = document.getElementById("parallel_plot");
+ if (!c) return [];
+ const pd = c.querySelector(".js-plotly-plot");
+ if (!pd || !pd.data) return [];
+ pd.data.forEach((t, i) => {
+ if (/^Travel \\d+$/.test(t.name)) Plotly.restyle(pd, {"opacity": op}, [i]);
+ });
+ return [];
+ }""",
+ )
+ # Export: render the GIF server-side with Matplotlib (CPU/Agg) —
+ # no WebGL or headless browser, so it works locally and on HF.
+ pp_export_button.click(
+ fn=export_parallel_gif,
+ inputs=[
+ gcode_file_1, gcode_file_2, gcode_file_3,
+ pp_color_1, pp_color_2, pp_color_3,
+ pp_gif_travel_opacity, pp_gap, pp_gif_duration, pp_gif_fps,
+ pp_elev, pp_azim,
+ ],
+ outputs=[pp_gif_file],
+ )
+
+ return demo
+
+
+demo = build_demo()
+
+
+if __name__ == "__main__":
+ demo.launch(ssr_mode=False)