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, 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" 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 = """
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 = """
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) -> 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" ) return "\n".join( [ "### Model Details", f"- Source: `{source_name}`", f"- Extents: `{extents[0]:.3f} x {extents[1]:.3f} x {extents[2]:.3f}`", f"- Faces: `{len(mesh.faces)}`", f"- Vertices: `{len(mesh.vertices)}`", f"- Watertight ({watertight_explanation}): `{watertight_status}`", ] ) 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 _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) -> tuple[str | None, str]: if not stl_file: return _viewer_update(None), "No model loaded." mesh = load_mesh(stl_file) glb_path = _build_annotated_scene(mesh, opacity=_resolve_model_opacity(opacity)) return _viewer_update(glb_path), _format_model_details(Path(stl_file).name, mesh) def preload_sample_models(opacity: float = 1.0) -> tuple: outputs: list[Any] = [] resolved_opacity = _resolve_model_opacity(opacity) for filename in 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 = load_mesh(stl_path) 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), ]) return tuple(outputs) def refresh_all_model_viewers( stl1: str | None, stl2: str | None, stl3: str | None, opacity: float, ) -> tuple: outputs: list[Any] = [] resolved_opacity = _resolve_model_opacity(opacity) for stl_file in (stl1, stl2, stl3): if not stl_file: outputs.extend([_viewer_update(None), "No model loaded."]) continue outputs.extend(load_single_model(stl_file, resolved_opacity)) return tuple(outputs) def generate_all_stacks( stl1: str | None, stl2: str | None, stl3: str | None, layer_height: float, pixel_size: float, progress: gr.Progress = gr.Progress(), ): files = [stl1, stl2, stl3] valid_count = max(1, sum(1 for f in files if f)) results: list = [] completed = 0 for stl_file in files: 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", ) stack = slice_stl_to_tiffs( stl_file, layer_height=layer_height, pixel_size=pixel_size, progress_callback=report_progress, ) 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 a shared 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, ) # --- Upload + 3D viewer row --- stl_files: list[gr.File] = [] model_viewers: list[gr.Model3D] = [] model_details_list: list[gr.Markdown] = [] 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.") stl_files.append(stl_file) model_viewers.append(model_viewer) model_details_list.append(model_details) # --- 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 --- for i in range(3): stl_files[i].change( fn=load_single_model, inputs=[stl_files[i], model_opacity], 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], outputs=preload_outputs, ) refresh_outputs: list = [] for i in range(3): refresh_outputs.extend([model_viewers[i], model_details_list[i]]) model_opacity.change( fn=refresh_all_model_viewers, inputs=[stl_files[0], stl_files[1], stl_files[2], model_opacity], outputs=refresh_outputs, ) generate_button.click( fn=generate_all_stacks, inputs=[stl_files[0], stl_files[1], stl_files[2], layer_height, pixel_size], 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)