diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -2,6 +2,7 @@ from __future__ import annotations import tempfile import math +import time import warnings from pathlib import Path from typing import Any @@ -42,9 +43,11 @@ 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) +DELETE_SHAPE_COOLDOWN_SECONDS = 1.0 UNIFORM_TARGET_AXES = ("X", "Y", "Z") SCALE_MODE_TARGET_DIMENSIONS = "Independent X/Y/Z" SCALE_MODE_UNIFORM_FACTOR = "Keep Proportions" +TARGET_DIMENSION_KEYS = ("target_x", "target_y", "target_z") FRONT_CAMERA = (90, 80, None) APP_CSS = """ .gradio-container { @@ -115,6 +118,49 @@ APP_CSS = """ box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.35) !important; } +#nozzle-spacing-table table tbody tr td:nth-child(-n+2), +#nozzle-spacing-table table thead th, +#nozzle-spacing-table [role="columnheader"], +#nozzle-spacing-table [role="gridcell"]:nth-child(4n + 1), +#nozzle-spacing-table [role="gridcell"]:nth-child(4n + 2) { + background: rgba(243, 244, 246, 0.82) !important; + color: var(--body-text-color-subdued) !important; + pointer-events: none; + user-select: none; +} + +#shape-settings-table table tbody tr td:last-child, +#shape-settings-table [role="gridcell"]:nth-child(10n) { + color: #ffffff !important; + cursor: pointer; + font-size: 0 !important; + text-align: center !important; + user-select: none; +} + +#shape-settings-table table tbody tr td:last-child::after, +#shape-settings-table [role="gridcell"]:nth-child(10n)::after { + content: "X"; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.45rem; + height: 1.45rem; + border-radius: 999px; + background: #dc2626; + color: #ffffff; + font-size: 0.85rem; + font-weight: 700; + line-height: 1; +} + +#shape-settings-table table tbody tr td:last-child input, +#shape-settings-table [role="gridcell"]:nth-child(10n) input, +#shape-settings-table table tbody tr td:last-child textarea, +#shape-settings-table [role="gridcell"]:nth-child(10n) textarea { + display: none !important; +} + #toolpath-anim-controls { display: flex; align-items: center; @@ -244,8 +290,18 @@ APP_HEAD = """ } }); } + function suppressDeleteCellEditor(event) { + var cell = event.target && event.target.closest ? event.target.closest('#shape-settings-table td:last-child, #shape-settings-table [role="gridcell"]:nth-child(10n)') : null; + if (!cell) return; + setTimeout(function () { + if (document.activeElement && cell.contains(document.activeElement)) { + document.activeElement.blur(); + } + }, 0); + } function start() { enableUndoButtons(); + document.addEventListener('focusin', suppressDeleteCellEditor); var observer = new MutationObserver(function (mutations) { for (var i = 0; i < mutations.length; i++) { var m = mutations[i]; @@ -1029,24 +1085,6 @@ def _normalize_scale_mode(scale_mode: str | None) -> str: 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 _resolve_mesh_scale_factors( mesh: trimesh.Trimesh, scale_to_target: bool | None, @@ -1058,20 +1096,17 @@ def _resolve_mesh_scale_factors( if not scale_to_target: return None - if _normalize_scale_mode(scale_mode) == SCALE_MODE_UNIFORM_FACTOR: - scale = _resolve_uniform_scale_from_targets( - mesh, - True, - target_x, - target_y, - target_z, - anchor_axis="X", - ) - return (scale, scale, scale) - target_extents = _resolve_target_extents(True, target_x, target_y, target_z) if target_extents is None: return None + + if _normalize_scale_mode(scale_mode) == SCALE_MODE_UNIFORM_FACTOR: + extents = np.asarray(mesh.extents, dtype=float) + ratios = np.asarray(target_extents, dtype=float) / extents + anchor_index = int(np.argmax(np.abs(np.log(ratios)))) + scale = float(ratios[anchor_index]) + return (scale, scale, scale) + return scale_factors_for_target_extents(mesh, target_extents) @@ -1134,20 +1169,6 @@ def _viewer_update(model_path: str | None) -> dict[str, Any]: return gr.update(value=model_path, camera_position=FRONT_CAMERA) -def update_scaling_controls_visibility( - scale_to_target: bool | None, - scale_mode: str | None, -) -> tuple[dict[str, Any], ...]: - enabled = bool(scale_to_target) - - return ( - gr.update(visible=enabled), - gr.update(visible=enabled), - gr.update(visible=enabled), - gr.update(visible=enabled), - ) - - def sync_uniform_target_dimensions( stl_file: str | None, scale_to_target: bool | None, @@ -1323,426 +1344,13 @@ def load_single_model( 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], - target2_x: float | None = DEFAULT_TARGET_EXTENTS[0], - target2_y: float | None = DEFAULT_TARGET_EXTENTS[1], - target2_z: float | None = DEFAULT_TARGET_EXTENTS[2], - target3_x: float | None = DEFAULT_TARGET_EXTENTS[0], - target3_y: float | None = DEFAULT_TARGET_EXTENTS[1], - target3_z: float | None = DEFAULT_TARGET_EXTENTS[2], -) -> 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, - ) - - 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], - ) - 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], - target2_x: float | None = DEFAULT_TARGET_EXTENTS[0], - target2_y: float | None = DEFAULT_TARGET_EXTENTS[1], - target2_z: float | None = DEFAULT_TARGET_EXTENTS[2], - target3_x: float | None = DEFAULT_TARGET_EXTENTS[0], - target3_y: float | None = DEFAULT_TARGET_EXTENTS[1], - target3_z: float | None = DEFAULT_TARGET_EXTENTS[2], -) -> 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, - ) - - for stl_file, values in zip((stl1, stl2, stl3), target_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], - ) - ) - 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], - target2_x: float | None = DEFAULT_TARGET_EXTENTS[0], - target2_y: float | None = DEFAULT_TARGET_EXTENTS[1], - target2_z: float | None = DEFAULT_TARGET_EXTENTS[2], - target3_x: float | None = DEFAULT_TARGET_EXTENTS[0], - target3_y: float | None = DEFAULT_TARGET_EXTENTS[1], - target3_z: float | None = DEFAULT_TARGET_EXTENTS[2], - 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, - ) - valid_count = max(1, sum(1 for f in files if f)) - results: list = [] - completed = 0 - - for stl_file, values in zip(files, target_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], - ) - - 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"), @@ -1751,36 +1359,6 @@ PARALLEL_COLOR_CHOICES = [ DEFAULT_PARALLEL_COLORS = ("#ff7f0e", "#1f77b4", "#2ca02c") -def _load_parallel_parts( - path1: str | None, - path2: str | None, - path3: str | None, - color1: str, - color2: str, - color3: str, -) -> tuple[list[dict], list[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).") - - return parts, messages - - def _resolve_nozzle_layout( parts: list[dict], same_spacing: bool | None, @@ -1788,6 +1366,7 @@ def _resolve_nozzle_layout( part_gap_12_y: float | None, part_gap_23_x: float | None, part_gap_23_y: float | None, + *extra_pair_gaps: float | None, ) -> tuple[dict[int, tuple[float, float]], list[dict]]: offsets: dict[int, tuple[float, float]] = {} spacings: list[dict] = [] @@ -1797,22 +1376,39 @@ def _resolve_nozzle_layout( ordered = sorted(parts, key=lambda part: part["idx"]) offsets[ordered[0]["idx"]] = (0.0, 0.0) + first_spacing = (float(part_gap_12_x or 0.0), float(part_gap_12_y or 0.0)) + raw_pairs: list[tuple[float, float]] = [ + first_spacing, + (float(part_gap_23_x or 0.0), float(part_gap_23_y or 0.0)), + ] + for index in range(0, len(extra_pair_gaps), 2): + raw_pairs.append(( + float(extra_pair_gaps[index] or 0.0), + float(extra_pair_gaps[index + 1] or 0.0) if index + 1 < len(extra_pair_gaps) else 0.0, + )) + max_pair_count = max((int(part.get("idx", 0)) for part in parts), default=1) - 1 + while len(raw_pairs) < max_pair_count: + raw_pairs.append(first_spacing) + if same_spacing: + raw_pairs = [first_spacing for _ in raw_pairs] + pair_spacing = { - (1, 2): (float(part_gap_12_x or 0.0), float(part_gap_12_y or 0.0)), - (2, 3): ( - float(part_gap_12_x or 0.0) if same_spacing else float(part_gap_23_x or 0.0), - float(part_gap_12_y or 0.0) if same_spacing else float(part_gap_23_y or 0.0), - ), + (index + 1, index + 2): raw_pairs[index] + for index in range(len(raw_pairs)) } def spacing_between(prev_idx: int, cur_idx: int) -> tuple[float, float]: if (prev_idx, cur_idx) in pair_spacing: return pair_spacing[(prev_idx, cur_idx)] - if prev_idx == 1 and cur_idx == 3: - x12, y12 = pair_spacing[(1, 2)] - x23, y23 = pair_spacing[(2, 3)] - return x12 + x23, y12 + y23 - return 0.0, 0.0 + if cur_idx <= prev_idx: + return 0.0, 0.0 + total_x = 0.0 + total_y = 0.0 + for pair_idx in range(prev_idx, cur_idx): + step_x, step_y = pair_spacing.get((pair_idx, pair_idx + 1), first_spacing) + total_x += step_x + total_y += step_y + return total_x, total_y for previous, current in zip(ordered, ordered[1:]): prev_idx = previous["idx"] @@ -1838,12 +1434,6 @@ def _same_spacing_from_individual(use_individual_spacing: bool | None) -> bool: return not bool(use_individual_spacing) -def update_individual_nozzle_spacing_visibility( - use_individual_spacing: bool | None, -) -> dict[str, Any]: - return gr.update(visible=bool(use_individual_spacing)) - - def _format_shape_dimensions(parts: list[dict]) -> list[str]: lines = ["**Shape dimensions from generated G-code:**"] for part in sorted(parts, key=lambda item: item["idx"]): @@ -1896,200 +1486,38 @@ def _format_nozzle_spacing_status( return " \n".join(lines) -def render_nozzle_spacing_preview( - path1: str | None, - path2: str | None, - path3: str | None, - use_individual_spacing: bool, - part_gap_12_x: float, - part_gap_12_y: float, - part_gap_23_x: float, - part_gap_23_y: float, -) -> tuple[Any, str]: - parts, _messages = _load_parallel_parts(path1, path2, path3, *DEFAULT_PARALLEL_COLORS) - if not parts: - return None, "No shape G-code available. Generate G-code first." - offsets, spacings = _resolve_nozzle_layout( - parts, - _same_spacing_from_individual(use_individual_spacing), - part_gap_12_x, - part_gap_12_y, - part_gap_23_x, - part_gap_23_y, - ) - figure = build_nozzle_spacing_figure(parts, offsets, spacings) - return figure, _format_nozzle_spacing_status(parts, offsets, spacings) - +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 -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, - use_individual_spacing: bool, - part_gap_12_x: float, - part_gap_12_y: float, - part_gap_23_x: float, - part_gap_23_y: float, - tube: bool = True, -) -> tuple[Any, str]: - parts, messages = _load_parallel_parts(path1, path2, path3, color1, color2, color3) + 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 - if not parts: - return None, "No shape G-code available. Generate G-code on the TIFF Slices to GCode tab first." - offsets, spacings = _resolve_nozzle_layout( - parts, - _same_spacing_from_individual(use_individual_spacing), - part_gap_12_x, - part_gap_12_y, - part_gap_23_x, - part_gap_23_y, - ) - figure = build_parallel_figure( - parts, - part_offsets=offsets, - filament_width=float(filament_width), - travel_width=float(travel_width), - travel_opacity=float(travel_opacity), - tube=tube, - ) - messages.append(_format_nozzle_spacing_status(parts, offsets, spacings)) - return figure, " \n".join(messages) +def generate_reference_stack( + *states: 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 states if s.get("tiff_paths")] -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), - ) + if not active_states: + return ( + _empty_state(), + _reset_slider(), + "No TIFF stacks available. Generate TIFF stacks first.", + None, + ) - -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, - use_individual_spacing: bool, - part_gap_12_x: float, - part_gap_12_y: float, - part_gap_23_x: float, - part_gap_23_y: 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. - """ - parts, _messages = _load_parallel_parts(path1, path2, path3, color1, color2, color3) - - if not parts: - return None - offsets, _spacings = _resolve_nozzle_layout( - parts, - _same_spacing_from_individual(use_individual_spacing), - part_gap_12_x, - part_gap_12_y, - part_gap_23_x, - part_gap_23_y, - ) - - 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, - part_offsets=offsets, - 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) + max_layers = max(len(s["tiff_paths"]) for s in active_states) # Determine the largest image dimensions across all stacks. max_width = 0 @@ -2181,1072 +1609,1184 @@ def generate_reference_stack( 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, optionally scale each shape, choose 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="Scale STLs", - value=False, - ) - with gr.Column(scale=0, min_width=180): - regenerate_models_button = gr.Button( - "Regenerate Images", - variant="secondary", - size="sm", - ) +SHAPE_SETTINGS_HEADERS = ["Shape", "STL", "Target X (mm)", "Target Y (mm)", "Target Z (mm)", "Pressure (psi)", "Valve", "Port", "Color", "Delete"] +SIMPLE_NOZZLE_SPACING_HEADERS = [ + "Spacing Mode", + "Applies To", + "X edge spacing (mm)", + "Y nozzle spacing (mm)", +] +ADVANCED_NOZZLE_SPACING_HEADERS = [ + "From Shape", + "To Shape", + "X edge spacing (mm)", + "Y nozzle spacing (mm)", +] +NOZZLE_SPACING_HEADERS = SIMPLE_NOZZLE_SPACING_HEADERS - with gr.Group(visible=False) as scaling_details_group: - with gr.Row(): - 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_groups: list[gr.Group] = [] - target_xs: list[gr.Number] = [] - target_ys: list[gr.Number] = [] - target_zs: list[gr.Number] = [] +def _normalise_rows(table: Any) -> list[list[Any]]: + if table is None: + return [] + if hasattr(table, "values") and hasattr(table, "columns"): + return table.values.tolist() + if isinstance(table, dict) and "data" in table: + return table.get("data") or [] + return list(table or []) - 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.Group(visible=False) as target_group: - 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, - ) - stl_files.append(stl_file) - model_viewers.append(model_viewer) - model_details_list.append(model_details) - target_groups.append(target_group) - target_xs.append(target_x) - target_ys.append(target_y) - target_zs.append(target_z) - - # --- Shared slicing controls --- - with gr.Row(): - layer_height = gr.Number(label="Layer Height (mm)", value=0.8, minimum=0.0001, step=0.01) - pixel_size = gr.Number( - label="Pixel Size/Fill Width (mm)", - 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] = [] +def _file_path_value(file_value: Any) -> str | None: + if not file_value: + return None + if isinstance(file_value, (str, Path)): + return str(file_value) + if isinstance(file_value, dict): + return file_value.get("path") or file_value.get("name") or file_value.get("orig_name") + return getattr(file_value, "name", None) or getattr(file_value, "path", None) - 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") +def _uploaded_file_paths(files: Any) -> list[str]: + if not files: + return [] + values = files if isinstance(files, list) else [files] + paths = [_file_path_value(value) for value in values] + return [str(path) for path in paths if path] - 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, - ) +def _default_color(index: int) -> str: + return DEFAULT_PARALLEL_COLORS[(index - 1) % len(DEFAULT_PARALLEL_COLORS)] - # --- File upload handlers --- - all_scale_inputs = [ - target_xs[0], - target_ys[0], - target_zs[0], - target_xs[1], - target_ys[1], - target_zs[1], - target_xs[2], - target_ys[2], - target_zs[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], - ], - 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], - ]) - - sample_load_event = load_samples_button.click( - fn=preload_sample_models, - inputs=[model_opacity, scale_to_target, scale_mode, *all_scale_inputs], - outputs=preload_outputs, - ) +def _default_target_extents_for_stl(path: str) -> tuple[float, float, float]: + try: + extents = load_mesh(path).extents + values = tuple(float(value) for value in extents) + if len(values) == 3 and all(math.isfinite(value) and value > 0 for value in values): + return values + except Exception: + pass + return DEFAULT_TARGET_EXTENTS - 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, - ) - visibility_outputs = [scaling_details_group, *target_groups] - scale_to_target.change( - fn=update_scaling_controls_visibility, - inputs=[scale_to_target, scale_mode], - outputs=visibility_outputs, - queue=False, - ) - scale_mode.change( - fn=update_scaling_controls_visibility, - inputs=[scale_to_target, scale_mode], - outputs=visibility_outputs, - queue=False, - ) - for i, stl_file in enumerate(stl_files): - target_xs[i].change( - fn=lambda stl, enabled, mode, x, y, z: sync_uniform_target_dimensions( - stl, enabled, mode, "X", x, y, z - ), - inputs=[stl_file, scale_to_target, scale_mode, target_xs[i], target_ys[i], target_zs[i]], - outputs=[target_xs[i], target_ys[i], target_zs[i]], - queue=False, - ) - target_ys[i].change( - fn=lambda stl, enabled, mode, x, y, z: sync_uniform_target_dimensions( - stl, enabled, mode, "Y", x, y, z - ), - inputs=[stl_file, scale_to_target, scale_mode, target_xs[i], target_ys[i], target_zs[i]], - outputs=[target_xs[i], target_ys[i], target_zs[i]], - queue=False, - ) - target_zs[i].change( - fn=lambda stl, enabled, mode, x, y, z: sync_uniform_target_dimensions( - stl, enabled, mode, "Z", x, y, z - ), - inputs=[stl_file, scale_to_target, scale_mode, target_xs[i], target_ys[i], target_zs[i]], - outputs=[target_xs[i], target_ys[i], target_zs[i]], - queue=False, - ) - scale_to_target.change( - fn=lambda stl, enabled, mode, x, y, z: sync_uniform_target_dimensions( - stl, enabled, mode, "X", x, y, z - ), - inputs=[stl_file, scale_to_target, scale_mode, target_xs[i], target_ys[i], target_zs[i]], - outputs=[target_xs[i], target_ys[i], target_zs[i]], - queue=False, - ) - scale_mode.change( - fn=lambda stl, enabled, mode, x, y, z: sync_uniform_target_dimensions( - stl, enabled, mode, "X", x, y, z - ), - inputs=[stl_file, scale_to_target, scale_mode, target_xs[i], target_ys[i], target_zs[i]], - outputs=[target_xs[i], target_ys[i], target_zs[i]], - queue=False, - ) - stl_file.change( - fn=lambda stl, enabled, mode, x, y, z: sync_uniform_target_dimensions( - stl, enabled, mode, "X", x, y, z - ), - inputs=[stl_file, scale_to_target, scale_mode, target_xs[i], target_ys[i], target_zs[i]], - outputs=[target_xs[i], target_ys[i], target_zs[i]], - queue=False, - ) - sample_load_event.then( - fn=lambda stl, enabled, mode, x, y, z: sync_uniform_target_dimensions( - stl, enabled, mode, "X", x, y, z - ), - inputs=[stl_file, scale_to_target, scale_mode, target_xs[i], target_ys[i], target_zs[i]], - outputs=[target_xs[i], target_ys[i], target_zs[i]], - queue=False, - ) - regenerate_models_button.click( - fn=refresh_all_model_viewers, - inputs=refresh_inputs, - outputs=refresh_outputs, - ) +def _shape_choice(record: dict) -> str: + return f"{record['idx']}: {record['name']}" - 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], - ) +def _selected_record_index(records: list[dict], selected: str | None) -> int: + if not records: + return -1 + try: + idx = int(str(selected or "").split(":", 1)[0]) + except ValueError: + idx = records[0].get("idx", 1) + for pos, record in enumerate(records): + if record.get("idx") == idx: + return pos + return 0 + + +def _records_from_files(files: Any, previous_records: list[dict] | None = None) -> list[dict]: + previous_by_path = {record.get("stl_path"): record for record in (previous_records or [])} + records: list[dict] = [] + for index, path in enumerate(_uploaded_file_paths(files), start=1): + previous = previous_by_path.get(path, {}) + name = previous.get("name") or Path(path).stem or f"Shape {index}" + default_x, default_y, default_z = _default_target_extents_for_stl(path) + records.append({ + "idx": index, + "name": name, + "stl_path": path, + "original_x": previous.get("original_x", default_x), + "original_y": previous.get("original_y", default_y), + "original_z": previous.get("original_z", default_z), + "target_x": previous.get("target_x", default_x), + "target_y": previous.get("target_y", default_y), + "target_z": previous.get("target_z", default_z), + "last_scaled_axis": previous.get("last_scaled_axis", "target_x"), + "pressure": previous.get("pressure", 25.0), + "valve": previous.get("valve", 4), + "port": previous.get("port", 1), + "color": previous.get("color", _default_color(index)), + "tiff_state": previous.get("tiff_state", _empty_state()), + "zip_path": previous.get("zip_path"), + "gcode_path": previous.get("gcode_path"), + }) + return records - 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, - ) +def _reindex_shape_records(records: list[dict]) -> list[dict]: + reindexed: list[dict] = [] + for index, record in enumerate(records, start=1): + copy = dict(record) + copy["idx"] = index + reindexed.append(copy) + return reindexed - 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") +def _shape_settings_rows(records: list[dict]) -> list[list[Any]]: + return [ + [ + record["idx"], + record["name"], + record.get("target_x", DEFAULT_TARGET_EXTENTS[0]), + record.get("target_y", DEFAULT_TARGET_EXTENTS[1]), + record.get("target_z", DEFAULT_TARGET_EXTENTS[2]), + record.get("pressure", 25.0), + record.get("valve", 4), + record.get("port", 1), + record.get("color", _default_color(record["idx"])), + "Delete", + ] + for record in records + ] - 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", - ) +def _apply_shape_settings(records: list[dict], settings_table: Any) -> list[dict]: + rows = _normalise_rows(settings_table) + by_idx: dict[int, list[Any]] = {} + for row in rows: + if not row: + continue + try: + by_idx[int(float(row[0]))] = row + except (TypeError, ValueError): + continue + updated: list[dict] = [] + for record in records or []: + copy = dict(record) + row = by_idx.get(int(copy.get("idx", 0))) + if row: + copy["name"] = str(row[1] or copy["name"]) + if "original_x" not in copy or "original_y" not in copy or "original_z" not in copy: + original_x, original_y, original_z = _default_target_extents_for_stl(str(copy.get("stl_path", ""))) + copy.setdefault("original_x", original_x) + copy.setdefault("original_y", original_y) + copy.setdefault("original_z", original_z) + for key, pos, default in ( + ("target_x", 2, DEFAULT_TARGET_EXTENTS[0]), + ("target_y", 3, DEFAULT_TARGET_EXTENTS[1]), + ("target_z", 4, DEFAULT_TARGET_EXTENTS[2]), + ("pressure", 5, 25.0), + ("valve", 6, 4), + ("port", 7, 1), + ): + try: + copy[key] = float(row[pos]) + except (IndexError, TypeError, ValueError): + copy[key] = copy.get(key, default) + try: + copy["valve"] = int(float(copy["valve"])) + copy["port"] = int(float(copy["port"])) + except (TypeError, ValueError): + copy["valve"] = 4 + copy["port"] = 1 + if len(row) > 8 and row[8]: + copy["color"] = str(row[8]) + updated.append(copy) + return updated + + +def _last_edited_target_axes(records: list[dict] | None, settings_table: Any) -> dict[int, str]: + rows = _normalise_rows(settings_table) + previous_by_idx: dict[int, dict] = {} + for record in records or []: + try: + previous_by_idx[int(record.get("idx", 0))] = record + except (TypeError, ValueError): + continue - gcode_status = gr.Markdown("") + edited_axes: dict[int, str] = {} + for row in rows: + try: + idx = int(float(row[0])) + except (IndexError, TypeError, ValueError): + continue + previous = previous_by_idx.get(idx) + if not previous: + continue + changed_axes: list[str] = [] + for key, pos in zip(TARGET_DIMENSION_KEYS, (2, 3, 4)): + try: + new_value = float(row[pos]) + old_value = float(previous.get(key)) + except (IndexError, TypeError, ValueError): + continue + if not math.isclose(new_value, old_value, rel_tol=1e-9, abs_tol=1e-9): + changed_axes.append(key) + if changed_axes: + edited_axes[idx] = changed_axes[-1] + return edited_axes + + +def _shape_spacing_label(record: dict) -> str: + return f"Shape {record.get('idx', '?')}: {record.get('name') or 'Unnamed'}" + + +def _spacing_pairs_from_table(spacing_table: Any) -> list[tuple[float, float]]: + pairs: list[tuple[float, float]] = [] + for row in _normalise_rows(spacing_table): + if not row: + continue + try: + if len(row) >= 4: + pairs.append((float(row[2]), float(row[3]))) + elif len(row) >= 3: + pairs.append((float(row[1]), float(row[2]))) + except (TypeError, ValueError): + continue + return pairs - with gr.Group(): - gr.Markdown("### Nozzle Spacing") - nozzle_use_individual_spacing = gr.Checkbox( - label="Use Individual Spacing", - value=False, - ) - with gr.Row(): - nozzle_part_gap_12_x = gr.Number( - label="Shared / Shape 1 -> 2 X edge spacing (mm)", - value=5.0, - step=0.5, - ) - nozzle_part_gap_12_y = gr.Number( - label="Shared / Nozzle 1 -> 2 Y spacing (mm)", - value=0.0, - step=0.5, - ) - with gr.Row(visible=False) as individual_nozzle_spacing_row: - nozzle_part_gap_23_x = gr.Number( - label="Shape 2 -> 3 X edge spacing (mm)", - value=5.0, - step=0.5, - ) - nozzle_part_gap_23_y = gr.Number( - label="Nozzle 2 -> 3 Y spacing (mm)", - value=0.0, - step=0.5, - ) - nozzle_preview_button = gr.Button( - "Visualize Nozzle Spacing", - variant="secondary", - elem_id="visualize-nozzle-spacing-button", - ) - with gr.Row(): - with gr.Column(scale=3, min_width=420): - nozzle_spacing_plot = gr.Plot(label="Nozzle Spacing") - with gr.Column(scale=1, min_width=260): - nozzle_spacing_status = gr.Markdown( - "Generate G-code, then visualize nozzle spacing." - ) - 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 []; - }""", - ) - nozzle_spacing_inputs = [ - gcode_file_1, - gcode_file_2, - gcode_file_3, - nozzle_use_individual_spacing, - nozzle_part_gap_12_x, - nozzle_part_gap_12_y, - nozzle_part_gap_23_x, - nozzle_part_gap_23_y, - ] - nozzle_use_individual_spacing.change( - fn=update_individual_nozzle_spacing_visibility, - inputs=[nozzle_use_individual_spacing], - outputs=[individual_nozzle_spacing_row], - queue=False, - ) - nozzle_preview_button.click( - fn=render_nozzle_spacing_preview, - inputs=nozzle_spacing_inputs, - outputs=[nozzle_spacing_plot, nozzle_spacing_status], - ) +def _spacing_table_update(records: list[dict], existing_table: Any | None = None, use_individual_spacing: bool | None = False) -> dict[str, Any]: + pairs = _spacing_pairs_from_table(existing_table) + first_pair = pairs[0] if pairs else (5.0, 0.0) - 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, - ) + if not use_individual_spacing: + return gr.update( + headers=SIMPLE_NOZZLE_SPACING_HEADERS, + value=[["Same spacing", "All neighboring shapes", first_pair[0], first_pair[1]]], + row_count=(1, "fixed"), + column_count=(len(SIMPLE_NOZZLE_SPACING_HEADERS), "fixed"), + label="Nozzle Spacing", + ) - 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("") + rows: list[list[Any]] = [] + for index, (first, second) in enumerate(zip(records, records[1:])): + gap_x, gap_y = pairs[index] if index < len(pairs) else first_pair + rows.append([ + _shape_spacing_label(first), + _shape_spacing_label(second), + gap_x, + gap_y, + ]) + return gr.update( + headers=ADVANCED_NOZZLE_SPACING_HEADERS, + value=rows, + row_count=(len(rows), "fixed"), + column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"), + label="Advanced Nozzle Spacing", + ) - # --- 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") +def _dropdown_update(records: list[dict], selected: str | None = None) -> dict[str, Any]: + choices = [_shape_choice(record) for record in records] + value = selected if selected in choices else (choices[0] if choices else None) + return gr.update(choices=choices, value=value) - 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 []; - }""" - ) +def _gcode_dropdown_update(records: list[dict], selected: str | None = None, include_upload: bool = False) -> dict[str, Any]: + choices = [_shape_choice(record) for record in records if record.get("gcode_path")] + if include_upload: + choices.append(GCODE_SOURCE_UPLOAD) + value = selected if selected in choices else (choices[0] if choices else None) + return gr.update(choices=choices, value=value) - with gr.Tab("Parallel Printing Visualization"): + +def _merge_file_paths(*file_groups: Any) -> list[str]: + merged: list[str] = [] + seen: set[str] = set() + for group in file_groups: + for path in _uploaded_file_paths(group): + key = str(Path(path)) + if key in seen: + continue + seen.add(key) + merged.append(path) + return merged + + +def sync_uploaded_shapes( + files: Any, + records: list[dict] | None, + settings_table: Any | None = None, + existing_spacing: Any | None = None, + use_individual_spacing: bool | None = False, +) -> tuple: + records = _apply_shape_settings(records or [], settings_table) + next_records = _records_from_files(files, records) + settings = _shape_settings_rows(next_records) + spacing = _spacing_table_update(next_records, existing_spacing, use_individual_spacing) + return ( + next_records, + settings, + spacing, + _dropdown_update(next_records), + _gcode_dropdown_update(next_records), + _gcode_dropdown_update(next_records, include_upload=True), + [record.get("zip_path") for record in next_records if record.get("zip_path")], + [record.get("gcode_path") for record in next_records if record.get("gcode_path")], + ) + + +def load_sample_shapes( + files: Any, + records: list[dict] | None, + settings_table: Any | None = None, + existing_spacing: Any | None = None, + use_individual_spacing: bool | None = False, +) -> tuple: + records = _apply_shape_settings(records or [], settings_table) + paths = [str(SAMPLE_STL_DIR / filename) for filename in SAMPLE_STL_FILENAMES if (SAMPLE_STL_DIR / filename).exists()] + merged_paths = _merge_file_paths(files, paths) + return ( + gr.update(value=merged_paths), + *sync_uploaded_shapes(merged_paths, records, None, existing_spacing, use_individual_spacing), + ) + + +def update_nozzle_spacing_table_mode( + records: list[dict] | None, + existing_spacing: Any | None, + use_individual_spacing: bool | None, +) -> dict[str, Any]: + return _spacing_table_update(records or [], existing_spacing, use_individual_spacing) + + +def _shape_delete_outputs( + records: list[dict], + existing_spacing: Any | None, + use_individual_spacing: bool | None, + last_delete_at: float | None, + upload_update: Any | None = None, +) -> tuple: + return ( + upload_update if upload_update is not None else gr.update(), + records, + _shape_settings_rows(records), + _spacing_table_update(records, existing_spacing, use_individual_spacing), + _dropdown_update(records), + _gcode_dropdown_update(records), + _gcode_dropdown_update(records, include_upload=True), + [record.get("zip_path") for record in records if record.get("zip_path")], + [record.get("gcode_path") for record in records if record.get("gcode_path")], + float(last_delete_at or 0.0), + ) + + +def delete_shape_from_settings( + records: list[dict] | None, + settings_table: Any | None, + existing_spacing: Any | None, + use_individual_spacing: bool | None, + last_delete_at: float | None, + evt: gr.SelectData, +) -> tuple: + now = time.monotonic() + rows = _normalise_rows(settings_table) + selected = getattr(evt, "index", None) + current_records = _apply_shape_settings(records or [], settings_table) + if not isinstance(selected, (list, tuple)) or len(selected) < 2: + return _shape_delete_outputs(current_records, existing_spacing, use_individual_spacing, last_delete_at) + + try: + row_index, column_index = int(selected[0]), int(selected[1]) + except (TypeError, ValueError): + return _shape_delete_outputs(current_records, existing_spacing, use_individual_spacing, last_delete_at) + delete_column_index = len(SHAPE_SETTINGS_HEADERS) - 1 + if column_index != delete_column_index or row_index < 0 or row_index >= len(rows): + return _shape_delete_outputs(current_records, existing_spacing, use_individual_spacing, last_delete_at) + if last_delete_at and now - float(last_delete_at) < DELETE_SHAPE_COOLDOWN_SECONDS: + return _shape_delete_outputs(current_records, existing_spacing, use_individual_spacing, last_delete_at) + + try: + delete_idx = int(float(rows[row_index][0])) + except (IndexError, TypeError, ValueError): + delete_idx = row_index + 1 + + next_records = _reindex_shape_records([ + record for record in current_records if int(record.get("idx", 0)) != delete_idx + ]) + upload_paths = [record.get("stl_path") for record in next_records if record.get("stl_path")] + return _shape_delete_outputs( + next_records, + existing_spacing, + use_individual_spacing, + now, + gr.update(value=upload_paths), + ) + + +def reset_shape_dimensions(records: list[dict] | None, settings_table: Any | None = None) -> tuple: + records = _apply_shape_settings(records or [], settings_table) + reset_records: list[dict] = [] + for record in records: + copy = dict(record) + original_x = copy.get("original_x") + original_y = copy.get("original_y") + original_z = copy.get("original_z") + if original_x is None or original_y is None or original_z is None: + original_x, original_y, original_z = _default_target_extents_for_stl(str(copy.get("stl_path", ""))) + copy["original_x"] = original_x + copy["original_y"] = original_y + copy["original_z"] = original_z + copy["target_x"] = original_x + copy["target_y"] = original_y + copy["target_z"] = original_z + copy["last_scaled_axis"] = "target_x" + reset_records.append(copy) + return reset_records, _shape_settings_rows(reset_records) + + +def normalize_shape_dimensions_for_mode( + records: list[dict] | None, + settings_table: Any | None, + scale_mode: str | None, +) -> tuple: + edited_axes = _last_edited_target_axes(records, settings_table) + records = _apply_shape_settings(records or [], settings_table) + if _normalize_scale_mode(scale_mode) != SCALE_MODE_UNIFORM_FACTOR: + for record in records: + idx = int(record.get("idx", 0)) + if idx in edited_axes: + record["last_scaled_axis"] = edited_axes[idx] + return records, _shape_settings_rows(records) + + normalized: list[dict] = [] + for record in records: + copy = dict(record) + originals = np.asarray([ + copy.get("original_x"), + copy.get("original_y"), + copy.get("original_z"), + ], dtype=float) + targets = np.asarray([ + copy.get("target_x"), + copy.get("target_y"), + copy.get("target_z"), + ], dtype=float) + if ( + originals.shape != (3,) + or targets.shape != (3,) + or not np.all(np.isfinite(originals)) + or not np.all(np.isfinite(targets)) + or np.any(originals <= 0) + or np.any(targets <= 0) + ): + normalized.append(copy) + continue + idx = int(copy.get("idx", 0)) + anchor_key = edited_axes.get(idx) or copy.get("last_scaled_axis") or "target_x" + try: + anchor_index = TARGET_DIMENSION_KEYS.index(str(anchor_key)) + except ValueError: + anchor_index = 0 + scale = float(targets[anchor_index] / originals[anchor_index]) + copy["last_scaled_axis"] = TARGET_DIMENSION_KEYS[anchor_index] + scaled = originals * scale + copy["target_x"] = round(float(scaled[0]), 6) + copy["target_y"] = round(float(scaled[1]), 6) + copy["target_z"] = round(float(scaled[2]), 6) + normalized.append(copy) + return normalized, _shape_settings_rows(normalized) + + +def show_selected_model( + records: list[dict] | None, + selected: str | None, + settings_table: Any, + opacity: float, + scale_mode: str | None, +) -> tuple: + records = _apply_shape_settings(records or [], settings_table) + pos = _selected_record_index(records, selected) + if pos < 0: + return _viewer_update(None), "No model loaded.", _reset_slider(), "No slice stack loaded yet.", None + record = records[pos] + viewer, details = load_single_model( + record.get("stl_path"), + opacity, + True, + scale_mode, + record.get("target_x"), + record.get("target_y"), + record.get("target_z"), + ) + state = record.get("tiff_state") or _empty_state() + label, preview = _render_selected_slice(state, 0) + slider = gr.update( + minimum=0, + maximum=max(0, len(state.get("tiff_paths", [])) - 1), + value=0, + step=1, + interactive=len(state.get("tiff_paths", [])) > 1, + ) + return viewer, details, slider, label, preview + + +def jump_to_selected_slice(records: list[dict] | None, selected: str | None, index: float) -> tuple[str, Image.Image | None]: + pos = _selected_record_index(records or [], selected) + if pos < 0: + return "No slice stack loaded yet.", None + return _render_selected_slice((records or [])[pos].get("tiff_state") or _empty_state(), int(index)) + + +def shift_selected_slice(records: list[dict] | None, selected: str | None, index: float, delta: int) -> tuple: + pos = _selected_record_index(records or [], selected) + if pos < 0: + return gr.update(value=0), "No slice stack loaded yet.", None + state = (records or [])[pos].get("tiff_state") or _empty_state() + paths = state.get("tiff_paths", []) + if not paths: + return gr.update(value=0), "No slice stack loaded yet.", None + next_index = max(0, min(int(index) + delta, len(paths) - 1)) + label, preview = _render_selected_slice(state, next_index) + return gr.update(value=next_index), label, preview + + +def generate_dynamic_stacks( + records: list[dict] | None, + settings_table: Any, + layer_height: float, + pixel_size: float, + scale_mode: str | None, + progress: gr.Progress = gr.Progress(), +) -> tuple: + records = _apply_shape_settings(records or [], settings_table) + if not records: + return records, [], "Upload at least one STL first.", _dropdown_update(records), _reset_slider(), "No slice stack loaded yet.", None + total = len(records) + messages: list[str] = [] + for pos, record in enumerate(records): + stl_path = record.get("stl_path") + if not stl_path: + messages.append(f"Shape {record['idx']}: skipped (no STL file).") + continue + + def report_progress(cur: int, tot: int, offset: int = pos) -> None: + progress((offset + cur / tot) / total, desc=f"Slicing shape {offset + 1} of {total}...") + + mesh = load_mesh(stl_path) + scale_factors = _resolve_mesh_scale_factors( + mesh, + True, + scale_mode, + record.get("target_x"), + record.get("target_y"), + record.get("target_z"), + ) + try: + stack = slice_stl_to_tiffs( + stl_path, + layer_height=float(layer_height), + pixel_size=float(pixel_size), + progress_callback=report_progress, + scale_factors=scale_factors, + ) + record["tiff_state"] = _stack_to_state(stack) + record["zip_path"] = str(stack.zip_path) + messages.append(f"Shape {record['idx']}: wrote `{stack.zip_path.name}`.") + except Exception as exc: + messages.append(f"Shape {record['idx']}: failed ({exc}).") + first_state = records[0].get("tiff_state") or _empty_state() + label, preview = _render_selected_slice(first_state, 0) + slider = gr.update( + minimum=0, + maximum=max(0, len(first_state.get("tiff_paths", [])) - 1), + value=0, + step=1, + interactive=len(first_state.get("tiff_paths", [])) > 1, + ) + return ( + records, + [record.get("zip_path") for record in records if record.get("zip_path")], + "\n".join(messages), + _dropdown_update(records), + slider, + label, + preview, + ) + + +def generate_dynamic_reference_stack(records: list[dict] | None, progress: gr.Progress = gr.Progress()) -> tuple: + states = [record.get("tiff_state") or _empty_state() for record in (records or [])] + return generate_reference_stack(*states, progress=progress) + + +def generate_dynamic_gcode( + records: list[dict] | None, + settings_table: Any, + all_g1: bool, + use_reference_motion: bool, + ref_state: ViewerState | None, + layer_height: float, + pixel_size: float, +) -> tuple: + records = _apply_shape_settings(records or [], settings_table) + motion_tiffs = (ref_state or {}).get("tiff_paths") if use_reference_motion else None + messages: list[str] = [] + for record in records: + zip_path = record.get("zip_path") + if not zip_path: + messages.append(f"Shape {record['idx']}: skipped (no TIFF ZIP available).") + continue + if use_reference_motion and not motion_tiffs: + messages.append(f"Shape {record['idx']}: skipped (Reference motion selected, but no Reference TIFF Stack exists).") + continue + shape_name = str(record.get("name") or Path(zip_path).stem).replace(" ", "_") + try: + gcode_path = generate_snake_path_gcode( + zip_path=zip_path, + shape_name=shape_name, + pressure=float(record.get("pressure", 25.0)), + valve=int(record.get("valve", 4)), + port=int(record.get("port", 1)), + layer_height=float(layer_height), + fil_width=float(pixel_size), + all_g1=bool(all_g1), + motion_tiffs=motion_tiffs, + ) + record["gcode_path"] = str(gcode_path) + messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.") + except Exception as exc: + messages.append(f"Shape {record['idx']}: failed ({exc}).") + return ( + records, + [record.get("gcode_path") for record in records if record.get("gcode_path")], + "\n".join(messages), + _gcode_dropdown_update(records), + _gcode_dropdown_update(records, include_upload=True), + ) + + +def load_selected_gcode_text(records: list[dict] | None, selected: str | None) -> str: + pos = _selected_record_index(records or [], selected) + if pos < 0: + return "# No G-code generated yet." + path = (records or [])[pos].get("gcode_path") + if not path: + return f"# No G-code generated for Shape {(records or [])[pos].get('idx', 1)} yet." + try: + return Path(path).read_text() + except OSError as exc: + return f"# Failed to read G-code file: {exc}" + + +def _parts_from_records(records: list[dict] | None) -> tuple[list[dict], list[str]]: + parts: list[dict] = [] + messages: list[str] = [] + for record in records or []: + path = record.get("gcode_path") + idx = int(record.get("idx", len(parts) + 1)) + 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": record.get("color", _default_color(idx)), "parsed": parsed}) + messages.append(f"Shape {idx}: {parsed['point_count']} moves, {parsed.get('layer_count', 0)} layer(s).") + return parts, messages + + +def _spacing_args_from_table(spacing_table: Any, use_individual_spacing: bool | None = False) -> tuple[float, float, float, float, list[float]]: + pairs = _spacing_pairs_from_table(spacing_table) + if not pairs: + pairs = [(5.0, 0.0)] + if not use_individual_spacing: + pairs = [pairs[0]] + while len(pairs) < 2: + pairs.append(pairs[0]) + extra = [value for pair in pairs[2:] for value in pair] + return pairs[0][0], pairs[0][1], pairs[1][0], pairs[1][1], extra + + +def render_dynamic_nozzle_spacing(records: list[dict] | None, use_individual_spacing: bool, spacing_table: Any) -> tuple[Any, str]: + parts, _messages = _parts_from_records(records) + if not parts: + return None, "No shape G-code available. Generate G-code first." + gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(spacing_table, use_individual_spacing) + offsets, spacings = _resolve_nozzle_layout( + parts, + _same_spacing_from_individual(use_individual_spacing), + gap12x, + gap12y, + gap23x, + gap23y, + *extra, + ) + return build_nozzle_spacing_figure(parts, offsets, spacings), _format_nozzle_spacing_status(parts, offsets, spacings) + + +def render_dynamic_toolpath( + source: str | None, + uploaded_path: str | None, + records: list[dict] | None, + travel_opacity: float, + print_opacity: float, + travel_color: str, + print_color: str, + print_width: float, + travel_width: float, + tube: bool = True, +) -> tuple[Any, str, dict]: + if source == GCODE_SOURCE_UPLOAD: + path = uploaded_path + label = "uploaded file" + else: + pos = _selected_record_index(records or [], source) + path = (records or [])[pos].get("gcode_path") if pos >= 0 else None + label = source or "selected shape" + if not path: + return None, f"No G-code available for {label}.", {} + try: + parsed = parse_gcode_path(Path(path).read_text()) + except OSError as exc: + return None, f"Failed to read G-code file: {exc}", {} + 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"] + return figure, ( + f"**{parsed['point_count']} moves parsed** - {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}], Y [{y_min:.2f}, {y_max:.2f}], Z [{z_min:.2f}, {z_max:.2f}] mm." + ), parsed + + +def render_dynamic_toolpath_lines(*args: Any) -> tuple[Any, str, dict, str, dict[str, Any], dict[str, Any]]: + figure, status, parsed = render_dynamic_toolpath(*args, tube=False) + return figure, status, parsed, "line", gr.update(visible=False), gr.update(visible=False) + + +def render_dynamic_toolpath_tubes(*args: Any) -> tuple[Any, str, dict, str, dict[str, Any], dict[str, Any]]: + figure, status, parsed = render_dynamic_toolpath(*args, tube=True) + has_animation = bool(parsed.get("point_count")) + return figure, status, parsed, "tube", gr.update(visible=has_animation), gr.update(visible=has_animation) + + +def rerender_dynamic_toolpath_current_mode(mode: str, *args: Any) -> tuple[Any, str, dict]: + return render_dynamic_toolpath(*args, tube=(mode != "line")) + + +def render_dynamic_parallel( + records: list[dict] | None, + settings_table: Any, + travel_opacity: float, + filament_width: float, + travel_width: float, + use_individual_spacing: bool, + spacing_table: Any, + tube: bool = True, +) -> tuple[Any, str]: + records = _apply_shape_settings(records or [], settings_table) + parts, messages = _parts_from_records(records) + if not parts: + return None, "No shape G-code available. Generate G-code on the TIFF Slices to GCode tab first." + gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(spacing_table, use_individual_spacing) + offsets, spacings = _resolve_nozzle_layout( + parts, + _same_spacing_from_individual(use_individual_spacing), + gap12x, + gap12y, + gap23x, + gap23y, + *extra, + ) + figure = build_parallel_figure( + parts, + part_offsets=offsets, + filament_width=float(filament_width), + travel_width=float(travel_width), + travel_opacity=float(travel_opacity), + tube=tube, + ) + messages.append(_format_nozzle_spacing_status(parts, offsets, spacings)) + return figure, " \n".join(messages) + + +def render_dynamic_parallel_lines(*args: Any) -> tuple[Any, str, str, dict[str, Any], dict[str, Any], dict[str, Any]]: + figure, status = render_dynamic_parallel(*args, tube=False) + 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_dynamic_parallel_tubes(*args: Any) -> tuple[Any, str, str, dict[str, Any], dict[str, Any], dict[str, Any]]: + figure, status = render_dynamic_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_dynamic_parallel_current_mode(mode: str, *args: Any) -> tuple[Any, str]: + return render_dynamic_parallel(*args, tube=(mode != "line")) + + +def export_dynamic_parallel_gif( + records: list[dict] | None, + settings_table: Any, + travel_opacity: float, + use_individual_spacing: bool, + spacing_table: Any, + duration: float, + fps: float, + elev: float, + azim: float, + progress: gr.Progress = gr.Progress(), +) -> str | None: + records = _apply_shape_settings(records or [], settings_table) + parts, _messages = _parts_from_records(records) + if not parts: + return None + gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(spacing_table, use_individual_spacing) + offsets, _spacings = _resolve_nozzle_layout( + parts, + _same_spacing_from_individual(use_individual_spacing), + gap12x, + gap12y, + gap23x, + gap23y, + *extra, + ) + + def report(frame: int, total: int) -> None: + progress(frame / max(total, 1), desc=f"Rendering GIF frame {frame}/{total}") + + out_path = Path(tempfile.mkdtemp(prefix="parallel_gif_")) / "parallel_print.gif" + result = build_parallel_gif( + parts, + out_path=out_path, + part_offsets=offsets, + travel_opacity=float(travel_opacity), + duration=float(duration), + fps=int(fps), + elev=float(elev), + azim=float(azim), + progress_cb=report, + ) + return str(result) if result else None + +def build_dynamic_demo() -> gr.Blocks: + with gr.Blocks(title="STL TIFF Slicer", css=APP_CSS, head=APP_HEAD + TOOLPATH_ANIM_HEAD + PARALLEL_ANIM_HEAD) as demo: + shape_records = gr.State([]) + last_shape_delete_at = gr.State(0.0) + ref_state = gr.State(_empty_state()) + + with gr.Tab("STL to TIFF Slicer"): 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." + """ + # STL to TIFF Slicer + Upload any number of STL files, edit per-shape dimensions and print settings in the table, then generate TIFF stacks. + """ ) 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" + stl_upload = gr.File( + label="STL Files", + file_types=[".stl"], + type="filepath", + file_count="multiple", + allow_reordering=True, + ) + with gr.Column(scale=0, min_width=200): + load_samples_button = gr.Button("Load Sample STLs", variant="secondary", size="sm", elem_id="load-sample-stls-button") + sync_uploads_button = gr.Button("Sync Uploaded STLs", variant="secondary", size="sm") + reset_dimensions_button = gr.Button("Reset Dimensions", variant="secondary", size="sm") + model_opacity = gr.Checkbox(label="Use 75% 3D Model Opacity", value=False) + scale_mode = gr.Radio( + choices=[SCALE_MODE_TARGET_DIMENSIONS, SCALE_MODE_UNIFORM_FACTOR], + value=SCALE_MODE_TARGET_DIMENSIONS, + label="Scaling Mode", ) - parallel_render_button = gr.Button( - "Render Parallel Print - Tube Plot with Animation", variant="primary" + + shape_settings = gr.Dataframe( + headers=SHAPE_SETTINGS_HEADERS, + value=[], + row_count=(0, "dynamic"), + column_count=(len(SHAPE_SETTINGS_HEADERS), "fixed"), + interactive=True, + label="Shape Settings", + elem_id="shape-settings-table", + ) + with gr.Row(): + layer_height = gr.Number(label="Layer Height (mm)", value=0.8, minimum=0.0001, step=0.01) + pixel_size = gr.Number(label="Pixel Size/Fill Width (mm)", value=0.8, minimum=0.0001, step=0.01) + generate_button = gr.Button("Generate TIFF Stacks", variant="primary") + + with gr.Row(): + selected_shape = gr.Dropdown(label="Preview Shape", choices=[], value=None, allow_custom_value=False) + refresh_preview_button = gr.Button("Regenerate Preview", variant="secondary", size="sm") + + with gr.Row(): + with gr.Column(scale=2, min_width=420): + model_viewer = gr.Model3D( + label="Selected 3D Viewer", + display_mode="solid", + clear_color=(0.94, 0.95, 0.97, 1.0), + camera_position=FRONT_CAMERA, + height=360, ) + with gr.Column(scale=1, min_width=300): + model_details = gr.Markdown("No model loaded.") + + with gr.Row(): + with gr.Column(scale=2, min_width=420): + slice_preview = gr.Image(label="Selected Slice Preview", type="pil", image_mode="RGB", height=320) + with gr.Column(scale=1, min_width=300): + slice_label = gr.Markdown("No slice stack loaded yet.") + with gr.Row(): + prev_button = gr.Button("Prev", scale=1, min_width=90, size="sm") + next_button = gr.Button("Next", scale=1, min_width=90, size="sm") + slice_slider = gr.Slider(label="Slice Index", minimum=0, maximum=0, value=0, step=1, interactive=False) + tiff_downloads = gr.File(label="Download TIFF ZIPs", file_count="multiple", interactive=False) + slicer_status = gr.Markdown("") + + 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("Prev", scale=1, min_width=90, size="sm") + ref_next_button = gr.Button("Next", 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) + + with gr.Tab("TIFF Slices to GCode"): + gr.Markdown( + """ + # TIFF Slices to GCode + Generate G-code for every shape with a TIFF stack. Pressure, valve, port, and color come from the Shape Settings table. + """ + ) + 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)", value=True) + gcode_button = gr.Button("Generate G-Code", variant="primary") + gcode_downloads = gr.File(label="Download G-Code Files", file_count="multiple", interactive=False, elem_classes=["gcode-download"]) + gcode_status = gr.Markdown("") + with gr.Row(): + gcode_text_source = gr.Dropdown(label="Preview G-Code", choices=[], value=None, allow_custom_value=False) + refresh_gcode_text_button = gr.Button("Refresh G-Code Preview", variant="secondary", size="sm") + gcode_text = gr.Code(label="Selected G-Code", language=None, lines=18, max_lines=18, interactive=False, elem_classes=["gcode-view"]) + + with gr.Group(): + gr.Markdown("### Nozzle Spacing") + nozzle_use_individual_spacing = gr.Checkbox(label="Advanced Settings", value=False) + nozzle_spacing_table = gr.Dataframe( + headers=NOZZLE_SPACING_HEADERS, + value=[["Same spacing", "All neighboring shapes", 5.0, 0.0]], + row_count=(1, "fixed"), + column_count=(len(NOZZLE_SPACING_HEADERS), "fixed"), + interactive=True, + label="Nozzle Spacing", + elem_id="nozzle-spacing-table", + ) + nozzle_preview_button = gr.Button("Visualize Nozzle Spacing", variant="secondary", elem_id="visualize-nozzle-spacing-button") + with gr.Row(): + with gr.Column(scale=3, min_width=420): + nozzle_spacing_plot = gr.Plot(label="Nozzle Spacing") + with gr.Column(scale=1, min_width=260): + nozzle_spacing_status = gr.Markdown("Generate G-code, then visualize nozzle spacing.") + + with gr.Tab("G-Code Visualization"): + gr.Markdown("### 3D Tool-Path Viewer") + with gr.Row(): + gcode_source = gr.Radio(choices=[GCODE_SOURCE_UPLOAD], value=GCODE_SOURCE_UPLOAD, label="G-Code source") + with gr.Column(elem_id="gcode-upload-col"): + gcode_upload = gr.File(label="Upload G-Code", file_types=[".txt", ".gcode", ".nc"], interactive=True, height=110) + + with gr.Row(): + 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( - "⚠️ Building three tube plots can take a while " - "for high-resolution models.", - elem_id="parallel-render-warning", + "⚠️ For high-resolution models (small layer heights), the tube plot can take a while to build and render.", + elem_id="tube-render-warning", ) - parallel_anim_controls = gr.HTML(PARALLEL_CONTROLS_HTML, visible=False) + anim_controls = gr.HTML(TOOLPATH_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, + 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, ) - pp_color_3 = gr.Dropdown( - label="Shape 3 color", choices=PARALLEL_COLOR_CHOICES, - value="#2ca02c", allow_custom_value=False, min_width=120, + 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, ) - 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 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("") + 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") + + with gr.Tab("Parallel Printing Visualization"): + gr.Markdown( + "### Parallel Printing Visualization\n" + "Plots all generated shapes using the nozzle spacing configured on the TIFF Slices to GCode tab." + ) + with gr.Row(): + 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 multiple 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) + 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_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) 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." - ) + gr.Markdown("**Export animation (GIF)** - a server-side line animation of the parallel print.") 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, - ) + 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_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, - nozzle_use_individual_spacing, - nozzle_part_gap_12_x, nozzle_part_gap_12_y, - nozzle_part_gap_23_x, nozzle_part_gap_23_y, - ] - 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 changes rebuild the figure in the last-used mode after the slider is released. - for _slider in ( - pp_filament_width, - pp_travel_width, - ): - _slider.release( - fn=rerender_parallel_current_mode, - inputs=[parallel_mode] + parallel_render_inputs, - outputs=[parallel_plot, parallel_status], - ) - # Number inputs do not support release() in this Gradio build. - nozzle_use_individual_spacing.change( - fn=rerender_parallel_current_mode, - inputs=[parallel_mode] + parallel_render_inputs, - outputs=[parallel_plot, parallel_status], - ) - for _number in ( - nozzle_part_gap_12_x, - nozzle_part_gap_12_y, - nozzle_part_gap_23_x, - nozzle_part_gap_23_y, - ): - _number.change( - 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, - nozzle_use_individual_spacing, - nozzle_part_gap_12_x, nozzle_part_gap_12_y, - nozzle_part_gap_23_x, nozzle_part_gap_23_y, - pp_gif_duration, pp_gif_fps, - pp_elev, pp_azim, - ], - outputs=[pp_gif_file], + + shape_sync_outputs = [shape_records, shape_settings, nozzle_spacing_table, selected_shape, gcode_text_source, gcode_source, tiff_downloads, gcode_downloads] + stl_upload.change(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing], outputs=shape_sync_outputs) + sync_uploads_button.click(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing], outputs=shape_sync_outputs) + load_samples_button.click(fn=load_sample_shapes, inputs=[stl_upload, shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing], outputs=[stl_upload, *shape_sync_outputs]) + shape_settings.select( + fn=delete_shape_from_settings, + inputs=[shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing, last_shape_delete_at], + outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at], + ) + + preview_inputs = [shape_records, selected_shape, shape_settings, model_opacity, scale_mode] + shape_settings.change( + fn=normalize_shape_dimensions_for_mode, + inputs=[shape_records, shape_settings, scale_mode], + outputs=[shape_records, shape_settings], + queue=False, + ) + selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details, slice_slider, slice_label, slice_preview]) + refresh_preview_button.click(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details, slice_slider, slice_label, slice_preview]) + model_opacity.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details, slice_slider, slice_label, slice_preview]) + scale_mode.change( + fn=normalize_shape_dimensions_for_mode, + inputs=[shape_records, shape_settings, scale_mode], + outputs=[shape_records, shape_settings], + queue=False, + ).then( + fn=show_selected_model, + inputs=preview_inputs, + outputs=[model_viewer, model_details, slice_slider, slice_label, slice_preview], + ) + reset_dimensions_button.click( + fn=reset_shape_dimensions, + inputs=[shape_records, shape_settings], + outputs=[shape_records, shape_settings], + ).then( + fn=show_selected_model, + inputs=preview_inputs, + outputs=[model_viewer, model_details, slice_slider, slice_label, slice_preview], + ) + + slice_slider.release(fn=jump_to_selected_slice, inputs=[shape_records, selected_shape, slice_slider], outputs=[slice_label, slice_preview], queue=False) + prev_button.click(fn=lambda records, selected, idx: shift_selected_slice(records, selected, idx, -1), inputs=[shape_records, selected_shape, slice_slider], outputs=[slice_slider, slice_label, slice_preview], queue=False) + next_button.click(fn=lambda records, selected, idx: shift_selected_slice(records, selected, idx, 1), inputs=[shape_records, selected_shape, slice_slider], outputs=[slice_slider, slice_label, slice_preview], queue=False) + + generate_button.click( + fn=generate_dynamic_stacks, + inputs=[shape_records, shape_settings, layer_height, pixel_size, scale_mode], + outputs=[shape_records, tiff_downloads, slicer_status, selected_shape, slice_slider, slice_label, slice_preview], + ) + ref_generate_button.click(fn=generate_dynamic_reference_stack, inputs=[shape_records], outputs=[ref_state, ref_slice_slider, ref_slice_label, ref_slice_preview]) + 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) + + gcode_button.click( + fn=generate_dynamic_gcode, + inputs=[shape_records, shape_settings, gcode_all_g1, gcode_use_ref_motion, ref_state, layer_height, pixel_size], + outputs=[shape_records, gcode_downloads, gcode_status, gcode_text_source, gcode_source], + ) + gcode_text_source.change(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text]) + refresh_gcode_text_button.click(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text]) + nozzle_use_individual_spacing.change(fn=update_nozzle_spacing_table_mode, inputs=[shape_records, nozzle_spacing_table, nozzle_use_individual_spacing], outputs=[nozzle_spacing_table], queue=False) + nozzle_preview_button.click(fn=render_dynamic_nozzle_spacing, inputs=[shape_records, nozzle_use_individual_spacing, nozzle_spacing_table], outputs=[nozzle_spacing_plot, nozzle_spacing_status]) + + 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, + shape_records, + travel_opacity_slider, + print_opacity_slider, + travel_color_picker, + print_color_picker, + print_width_slider, + travel_width_slider, + ] + render_line_button.click(fn=render_dynamic_toolpath_lines, inputs=render_inputs, outputs=[toolpath_plot, toolpath_status, parsed_state, render_mode, anim_controls, width_row]) + render_tube_button.click(fn=render_dynamic_toolpath_tubes, inputs=render_inputs, outputs=[toolpath_plot, toolpath_status, parsed_state, render_mode, anim_controls, width_row]) + travel_width_slider.release(fn=rerender_dynamic_toolpath_current_mode, inputs=[render_mode] + render_inputs, outputs=[toolpath_plot, toolpath_status, parsed_state]) + print_width_slider.release(fn=rerender_dynamic_toolpath_current_mode, inputs=[render_mode] + render_inputs, outputs=[toolpath_plot, toolpath_status, parsed_state]) + + def sync_width_sliders(v: float): + height = float(v or 0.8) + travel = height / 4 + return ( + gr.update(value=height, minimum=min(0.1, height), maximum=height * 1.5), + gr.update(value=travel, minimum=min(0.05, travel), maximum=height * 1.5), ) + layer_height.change(fn=sync_width_sliders, inputs=[layer_height], outputs=[print_width_slider, travel_width_slider], queue=False) + + parallel_render_inputs = [shape_records, shape_settings, pp_travel_opacity, pp_filament_width, pp_travel_width, nozzle_use_individual_spacing, nozzle_spacing_table] + parallel_outputs = [parallel_plot, parallel_status, parallel_mode, parallel_anim_controls, pp_width_row, pp_export_group] + parallel_line_button.click(fn=render_dynamic_parallel_lines, inputs=parallel_render_inputs, outputs=parallel_outputs) + parallel_render_button.click(fn=render_dynamic_parallel_tubes, inputs=parallel_render_inputs, outputs=parallel_outputs) + pp_filament_width.release(fn=rerender_dynamic_parallel_current_mode, inputs=[parallel_mode] + parallel_render_inputs, outputs=[parallel_plot, parallel_status]) + pp_travel_width.release(fn=rerender_dynamic_parallel_current_mode, inputs=[parallel_mode] + parallel_render_inputs, outputs=[parallel_plot, parallel_status]) + pp_export_button.click( + fn=export_dynamic_parallel_gif, + inputs=[shape_records, shape_settings, pp_gif_travel_opacity, nozzle_use_individual_spacing, nozzle_spacing_table, pp_gif_duration, pp_gif_fps, pp_elev, pp_azim], + outputs=[pp_gif_file], + ) + return demo -demo = build_demo() +demo = build_dynamic_demo() if __name__ == "__main__":