from __future__ import annotations
import tempfile
import math
import time
import warnings
import zipfile
from pathlib import Path
from typing import Any
# css/head are intentionally passed to gr.Blocks rather than launch(): we run
# via `gradio app.py` (hot-reload), which ignores the __main__ launch() call,
# so moving them there would drop our CSS and head scripts. Silence the Gradio
# 6.0 deprecation notice about that.
warnings.filterwarnings(
"ignore",
message=r".*parameters have been moved from the Blocks constructor.*",
category=UserWarning,
)
import gradio as gr
import numpy as np
from PIL import Image, ImageDraw, ImageFont
import trimesh
from gcode_viewer import (
build_nozzle_spacing_figure,
build_parallel_figure,
build_parallel_gif,
build_toolpath_figure,
parse_gcode_path,
)
from stl_slicer import (
SliceStack,
load_mesh,
scale_factors_for_target_extents,
scale_mesh,
slice_stl_to_tiffs,
)
from tiff_to_gcode import (
RASTER_PATTERN_CHOICES,
RASTER_PATTERN_SAME_DIRECTION,
generate_snake_path_gcode,
)
ViewerState = dict[str, Any]
SAMPLE_STL_FILENAMES = ("Hollow_Pyramid.stl", "Rounded_Cube_Through_Holes.stl", "halfsphere.stl")
SAMPLE_STL_DIR = Path(__file__).resolve().parent / "sample_stls"
DEFAULT_TARGET_EXTENTS = (20.0, 20.0, 20.0)
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)
NOZZLE_LAYOUT_GRID = "Grid Layout"
NOZZLE_LAYOUT_PAIR_TABLE = "Custom Spacing"
NOZZLE_LAYOUT_PRESETS = [
"Custom",
"One row",
"One column",
"2 x 1",
"1 x 2",
"2 x 2",
"3 x 3",
"2 x 5",
"5 x 2",
]
APP_CSS = """
.gradio-container {
font-size: 90%;
padding-top: 0.5rem !important;
padding-bottom: 0.5rem !important;
}
.gradio-container .gr-row {
gap: 0.5rem !important;
}
.gradio-container .gr-form,
.gradio-container .gr-box,
.gradio-container .block {
padding: 0.4rem !important;
}
.gradio-container .prose {
margin-bottom: 0.4rem !important;
}
.gcode-shape-card {
border: 1px solid var(--border-color-primary);
border-radius: 0.5rem;
padding: 0.5rem !important;
min-height: 220px;
}
.gcode-shape-card .prose {
margin-bottom: 0.25rem !important;
}
.gcode-param-label {
font-size: 0.8rem;
font-weight: 600;
line-height: 1.15;
margin-bottom: 0.2rem !important;
}
.model3D button[aria-label="Undo"] {
color: var(--block-label-text-color) !important;
cursor: pointer !important;
opacity: 1 !important;
}
.settings-accordion summary,
.settings-accordion button[aria-expanded],
.settings-accordion .label-wrap span {
font-size: 1.05rem !important;
font-weight: 700 !important;
}
.settings-accordion summary {
padding-top: 0.55rem !important;
padding-bottom: 0.55rem !important;
}
#load-sample-stls-button,
#load-sample-stls-button button,
#visualize-nozzle-spacing-button,
#visualize-nozzle-spacing-button button {
background: #f97316 !important;
border-color: #ea580c !important;
color: #ffffff !important;
}
#load-sample-stls-button:hover,
#load-sample-stls-button button:hover,
#visualize-nozzle-spacing-button:hover,
#visualize-nozzle-spacing-button button:hover {
background: #ea580c !important;
border-color: #c2410c !important;
}
#load-sample-stls-button:focus-visible,
#load-sample-stls-button button:focus-visible,
#visualize-nozzle-spacing-button:focus-visible,
#visualize-nozzle-spacing-button button:focus-visible {
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(12n) {
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(12n)::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(12n) input,
#shape-settings-table table tbody tr td:last-child textarea,
#shape-settings-table [role="gridcell"]:nth-child(12n) textarea {
display: none !important;
}
#toolpath-anim-controls {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
padding: 0.4rem 0.2rem;
}
#toolpath-anim-controls button,
#toolpath-anim-controls select {
background: var(--button-secondary-background-fill);
color: var(--button-secondary-text-color);
border: 1px solid var(--border-color-primary);
border-radius: 0.4rem;
padding: 0.25rem 0.7rem;
font-size: 0.85rem;
cursor: pointer;
}
#toolpath-anim-controls button:hover,
#toolpath-anim-controls select:hover {
border-color: #f97316;
}
#tp-scrub {
flex: 1 1 140px;
min-width: 120px;
accent-color: #f97316;
}
#tp-readout {
font-size: 0.85rem;
color: var(--body-text-color-subdued);
flex-basis: 100%;
}
#tp-hint {
font-size: 0.78rem;
color: var(--body-text-color-subdued);
padding: 0 0.2rem 0.3rem;
}
#tube-render-warning {
font-size: 0.8rem;
color: var(--body-text-color-subdued);
margin-top: -0.3rem !important;
}
/* Keep the per-shape G-code previews at a fixed height with internal scroll
(gr.Code's max_lines does not constrain the editor in this Gradio build). */
.gcode-view .cm-editor {
max-height: 320px;
}
.gcode-view .cm-scroller {
overflow: auto;
}
/* Shrink the empty-state placeholder of the G-code download boxes so they are
the same compact height before and after Generate G-Code is pressed. */
.gcode-download .empty {
min-height: 0 !important;
height: 35px !important;
padding: 0 !important;
overflow: hidden !important;
}
.gcode-download .empty svg {
width: 22px !important;
height: 22px !important;
}
/* The G-code upload box starts hidden; it is shown client-side only when
"Upload G-Code file" is selected (see gcode_source.change). */
#gcode-upload-col {
display: none;
}
/* Parallel-printing animation controls share the look of the single-plot bar. */
#parallel-anim-controls {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
padding: 0.4rem 0.2rem;
}
#parallel-anim-controls button,
#parallel-anim-controls select {
background: var(--button-secondary-background-fill);
color: var(--button-secondary-text-color);
border: 1px solid var(--border-color-primary);
border-radius: 0.4rem;
padding: 0.25rem 0.7rem;
font-size: 0.85rem;
cursor: pointer;
}
#parallel-anim-controls button:hover,
#parallel-anim-controls select:hover {
border-color: #f97316;
}
#pp-scrub {
flex: 1 1 140px;
min-width: 120px;
accent-color: #f97316;
}
#pp-readout {
font-size: 0.85rem;
color: var(--body-text-color-subdued);
flex-basis: 100%;
}
"""
# Gradio 6.10's gr.Model3D leaves the Undo (reset view) button permanently
# disabled when the value is supplied programmatically — its `has_change_history`
# state only flips on uploads through Model3D's own upload widget. This script
# strips the disabled attribute so clicks reach Svelte's handle_undo, which
# calls reset_camera_position on the underlying canvas.
APP_HEAD = """
"""
# Client-side build animation for the G-Code Visualization tab. The rendered
# Plotly figure carries per-point timestamps (cumulative path length) in
# layout.meta.animation; this script reveals the print/travel traces up to a
# moving time cutoff via Plotly.restyle, entirely in the browser. Event
# listeners are delegated from document so they survive Gradio re-renders.
TOOLPATH_ANIM_HEAD = """
"""
TOOLPATH_CONTROLS_HTML = """
Render a tool path, then press Play.
Tip: drag the plot to rotate and scroll to zoom — easiest while the
animation is paused. The ⏴ / ⏵ buttons step through the path
one move at a time.
"""
# Parallel-printing animation engine: drives multiple parts in one plot off a
# shared cumulative-length time axis. Independent of the single-plot engine so
# the G-Code Visualization tab is unaffected. Targets #parallel_plot / #pp-*.
PARALLEL_ANIM_HEAD = """
"""
PARALLEL_CONTROLS_HTML = """
Render the parallel print, then press Play.
"""
def _read_slice_preview(path: str) -> Image.Image:
with Image.open(path) as image:
preview = image.copy()
# Upscale low-resolution TIFF previews so they fill the viewer area better.
min_display_side = 480
width, height = preview.size
max_dim = max(width, height)
if max_dim > 0 and max_dim < min_display_side:
scale = min_display_side / max_dim
new_size = (
max(1, int(round(width * scale))),
max(1, int(round(height * scale))),
)
preview = preview.resize(new_size, resample=Image.Resampling.NEAREST)
return preview
def _empty_state() -> ViewerState:
return {
"tiff_paths": [],
"z_values": [],
"pixel_size": 0.0,
"x_min": 0.0,
"y_min": 0.0,
"image_width": 0,
"image_height": 0,
}
def _reset_slider() -> dict[str, Any]:
return gr.update(minimum=0, maximum=0, value=0, step=1, interactive=False)
def _stack_to_state(stack: SliceStack) -> ViewerState:
(x_min, y_min, _z_min), (_x_max, _y_max, _z_max) = stack.bounds
return {
"tiff_paths": [str(path) for path in stack.tiff_paths],
"z_values": stack.z_values,
"pixel_size": stack.pixel_size,
"x_min": x_min,
"y_min": y_min,
"image_width": stack.image_size[0],
"image_height": stack.image_size[1],
}
def _format_model_details(
source_name: str,
mesh,
scale_factors: tuple[float, float, float] | None = None,
) -> str:
extents = mesh.extents
watertight_status = "yes" if mesh.is_watertight else "no"
watertight_explanation = (
"closed solid with no holes or open edges"
if mesh.is_watertight
else "mesh has holes or open edges"
)
lines = [
"### Model Details",
f"- Source: `{source_name}`",
f"- Dimensions (mm): `X {extents[0]:.3f}, Y {extents[1]:.3f}, Z {extents[2]:.3f}`",
f"- Footprint (mm): `X {extents[0]:.3f} x Y {extents[1]:.3f}`",
]
if scale_factors and not all(math.isclose(value, 1.0) for value in scale_factors):
lines.append(
f"- Scale Factors: `X {scale_factors[0]:.4g}, Y {scale_factors[1]:.4g}, Z {scale_factors[2]:.4g}`"
)
lines.extend(
[
f"- Faces: `{len(mesh.faces)}`",
f"- Vertices: `{len(mesh.vertices)}`",
f"- Watertight ({watertight_explanation}): `{watertight_status}`",
]
)
return "\n".join(lines)
def _slice_label(state: ViewerState, index: int) -> str:
path = Path(state["tiff_paths"][index]).name
z_value = state["z_values"][index]
total = len(state["tiff_paths"])
return f"Slice {index + 1} / {total} | z = {z_value:.4f} | {path}"
def _annotate_preview(
image: Image.Image,
pixel_size: float,
x_min: float,
y_min: float,
orig_width: int,
orig_height: int,
) -> Image.Image:
"""Draw a blue origin crosshair with axis labels and a scale bar."""
rgb = image.convert("RGB")
draw = ImageDraw.Draw(rgb)
preview_w, preview_h = rgb.size
scale_x = preview_w / orig_width if orig_width else 1.0
scale_y = preview_h / orig_height if orig_height else 1.0
BLUE = (50, 120, 255)
try:
font = ImageFont.load_default(size=14)
except TypeError:
font = ImageFont.load_default()
try:
small_font = ImageFont.load_default(size=12)
except TypeError:
small_font = font
# --- Origin crosshair & axis indicators ---
origin_px = (0.0 - x_min) / pixel_size
origin_py_from_bottom = (0.0 - y_min) / pixel_size
origin_img_y = orig_height - 1 - origin_py_from_bottom
ox = int(round(origin_px * scale_x))
oy = int(round(origin_img_y * scale_y))
arm = 20
margin_edge = 8 # inset from image border for off-screen indicators
on_screen = 0 <= ox < preview_w and 0 <= oy < preview_h
if on_screen:
# +X axis (rightward)
x_start = max(0, ox)
x_end = min(preview_w - 1, ox + arm)
if x_end > x_start:
draw.line([(x_start, oy), (x_end, oy)], fill=BLUE, width=2)
draw.polygon(
[(x_end, oy), (x_end - 5, oy - 4), (x_end - 5, oy + 4)],
fill=BLUE,
)
if x_end + 4 < preview_w:
draw.text((x_end + 4, oy - 7), "X", fill=BLUE, font=small_font)
# +Y axis (upward in world = upward in image)
y_end = max(0, oy - arm)
y_start = min(preview_h - 1, oy)
if y_start > y_end:
draw.line([(ox, y_start), (ox, y_end)], fill=BLUE, width=2)
draw.polygon(
[(ox, y_end), (ox - 4, y_end + 5), (ox + 4, y_end + 5)],
fill=BLUE,
)
if y_end - 16 >= 0:
draw.text((ox + 5, y_end - 16), "Y", fill=BLUE, font=small_font)
# -X stub (leftward from origin)
stub = min(8, max(0, ox))
if stub > 0:
draw.line([(ox - stub, oy), (ox, oy)], fill=BLUE, width=2)
# -Y stub (downward from origin in image)
stub_y = min(8, max(0, preview_h - 1 - oy))
if stub_y > 0:
draw.line([(ox, oy), (ox, oy + stub_y)], fill=BLUE, width=2)
# Origin label
lx = ox + arm + 4 if ox + arm + 40 < preview_w else ox - 45
ly = oy + 6
if 0 <= ly < preview_h:
draw.text((max(0, lx), ly), "(0, 0)", fill=BLUE, font=small_font)
else:
# Origin is off-screen — draw edge indicator(s) pointing toward it.
arrow_len = 14
arrow_half = 5
# Compute direction label text showing approximate origin coordinates
origin_x_mm = x_min
origin_y_mm = y_min
coord_text = f"Origin ({-origin_x_mm:+.1f}, {-origin_y_mm:+.1f})"
if ox < 0:
# Origin is to the LEFT — draw left-pointing arrow on left edge
ay = max(margin_edge + arrow_half, min(preview_h - margin_edge - arrow_half, oy))
draw.polygon(
[(margin_edge, ay), (margin_edge + arrow_len, ay - arrow_half), (margin_edge + arrow_len, ay + arrow_half)],
fill=BLUE,
)
draw.text((margin_edge + arrow_len + 4, ay - 7), coord_text, fill=BLUE, font=small_font)
elif ox >= preview_w:
# Origin is to the RIGHT
ay = max(margin_edge + arrow_half, min(preview_h - margin_edge - arrow_half, oy))
rx = preview_w - margin_edge
draw.polygon(
[(rx, ay), (rx - arrow_len, ay - arrow_half), (rx - arrow_len, ay + arrow_half)],
fill=BLUE,
)
tw = len(coord_text) * 7
draw.text((max(0, rx - arrow_len - tw - 4), ay - 7), coord_text, fill=BLUE, font=small_font)
if oy < 0:
# Origin is ABOVE — draw upward-pointing arrow on top edge
ax = max(margin_edge + arrow_half, min(preview_w - margin_edge - arrow_half, ox))
draw.polygon(
[(ax, margin_edge), (ax - arrow_half, margin_edge + arrow_len), (ax + arrow_half, margin_edge + arrow_len)],
fill=BLUE,
)
elif oy >= preview_h:
# Origin is BELOW — draw downward-pointing arrow on bottom edge
ax = max(margin_edge + arrow_half, min(preview_w - margin_edge - arrow_half, ox))
by = preview_h - margin_edge
draw.polygon(
[(ax, by), (ax - arrow_half, by - arrow_len), (ax + arrow_half, by - arrow_len)],
fill=BLUE,
)
# If we didn't already draw a left/right label, label here
if 0 <= ox < preview_w:
draw.text((ax + arrow_half + 4, by - arrow_len - 2), coord_text, fill=BLUE, font=small_font)
# --- Scale bar (bottom-left) ---
image_width_mm = orig_width * pixel_size
target_bar_mm = image_width_mm * 0.2
nice = [0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500]
bar_mm = min(nice, key=lambda v: abs(v - target_bar_mm))
bar_px = (bar_mm / pixel_size) * scale_x
margin = 12
bar_y = preview_h - margin
bar_x0 = margin
bar_x1 = bar_x0 + bar_px
cap = 5
draw.line([(int(bar_x0), int(bar_y)), (int(bar_x1), int(bar_y))], fill=BLUE, width=3)
draw.line([(int(bar_x0), int(bar_y - cap)), (int(bar_x0), int(bar_y + cap))], fill=BLUE, width=2)
draw.line([(int(bar_x1), int(bar_y - cap)), (int(bar_x1), int(bar_y + cap))], fill=BLUE, width=2)
bar_label = f"{bar_mm:g} mm"
draw.text((int(bar_x0), int(bar_y - 20)), bar_label, fill=BLUE, font=font)
return rgb
def _render_selected_slice(state: ViewerState, index: int) -> tuple[str, Image.Image | None]:
tiff_paths = state.get("tiff_paths", [])
if not tiff_paths:
return "No slice stack loaded yet.", None
bounded_index = max(0, min(int(index), len(tiff_paths) - 1))
selected_path = tiff_paths[bounded_index]
preview = _read_slice_preview(selected_path)
pixel_size = state.get("pixel_size", 0.0)
if pixel_size and pixel_size > 0:
preview = _annotate_preview(
preview,
pixel_size=pixel_size,
x_min=state.get("x_min", 0.0),
y_min=state.get("y_min", 0.0),
orig_width=state.get("image_width", 0) or preview.size[0],
orig_height=state.get("image_height", 0) or preview.size[1],
)
return (
_slice_label(state, bounded_index),
preview,
)
def _opacity_to_alpha(opacity: float) -> int:
bounded = max(0.05, min(float(opacity), 1.0))
return int(round(255 * bounded))
def _resolve_model_opacity(setting: float | bool | None) -> float:
if isinstance(setting, bool):
return 0.75 if setting else 1.0
if setting is None:
return 1.0
return max(0.05, min(float(setting), 1.0))
def _resolve_target_extents(
scale_to_target: bool | None,
target_x: float | None,
target_y: float | None,
target_z: float | None,
) -> tuple[float, float, float] | None:
if not scale_to_target:
return None
values = (target_x, target_y, target_z)
if any(value is None for value in values):
raise ValueError("Target X, Y, and Z dimensions are required when STL scaling is enabled.")
target = tuple(float(value) for value in values)
if any(not math.isfinite(value) or value <= 0 for value in target):
raise ValueError("Target X, Y, and Z dimensions must be greater than zero.")
return (target[0], target[1], target[2])
def _axis_index(axis: str | None) -> int:
normalized = (axis or "X").upper()
if normalized not in UNIFORM_TARGET_AXES:
raise ValueError("Uniform target side must be X, Y, or Z.")
return UNIFORM_TARGET_AXES.index(normalized)
def _resolve_uniform_scale_from_targets(
mesh: trimesh.Trimesh,
scale_to_target: bool | None,
target_x: float | None,
target_y: float | None,
target_z: float | None,
anchor_axis: str | None = "X",
) -> float | None:
if not scale_to_target:
return None
targets = (target_x, target_y, target_z)
anchor_index = _axis_index(anchor_axis)
target_size = targets[anchor_index]
if target_size is None:
raise ValueError("Target side length is required when uniform STL scaling is enabled.")
target_size = float(target_size)
if not math.isfinite(target_size) or target_size <= 0:
raise ValueError("Target side length must be greater than zero.")
current_size = float(mesh.extents[anchor_index])
if current_size <= 0:
axis = UNIFORM_TARGET_AXES[anchor_index]
raise ValueError(f"Cannot scale uniformly from a zero-sized {axis} extent.")
return target_size / current_size
def _normalize_scale_mode(scale_mode: str | None) -> str:
if scale_mode == SCALE_MODE_UNIFORM_FACTOR:
return SCALE_MODE_UNIFORM_FACTOR
return SCALE_MODE_TARGET_DIMENSIONS
def _resolve_mesh_scale_factors(
mesh: trimesh.Trimesh,
scale_to_target: bool | None,
scale_mode: str | None,
target_x: float | None,
target_y: float | None,
target_z: float | None,
) -> tuple[float, float, float] | None:
if not scale_to_target:
return None
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)
def _uniform_target_extents_from_anchor(
mesh: trimesh.Trimesh,
anchor_axis: str | None,
target_x: float | None,
target_y: float | None,
target_z: float | None,
) -> tuple[float, float, float]:
scale = _resolve_uniform_scale_from_targets(
mesh,
True,
target_x,
target_y,
target_z,
anchor_axis=anchor_axis,
)
extents = np.asarray(mesh.extents, dtype=float)
return (
float(extents[0] * scale),
float(extents[1] * scale),
float(extents[2] * scale),
)
def _dimension_update(current: float | None, target: float) -> dict[str, Any]:
rounded = round(float(target), 6)
try:
if current is not None and math.isclose(float(current), rounded, rel_tol=1e-9, abs_tol=1e-6):
return gr.update()
except (TypeError, ValueError):
pass
return gr.update(value=rounded)
def _load_model_mesh(
stl_file: str | Path,
scale_to_target: bool | None = False,
scale_mode: str | None = SCALE_MODE_TARGET_DIMENSIONS,
target_x: float | None = DEFAULT_TARGET_EXTENTS[0],
target_y: float | None = DEFAULT_TARGET_EXTENTS[1],
target_z: float | None = DEFAULT_TARGET_EXTENTS[2],
) -> tuple[trimesh.Trimesh, tuple[float, float, float]]:
mesh = load_mesh(stl_file)
scale_factors = _resolve_mesh_scale_factors(
mesh,
scale_to_target,
scale_mode,
target_x,
target_y,
target_z,
)
if scale_factors is None:
return mesh, (1.0, 1.0, 1.0)
return scale_mesh(mesh, scale_factors), scale_factors
def _viewer_update(model_path: str | None) -> dict[str, Any]:
return gr.update(value=model_path, camera_position=FRONT_CAMERA)
def sync_uniform_target_dimensions(
stl_file: str | None,
scale_to_target: bool | None,
scale_mode: str | None,
changed_axis: str,
target_x: float | None,
target_y: float | None,
target_z: float | None,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
if (
not stl_file
or not scale_to_target
or _normalize_scale_mode(scale_mode) != SCALE_MODE_UNIFORM_FACTOR
):
return gr.update(), gr.update(), gr.update()
try:
mesh = load_mesh(stl_file)
x_value, y_value, z_value = _uniform_target_extents_from_anchor(
mesh,
changed_axis,
target_x,
target_y,
target_z,
)
except Exception:
return gr.update(), gr.update(), gr.update()
return (
_dimension_update(target_x, x_value),
_dimension_update(target_y, y_value),
_dimension_update(target_z, z_value),
)
def _build_annotated_scene(mesh: trimesh.Trimesh, opacity: float = 1.0) -> str:
"""Export a GLB containing the mesh, origin axes, and a Z=0 grid plane."""
scene = trimesh.Scene()
display_transform = trimesh.transformations.rotation_matrix(-np.pi / 2, [1, 0, 0])
# --- Model (muted orange to match the Gradio theme accent) ---
model_copy = mesh.copy()
model_copy.apply_transform(display_transform)
bounded_opacity = _resolve_model_opacity(opacity)
mat = trimesh.visual.material.PBRMaterial(
baseColorFactor=[230, 150, 90, _opacity_to_alpha(bounded_opacity)],
alphaMode="OPAQUE" if bounded_opacity >= 0.999 else "BLEND",
metallicFactor=0.0,
roughnessFactor=0.6,
)
model_copy.visual = trimesh.visual.TextureVisuals(material=mat)
scene.add_geometry(model_copy, geom_name="model")
bounds = mesh.bounds
(x_min, y_min, z_min), (x_max, y_max, z_max) = bounds
extent = max(x_max - x_min, y_max - y_min, z_max - z_min)
# --- Origin axes (coloured cylinders + cones) ---
axis_len = extent * 0.4
axis_radius = extent * 0.008
cone_radius = axis_radius * 3.5
cone_height = axis_len * 0.12
axis_defs = [
("X", [1, 0, 0], [255, 50, 50, 255]),
("Y", [0, 1, 0], [50, 200, 50, 255]),
("Z", [0, 0, 1], [50, 120, 255, 255]),
]
for name, direction, color in axis_defs:
d = np.array(direction, dtype=float)
# Cylinder from origin along axis
cyl = trimesh.creation.cylinder(
radius=axis_radius, height=axis_len, sections=12
)
# Default cylinder is along Z; rotate to desired axis
midpoint = d * axis_len / 2
if name == "X":
cyl.apply_transform(trimesh.transformations.rotation_matrix(
np.pi / 2, [0, 1, 0]
))
elif name == "Y":
cyl.apply_transform(trimesh.transformations.rotation_matrix(
-np.pi / 2, [1, 0, 0]
))
cyl.apply_translation(midpoint)
cyl.apply_transform(display_transform)
cyl.visual = trimesh.visual.ColorVisuals(
mesh=cyl,
face_colors=np.tile(color, (len(cyl.faces), 1)),
)
scene.add_geometry(cyl, geom_name=f"axis_{name}")
# Cone arrowhead at tip
cone = trimesh.creation.cone(
radius=cone_radius, height=cone_height, sections=12
)
if name == "X":
cone.apply_transform(trimesh.transformations.rotation_matrix(
np.pi / 2, [0, 1, 0]
))
elif name == "Y":
cone.apply_transform(trimesh.transformations.rotation_matrix(
-np.pi / 2, [1, 0, 0]
))
cone.apply_translation(d * (axis_len + cone_height / 2))
cone.apply_transform(display_transform)
cone.visual = trimesh.visual.ColorVisuals(
mesh=cone,
face_colors=np.tile(color, (len(cone.faces), 1)),
)
scene.add_geometry(cone, geom_name=f"cone_{name}")
# --- Grid plane at z=0 ---
nice_spacings = [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100]
target_spacing = extent * 0.1
grid_spacing = min(nice_spacings, key=lambda v: abs(v - target_spacing))
# Grid extends to cover model footprint plus some margin
margin = grid_spacing * 2
gx_min = math.floor((x_min - margin) / grid_spacing) * grid_spacing
gx_max = math.ceil((x_max + margin) / grid_spacing) * grid_spacing
gy_min = math.floor((y_min - margin) / grid_spacing) * grid_spacing
gy_max = math.ceil((y_max + margin) / grid_spacing) * grid_spacing
grid_color = [160, 160, 160, 100]
grid_segments: list[list[list[float]]] = []
# Lines parallel to Y
x = gx_min
while x <= gx_max:
grid_segments.append([[x, gy_min, 0], [x, gy_max, 0]])
x += grid_spacing
# Lines parallel to X
y = gy_min
while y <= gy_max:
grid_segments.append([[gx_min, y, 0], [gx_max, y, 0]])
y += grid_spacing
if grid_segments:
grid_path = trimesh.load_path(grid_segments)
grid_path.apply_transform(display_transform)
grid_path.colors = np.tile(grid_color, (len(grid_path.entities), 1))
scene.add_geometry(grid_path, geom_name="grid")
# Export to GLB (camera angle is set via gr.Model3D camera_position)
out_path = Path(tempfile.mkdtemp(prefix="model3d_")) / "scene.glb"
scene.export(str(out_path), file_type="glb")
return str(out_path)
def load_single_model(
stl_file: str | None,
opacity: float = 1.0,
scale_to_target: bool | None = False,
scale_mode: str | None = SCALE_MODE_TARGET_DIMENSIONS,
target_x: float | None = DEFAULT_TARGET_EXTENTS[0],
target_y: float | None = DEFAULT_TARGET_EXTENTS[1],
target_z: float | None = DEFAULT_TARGET_EXTENTS[2],
) -> tuple[str | None, str]:
if not stl_file:
return _viewer_update(None), "No model loaded."
mesh, scale_factors = _load_model_mesh(
stl_file,
scale_to_target=scale_to_target,
scale_mode=scale_mode,
target_x=target_x,
target_y=target_y,
target_z=target_z,
)
glb_path = _build_annotated_scene(mesh, opacity=_resolve_model_opacity(opacity))
return _viewer_update(glb_path), _format_model_details(Path(stl_file).name, mesh, scale_factors)
def jump_to_slice(state: ViewerState, index: float) -> tuple[str, Image.Image | None]:
return _render_selected_slice(state, int(index))
GCODE_SOURCE_UPLOAD = "Upload G-Code file"
PARALLEL_COLOR_CHOICES = [
("Orange", "#ff7f0e"), ("Blue", "#1f77b4"), ("Green", "#2ca02c"),
("Red", "#d62728"), ("Purple", "#9467bd"), ("Pink", "#e377c2"),
("Teal", "#17becf"), ("Black", "#000000"),
]
DEFAULT_PARALLEL_COLORS = ("#ff7f0e", "#1f77b4", "#2ca02c")
def _resolve_nozzle_layout(
parts: list[dict],
same_spacing: bool | None,
part_gap_12_x: float | None,
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] = []
if not parts:
return offsets, spacings
grouped: dict[int, list[dict]] = {}
for part in parts:
grouped.setdefault(_record_nozzle_number(part, int(part.get("idx", 1) or 1)), []).append(part)
ordered_nozzles = sorted(grouped)
offsets[ordered_nozzles[0]] = (0.0, 0.0)
def nozzle_bounds(nozzle: int) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
mins: list[tuple[float, float, float]] = []
maxs: list[tuple[float, float, float]] = []
for part in grouped[nozzle]:
part_min, part_max = part["parsed"]["bounds"]
mins.append(part_min)
maxs.append(part_max)
return (
tuple(min(values) for values in zip(*mins)),
tuple(max(values) for values in zip(*maxs)),
)
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(0, len(ordered_nozzles) - 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 = {
(ordered_nozzles[index], ordered_nozzles[index + 1]): raw_pairs[index]
for index in range(min(len(raw_pairs), max_pair_count))
}
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)]
try:
start_pos = ordered_nozzles.index(prev_idx)
end_pos = ordered_nozzles.index(cur_idx)
except ValueError:
return 0.0, 0.0
if end_pos <= start_pos:
return 0.0, 0.0
total_x = 0.0
total_y = 0.0
for pair_pos in range(start_pos, end_pos):
pair = (ordered_nozzles[pair_pos], ordered_nozzles[pair_pos + 1])
step_x, step_y = pair_spacing.get(pair, first_spacing)
total_x += step_x
total_y += step_y
return total_x, total_y
for prev_idx, cur_idx in zip(ordered_nozzles, ordered_nozzles[1:]):
gap_x, y_step = spacing_between(prev_idx, cur_idx)
prev_offset_x, prev_offset_y = offsets[prev_idx]
(_, _, _), (prev_xmax, _prev_ymax, _) = nozzle_bounds(prev_idx)
(cur_xmin, _cur_ymin, _), (_, _, _) = nozzle_bounds(cur_idx)
dx = (prev_xmax + prev_offset_x + gap_x) - cur_xmin
dy = prev_offset_y + y_step
offsets[cur_idx] = (dx, dy)
spacings.append({
"from": prev_idx,
"to": cur_idx,
"dx": dx - prev_offset_x,
"dy": y_step,
})
return offsets, spacings
def _group_parts_by_nozzle(parts: list[dict]) -> dict[int, list[dict]]:
grouped: dict[int, list[dict]] = {}
for part in parts:
grouped.setdefault(_record_nozzle_number(part, int(part.get("idx", 1) or 1)), []).append(part)
return grouped
def _nozzle_group_bounds(grouped: dict[int, list[dict]], nozzle: int) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
mins: list[tuple[float, float, float]] = []
maxs: list[tuple[float, float, float]] = []
for part in grouped[nozzle]:
part_min, part_max = part["parsed"]["bounds"]
mins.append(part_min)
maxs.append(part_max)
return (
tuple(min(values) for values in zip(*mins)),
tuple(max(values) for values in zip(*maxs)),
)
def _resolve_nozzle_grid_layout(
parts: list[dict],
columns: Any,
rows: Any,
column_spacing: Any,
row_spacing: Any,
) -> tuple[dict[int, tuple[float, float]], list[dict]]:
offsets: dict[int, tuple[float, float]] = {}
spacings: list[dict] = []
if not parts:
return offsets, spacings
grouped = _group_parts_by_nozzle(parts)
ordered_nozzles = sorted(grouped)
column_count = max(1, _coerce_int(columns, 1))
requested_rows = max(1, _coerce_int(rows, 1))
row_count = max(requested_rows, math.ceil(len(ordered_nozzles) / column_count))
try:
x_gap = float(column_spacing)
except (TypeError, ValueError):
x_gap = 0.0
try:
y_gap = float(row_spacing)
except (TypeError, ValueError):
y_gap = 0.0
placements = {
nozzle: (index % column_count, index // column_count)
for index, nozzle in enumerate(ordered_nozzles)
}
column_widths = [0.0 for _ in range(column_count)]
row_heights = [0.0 for _ in range(row_count)]
bounds_by_nozzle: dict[int, tuple[tuple[float, float, float], tuple[float, float, float]]] = {}
for nozzle, (column, row) in placements.items():
bounds = _nozzle_group_bounds(grouped, nozzle)
bounds_by_nozzle[nozzle] = bounds
(xmin, ymin, _), (xmax, ymax, _) = bounds
column_widths[column] = max(column_widths[column], xmax - xmin)
row_heights[row] = max(row_heights[row], ymax - ymin)
column_positions: list[float] = []
x_pos = 0.0
for width in column_widths:
column_positions.append(x_pos)
x_pos += width + x_gap
row_positions: list[float] = []
y_pos = 0.0
for height in row_heights:
row_positions.append(y_pos)
y_pos += height + y_gap
for nozzle, (column, row) in placements.items():
(xmin, ymin, _), _ = bounds_by_nozzle[nozzle]
offsets[nozzle] = (column_positions[column] - xmin, row_positions[row] - ymin)
for first, second in zip(ordered_nozzles, ordered_nozzles[1:]):
first_x, first_y = offsets[first]
second_x, second_y = offsets[second]
spacings.append({
"from": first,
"to": second,
"dx": second_x - first_x,
"dy": second_y - first_y,
})
return offsets, spacings
def _resolve_layout_from_spacing_controls(
parts: list[dict],
layout_mode: str | None,
columns: Any,
rows: Any,
column_spacing: Any,
row_spacing: Any,
use_individual_spacing: bool,
spacing_table: Any,
) -> tuple[dict[int, tuple[float, float]], list[dict]]:
if layout_mode != NOZZLE_LAYOUT_PAIR_TABLE:
return _resolve_nozzle_grid_layout(parts, columns, rows, column_spacing, row_spacing)
gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(spacing_table, use_individual_spacing)
return _resolve_nozzle_layout(
parts,
_same_spacing_from_individual(use_individual_spacing),
gap12x,
gap12y,
gap23x,
gap23y,
*extra,
)
def _same_spacing_from_individual(use_individual_spacing: bool | None) -> bool:
return not 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"]):
(xmin, ymin, zmin), (xmax, ymax, zmax) = part["parsed"]["bounds"]
nozzle = _record_nozzle_number(part, int(part.get("idx", 1) or 1))
lines.append(
f"Shape {part['idx']} (Nozzle {nozzle}): X {xmax - xmin:.2f} mm, "
f"Y {ymax - ymin:.2f} mm, Z {zmax - zmin:.2f} mm."
)
return lines
def _format_nozzle_spacing_status(
parts: list[dict],
offsets: dict[int, tuple[float, float]],
spacings: list[dict],
) -> str:
lines = _format_shape_dimensions(parts)
ordered_nozzles = sorted(offsets)
if ordered_nozzles:
lines.append("**Nozzle coordinates:**")
for idx in ordered_nozzles:
x, y = offsets[idx]
lines.append(f"Nozzle {idx}: X {x:.2f} mm, Y {y:.2f} mm.")
if not spacings:
lines.append("Generate G-code for at least two nozzles to calculate nozzle spacing.")
return " \n".join(lines)
lines.append("**Nozzle-to-nozzle distances:**")
for first_pos, first_idx in enumerate(ordered_nozzles):
for second_idx in ordered_nozzles[first_pos + 1:]:
x0, y0 = offsets[first_idx]
x1, y1 = offsets[second_idx]
dx = x1 - x0
dy = y1 - y0
distance = math.hypot(dx, dy)
angle = math.degrees(math.atan2(dy, dx)) if distance else 0.0
lines.append(
f"Nozzle {first_idx} -> {second_idx}: "
f"Delta X {dx:.2f} mm, Delta Y {dy:.2f} mm; "
f"distance {distance:.2f} mm at {angle:.1f} deg."
)
lines.append("**Adjacent spacing inputs:**")
for spacing in spacings:
lines.append(
f"Nozzle {spacing['from']} -> {spacing['to']}: "
f"X {spacing['dx']:.2f} mm, Y {spacing['dy']:.2f} mm."
)
return " \n".join(lines)
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(
*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")]
if not active_states:
return (
_empty_state(),
_reset_slider(),
"No TIFF stacks available. Generate TIFF stacks first.",
None,
)
max_layers = max(len(s["tiff_paths"]) for s in active_states)
# Determine the largest image dimensions across all stacks.
max_width = 0
max_height = 0
source_sizes: list[tuple[int, int]] = []
for state in active_states:
w = state.get("image_width", 0)
h = state.get("image_height", 0)
if not w or not h:
with Image.open(state["tiff_paths"][0]) as img:
w, h = img.size
source_sizes.append((w, h))
max_width = max(max_width, w)
max_height = max(max_height, h)
# Compute annotation metadata from the first active state, accounting for
# the centering offset applied to its image on the larger canvas.
first = active_states[0]
first_w, first_h = source_sizes[0]
ref_pixel_size = first.get("pixel_size", 0.0)
x_off_first = (max_width - first_w) // 2
y_off_first = (max_height - first_h) // 2
ref_x_min = first.get("x_min", 0.0) - x_off_first * ref_pixel_size
ref_y_min = first.get("y_min", 0.0) - y_off_first * ref_pixel_size
output_dir = Path(tempfile.mkdtemp(prefix="reference_stack_"))
slices_dir = output_dir / "tiff_slices"
slices_dir.mkdir(parents=True, exist_ok=True)
tiff_paths: list[Path] = []
z_values: list[float] = []
for layer_idx in range(max_layers):
progress(
layer_idx / max_layers,
desc=f"Compositing reference layer {layer_idx + 1}/{max_layers}",
)
# Start with an all-white canvas.
ref_array = np.full((max_height, max_width), 255, dtype=np.uint8)
for state in active_states:
paths = state["tiff_paths"]
if layer_idx >= len(paths):
continue # Stack exhausted – contributes white.
with Image.open(paths[layer_idx]) as img:
arr = np.asarray(img)
h, w = arr.shape[:2]
y_off = (max_height - h) // 2
x_off = (max_width - w) // 2
# Black (0) wins: pixel-wise minimum keeps any black pixel.
region = ref_array[y_off : y_off + h, x_off : x_off + w]
ref_array[y_off : y_off + h, x_off : x_off + w] = np.minimum(region, arr)
ref_image = Image.fromarray(ref_array, mode="L")
tiff_path = slices_dir / f"ref_slice_{layer_idx:04d}.tif"
ref_image.save(tiff_path, compression="tiff_deflate")
tiff_paths.append(tiff_path)
# Use z-value from the first active state that covers this layer.
z_val = 0.0
for state in active_states:
if layer_idx < len(state["z_values"]):
z_val = state["z_values"][layer_idx]
break
z_values.append(z_val)
ref_state: ViewerState = {
"tiff_paths": [str(p) for p in tiff_paths],
"z_values": z_values,
"pixel_size": ref_pixel_size,
"x_min": ref_x_min,
"y_min": ref_y_min,
"image_width": max_width,
"image_height": max_height,
}
label, preview = _render_selected_slice(ref_state, 0)
slider = gr.update(
minimum=0,
maximum=max(0, len(tiff_paths) - 1),
value=0,
step=1,
interactive=len(tiff_paths) > 1,
)
return ref_state, slider, label, preview
def _zip_tiff_paths(tiff_paths: list[Path], zip_path: Path) -> None:
with zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
for tiff_path in tiff_paths:
archive.write(tiff_path, arcname=tiff_path.name)
def _partition_length(length: int, count: int) -> list[tuple[int, int]]:
base = length // count
remainder = length % count
spans: list[tuple[int, int]] = []
start = 0
for index in range(count):
size = base + (1 if index < remainder else 0)
end = start + size
spans.append((start, end))
start = end
return spans
def _layer_split_spans(length: int, count: int, layer_index: int, overlap_pixels: int) -> list[tuple[int, int]]:
base_spans = _partition_length(length, count)
if overlap_pixels <= 0 or count <= 1:
return base_spans
boundaries = [base_spans[0][0], *[end for _start, end in base_spans]]
adjusted = list(boundaries)
for boundary_index in range(1, len(boundaries) - 1):
direction = 1 if (layer_index + boundary_index) % 2 == 1 else -1
lower = adjusted[boundary_index - 1] + 1
upper = boundaries[boundary_index + 1] - 1
adjusted[boundary_index] = max(lower, min(upper, boundaries[boundary_index] + direction * overlap_pixels))
return [(adjusted[index], adjusted[index + 1]) for index in range(count)]
def _overlap_canvas_span(start: int, end: int, length: int, position: int, count: int, overlap_pixels: int) -> tuple[int, int]:
if overlap_pixels <= 0:
return start, end
canvas_start = max(0, start - overlap_pixels) if position > 1 else start
canvas_end = min(length, end + overlap_pixels) if position < count else end
return canvas_start, canvas_end
def split_tiff_stack_grid(
state: ViewerState,
base_name: str = "split_shape",
columns: float = 2,
rows: float = 1,
overlapping_layers: bool | None = False,
progress: gr.Progress = gr.Progress(),
) -> list[dict[str, Any]]:
tiff_paths = [Path(path) for path in state.get("tiff_paths", [])]
if not tiff_paths:
raise ValueError("Generate a TIFF stack for the selected shape before splitting it.")
with Image.open(tiff_paths[0]) as first_image:
width, height = first_image.size
column_count = max(1, _coerce_int(columns, 2))
row_count = max(1, _coerce_int(rows, 1))
if column_count > width:
raise ValueError(f"Cannot split {width}-pixel-wide TIFF slices into {column_count} columns.")
if row_count > height:
raise ValueError(f"Cannot split {height}-pixel-tall TIFF slices into {row_count} rows.")
safe_name = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in base_name).strip("_") or "split_shape"
output_dir = Path(tempfile.mkdtemp(prefix=f"{safe_name}_split_"))
x_spans = _partition_length(width, column_count)
y_spans = _partition_length(height, row_count)
overlap_pixels = 1 if overlapping_layers else 0
pieces: list[dict[str, Any]] = []
for row_index, (y_start, y_end) in enumerate(y_spans, start=1):
for col_index, (x_start, x_end) in enumerate(x_spans, start=1):
piece_dir = output_dir / f"r{row_index:02d}_c{col_index:02d}_tiff_slices"
piece_dir.mkdir(parents=True, exist_ok=True)
pieces.append({
"row": row_index,
"col": col_index,
"x_start": x_start,
"x_end": x_end,
"y_start": y_start,
"y_end": y_end,
"tiff_dir": piece_dir,
"tiff_paths": [],
})
for index, source_path in enumerate(tiff_paths):
progress(index / max(len(tiff_paths), 1), desc=f"Splitting layer {index + 1}/{len(tiff_paths)}")
with Image.open(source_path) as image:
layer = image.convert("L")
if layer.size != (width, height):
raise ValueError("All TIFF slices must have the same dimensions to split the stack.")
layer_x_spans = _layer_split_spans(width, column_count, index, overlap_pixels)
layer_y_spans = _layer_split_spans(height, row_count, index, overlap_pixels)
for piece in pieces:
canvas_x_start, canvas_x_end = _overlap_canvas_span(
piece["x_start"], piece["x_end"], width, piece["col"], column_count, overlap_pixels
)
canvas_y_start, canvas_y_end = _overlap_canvas_span(
piece["y_start"], piece["y_end"], height, piece["row"], row_count, overlap_pixels
)
x_start, x_end = layer_x_spans[piece["col"] - 1]
y_start, y_end = layer_y_spans[piece["row"] - 1]
if overlapping_layers:
piece_image = Image.new("L", (canvas_x_end - canvas_x_start, canvas_y_end - canvas_y_start), 255)
piece_image.paste(
layer.crop((x_start, y_start, x_end, y_end)),
(x_start - canvas_x_start, y_start - canvas_y_start),
)
else:
piece_image = layer.crop((x_start, y_start, x_end, y_end))
piece_path = piece["tiff_dir"] / f"slice_{index:04d}.tif"
piece_image.save(piece_path, compression="tiff_deflate")
piece["tiff_paths"].append(piece_path)
pixel_size = float(state.get("pixel_size", 0.0) or 0.0)
z_values = list(state.get("z_values", []))
if len(z_values) < len(tiff_paths):
z_values.extend([0.0] * (len(tiff_paths) - len(z_values)))
base_x_min = float(state.get("x_min", 0.0) or 0.0)
base_y_min = float(state.get("y_min", 0.0) or 0.0)
for piece in pieces:
canvas_x_start, canvas_x_end = _overlap_canvas_span(
piece["x_start"], piece["x_end"], width, piece["col"], column_count, overlap_pixels
)
canvas_y_start, canvas_y_end = _overlap_canvas_span(
piece["y_start"], piece["y_end"], height, piece["row"], row_count, overlap_pixels
)
piece_width = canvas_x_end - canvas_x_start if overlapping_layers else piece["x_end"] - piece["x_start"]
piece_height = canvas_y_end - canvas_y_start if overlapping_layers else piece["y_end"] - piece["y_start"]
zip_path = output_dir / f"{safe_name}_r{piece['row']:02d}_c{piece['col']:02d}_tiff_slices.zip"
_zip_tiff_paths(piece["tiff_paths"], zip_path)
piece_state: ViewerState = {
"tiff_paths": [str(path) for path in piece["tiff_paths"]],
"z_values": z_values[: len(piece["tiff_paths"])],
"pixel_size": pixel_size,
"x_min": base_x_min + ((canvas_x_start if overlapping_layers else piece["x_start"]) * pixel_size),
"y_min": base_y_min + ((height - (canvas_y_end if overlapping_layers else piece["y_end"])) * pixel_size),
"image_width": piece_width,
"image_height": piece_height,
"zip_path": str(zip_path),
"overlapping_layers": bool(overlapping_layers),
}
piece["state"] = piece_state
piece["zip_path"] = zip_path
return pieces
def split_tiff_stack_left_right(
state: ViewerState,
base_name: str = "split_shape",
progress: gr.Progress = gr.Progress(),
) -> tuple[ViewerState, ViewerState, Path, Path]:
pieces = split_tiff_stack_grid(state, base_name=base_name, columns=2, rows=1, progress=progress)
return pieces[0]["state"], pieces[1]["state"], pieces[0]["zip_path"], pieces[1]["zip_path"]
SHAPE_SETTINGS_HEADERS = [
"Shape",
"STL",
"Target X (mm)",
"Target Y (mm)",
"Target Z (mm)",
"Pressure (psi)",
"Valve",
"Nozzle",
"Port",
"Color",
"Contour Tracing",
"Delete",
]
SHAPE_SETTINGS_DATATYPES = [
"number",
"str",
"number",
"number",
"number",
"number",
"number",
"number",
"number",
"str",
"bool",
"str",
]
SIMPLE_NOZZLE_SPACING_HEADERS = [
"Spacing Mode",
"Applies To",
"X edge spacing (mm)",
"Y nozzle spacing (mm)",
]
ADVANCED_NOZZLE_SPACING_HEADERS = [
"From Nozzle",
"To Nozzle",
"X edge spacing (mm)",
"Y nozzle spacing (mm)",
]
NOZZLE_SPACING_HEADERS = SIMPLE_NOZZLE_SPACING_HEADERS
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 [])
def _coerce_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
if isinstance(value, (int, float)):
return bool(value)
text = str(value).strip().lower()
if not text:
return default
if text in {"1", "true", "yes", "y", "on", "checked"}:
return True
if text in {"0", "false", "no", "n", "off", "unchecked"}:
return False
return default
def _coerce_int(value: Any, default: int) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
try:
return int(float(default))
except (TypeError, ValueError):
return 0
def _record_nozzle_number(record: dict, fallback: int | None = None) -> int:
default = fallback if fallback is not None else int(record.get("idx", 1) or 1)
nozzle = _coerce_int(record.get("nozzle", default), default)
return nozzle if nozzle > 0 else default
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)
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]
def _default_color(index: int) -> str:
return DEFAULT_PARALLEL_COLORS[(index - 1) % len(DEFAULT_PARALLEL_COLORS)]
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
def _shape_choice(record: dict) -> str:
return f"{record['idx']}: {record['name']}"
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 _next_unused_nozzle(used_nozzles: set[int]) -> int:
nozzle = 1
while nozzle in used_nozzles:
nozzle += 1
return nozzle
def _records_from_files(files: Any, previous_records: list[dict] | None = None) -> list[dict]:
previous_by_path: dict[str | None, list[dict]] = {}
for record in previous_records or []:
previous_by_path.setdefault(record.get("stl_path"), []).append(record)
used_nozzles: set[int] = set()
records: list[dict] = []
for index, path in enumerate(_uploaded_file_paths(files), start=1):
previous_queue = previous_by_path.get(path) or []
previous = previous_queue.pop(0) if previous_queue else {}
name = previous.get("name") or Path(path).stem or f"Shape {index}"
default_x, default_y, default_z = _default_target_extents_for_stl(path)
nozzle = _record_nozzle_number(previous, index) if previous else _next_unused_nozzle(used_nozzles)
used_nozzles.add(nozzle)
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),
"nozzle": nozzle,
"port": previous.get("port", 1),
"color": previous.get("color", _default_color(index)),
"contour_tracing": previous.get("contour_tracing", False),
"tiff_state": previous.get("tiff_state", _empty_state()),
"zip_path": previous.get("zip_path"),
"gcode_path": previous.get("gcode_path"),
})
return records
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
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_nozzle_number(record, int(record["idx"])),
record.get("port", 1),
record.get("color", _default_color(record["idx"])),
bool(record.get("contour_tracing", False)),
"Delete",
]
for record in records
]
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),
):
try:
copy[key] = float(row[pos])
except (IndexError, TypeError, ValueError):
copy[key] = copy.get(key, default)
has_nozzle_column = len(row) >= len(SHAPE_SETTINGS_HEADERS)
nozzle_pos = 7 if has_nozzle_column else None
port_pos = 8 if has_nozzle_column else 7
color_pos = 9 if has_nozzle_column else 8
contour_pos = 10 if has_nozzle_column else 9
copy["valve"] = _coerce_int(copy.get("valve", 4), 4)
copy["nozzle"] = _coerce_int(
row[nozzle_pos] if nozzle_pos is not None else copy.get("nozzle", copy.get("idx", 1)),
_record_nozzle_number(copy),
)
copy["port"] = _coerce_int(
row[port_pos] if len(row) > port_pos else copy.get("port", 1),
_coerce_int(copy.get("port", 1), 1),
)
if copy["nozzle"] <= 0:
copy["nozzle"] = _record_nozzle_number(copy)
if len(row) > color_pos and row[color_pos]:
copy["color"] = str(row[color_pos])
try:
copy["contour_tracing"] = _coerce_bool(row[contour_pos], bool(copy.get("contour_tracing", False)))
except IndexError:
copy["contour_tracing"] = bool(copy.get("contour_tracing", False))
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
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 _nozzle_spacing_label(nozzle: int, records: list[dict]) -> str:
shapes = [
f"Shape {record.get('idx', '?')}"
for record in records
if _record_nozzle_number(record, int(record.get("idx", 1) or 1)) == nozzle
]
if shapes:
return f"Nozzle {nozzle}: {', '.join(shapes)}"
return f"Nozzle {nozzle}"
def _ordered_nozzle_numbers(records: list[dict]) -> list[int]:
nozzles = {
_record_nozzle_number(record, int(record.get("idx", position) or position))
for position, record in enumerate(records, start=1)
}
return sorted(nozzles)
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
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)
if not use_individual_spacing:
return gr.update(
headers=SIMPLE_NOZZLE_SPACING_HEADERS,
value=[["Same spacing", "All neighboring nozzles", first_pair[0], first_pair[1]]],
row_count=(1, "fixed"),
column_count=(len(SIMPLE_NOZZLE_SPACING_HEADERS), "fixed"),
label="Nozzle Spacing",
)
rows: list[list[Any]] = []
ordered_nozzles = _ordered_nozzle_numbers(records)
for index, (first, second) in enumerate(zip(ordered_nozzles, ordered_nozzles[1:])):
gap_x, gap_y = pairs[index] if index < len(pairs) else first_pair
rows.append([
_nozzle_spacing_label(first, records),
_nozzle_spacing_label(second, records),
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",
)
def _grid_spacing_rows(
records: list[dict],
columns: Any,
rows: Any,
column_spacing: Any,
row_spacing: Any,
) -> tuple[list[list[Any]], int, int]:
ordered_nozzles = _ordered_nozzle_numbers(records)
column_count = max(1, _coerce_int(columns, 2))
row_count = max(1, _coerce_int(rows, 1))
try:
x_spacing = float(column_spacing)
except (TypeError, ValueError):
x_spacing = 5.0
try:
y_spacing = float(row_spacing)
except (TypeError, ValueError):
y_spacing = 5.0
spacing_rows: list[list[Any]] = []
for index, (first, second) in enumerate(zip(ordered_nozzles, ordered_nozzles[1:])):
current_col = index % column_count
next_col = (index + 1) % column_count
if next_col > current_col:
gap_x = x_spacing
gap_y = 0.0
else:
gap_x = -x_spacing * (column_count - 1)
gap_y = y_spacing
spacing_rows.append([
_nozzle_spacing_label(first, records),
_nozzle_spacing_label(second, records),
gap_x,
gap_y,
])
return spacing_rows, column_count, row_count
def apply_nozzle_grid_spacing(
records: list[dict] | None,
columns: Any,
rows: Any,
column_spacing: Any,
row_spacing: Any,
) -> tuple[dict[str, Any], dict[str, Any], str]:
records = records or []
spacing_rows, column_count, row_count = _grid_spacing_rows(records, columns, rows, column_spacing, row_spacing)
nozzle_count = len(_ordered_nozzle_numbers(records))
capacity = column_count * row_count
status = f"Applied {column_count} x {row_count} grid spacing to {max(nozzle_count - 1, 0)} nozzle pair(s)."
if nozzle_count > capacity:
status += f" {nozzle_count} nozzles exceed {capacity} grid slots, so spacing continues row by row."
return (
gr.update(value=True),
gr.update(
headers=ADVANCED_NOZZLE_SPACING_HEADERS,
value=spacing_rows,
row_count=(len(spacing_rows), "fixed"),
column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"),
label="Advanced Nozzle Spacing",
),
status,
)
def update_nozzle_grid_preset(
preset: str | None,
records: list[dict] | None,
columns: Any,
rows: Any,
) -> tuple[dict[str, Any], dict[str, Any]]:
nozzle_count = max(1, len(_ordered_nozzle_numbers(records or [])))
if preset == "One row":
return gr.update(value=nozzle_count), gr.update(value=1)
if preset == "One column":
return gr.update(value=1), gr.update(value=nozzle_count)
if preset and " x " in preset:
left, right = preset.split(" x ", 1)
return gr.update(value=max(1, _coerce_int(left, 1))), gr.update(value=max(1, _coerce_int(right, 1)))
return gr.update(value=max(1, _coerce_int(columns, 1))), gr.update(value=max(1, _coerce_int(rows, 1)))
def update_nozzle_spacing_mode(layout_mode: str | None) -> tuple[dict[str, Any], dict[str, Any]]:
custom_selected = layout_mode == NOZZLE_LAYOUT_PAIR_TABLE
return gr.update(visible=not custom_selected), gr.update(visible=custom_selected)
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)
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)
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 _append_file_paths(*file_groups: Any) -> list[str]:
paths: list[str] = []
for group in file_groups:
paths.extend(_uploaded_file_paths(group))
return paths
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 = _append_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 normalize_shape_settings_and_spacing(
records: list[dict] | None,
settings_table: Any | None,
scale_mode: str | None,
existing_spacing: Any | None,
use_individual_spacing: bool | None,
) -> tuple:
updated_records, updated_settings = normalize_shape_dimensions_for_mode(records, settings_table, scale_mode)
return (
updated_records,
updated_settings,
_spacing_table_update(updated_records, existing_spacing, use_individual_spacing),
)
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,
_empty_state(),
_reset_slider(),
"No reference stack generated 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}).")
ref_state, ref_slider, ref_label, ref_preview = generate_dynamic_reference_stack(records, progress=progress)
if (ref_state or {}).get("tiff_paths"):
messages.append("Reference TIFF Stack: updated automatically.")
else:
messages.append("Reference TIFF Stack: skipped (no generated shape slices available).")
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,
ref_state,
ref_slider,
ref_label,
ref_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 _split_piece_choice(piece: dict[str, Any]) -> str:
return f"R{piece['row']} C{piece['col']}"
def _split_piece_dropdown_update(pieces: list[dict[str, Any]], selected: str | None = None) -> dict[str, Any]:
choices = [_split_piece_choice(piece) for piece in pieces]
value = selected if selected in choices else (choices[0] if choices else None)
return gr.update(choices=choices, value=value)
def _selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None) -> dict[str, Any] | None:
if not pieces:
return None
for piece in pieces:
if _split_piece_choice(piece) == selected:
return piece
return pieces[0]
def preview_selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None) -> tuple:
piece = _selected_split_piece(pieces, selected)
if not piece:
return _reset_slider(), "No split stack generated yet.", None
state = piece.get("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 slider, label, preview
def jump_to_selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None, index: float) -> tuple:
piece = _selected_split_piece(pieces, selected)
if not piece:
return "No split stack generated yet.", None
return _render_selected_slice(piece.get("state") or _empty_state(), int(index))
def shift_selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None, index: float, delta: int) -> tuple:
piece = _selected_split_piece(pieces, selected)
if not piece:
return gr.update(value=0), "No split stack generated yet.", None
state = piece.get("state") or _empty_state()
new_index, label, preview = shift_slice(state, index, delta)
return gr.update(value=new_index), label, preview
def split_selected_shape_for_grid(
records: list[dict] | None,
selected: str | None,
settings_table: Any | None,
existing_spacing: Any | None,
use_individual_spacing: bool | None,
columns: float,
rows: float,
overlapping_layers: bool,
starting_nozzle: float,
starting_valve: float,
progress: gr.Progress = gr.Progress(),
) -> tuple:
records = _apply_shape_settings(records or [], settings_table)
if not records:
empty = _empty_state()
return (
records,
_shape_settings_rows(records),
_spacing_table_update(records, existing_spacing, use_individual_spacing),
[],
[],
_gcode_dropdown_update(records),
_gcode_dropdown_update(records, include_upload=True),
_dropdown_update(records),
[],
[],
_split_piece_dropdown_update([]),
_reset_slider(),
"No split stack generated yet.",
None,
"Generate TIFF stacks for a shape before splitting it.",
)
pos = _selected_record_index(records, selected)
if pos < 0:
pos = 0
source = records[pos]
state = source.get("tiff_state") or _empty_state()
try:
pieces = split_tiff_stack_grid(
state,
base_name=str(source.get("name") or f"shape_{source.get('idx', pos + 1)}"),
columns=columns,
rows=rows,
overlapping_layers=overlapping_layers,
progress=progress,
)
except Exception as exc:
empty = _empty_state()
return (
records,
_shape_settings_rows(records),
_spacing_table_update(records, existing_spacing, use_individual_spacing),
[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")],
_gcode_dropdown_update(records),
_gcode_dropdown_update(records, include_upload=True),
_dropdown_update(records, selected),
[],
[],
_split_piece_dropdown_update([]),
_reset_slider(),
"No split stack generated yet.",
None,
f"Split failed: {exc}",
)
base_name = str(source.get("name") or f"Shape {source.get('idx', pos + 1)}")
first_nozzle = max(1, _coerce_int(starting_nozzle, 1))
first_valve = max(1, _coerce_int(starting_valve, _coerce_int(source.get("valve", 4), 4)))
split_records: list[dict] = []
for index, piece in enumerate(pieces):
piece_state = piece["state"]
piece_width_mm = float(piece_state.get("image_width", 0) or 0) * float(piece_state.get("pixel_size", 0.0) or 0.0)
piece_height_mm = float(piece_state.get("image_height", 0) or 0) * float(piece_state.get("pixel_size", 0.0) or 0.0)
piece_record = dict(source)
piece_record.update({
"name": f"{base_name} - R{piece['row']}C{piece['col']}",
"stl_path": None,
"target_x": piece_width_mm or source.get("target_x", DEFAULT_TARGET_EXTENTS[0]),
"target_y": piece_height_mm or source.get("target_y", DEFAULT_TARGET_EXTENTS[1]),
"nozzle": first_nozzle + index,
"valve": first_valve + index,
"tiff_state": piece_state,
"zip_path": str(piece["zip_path"]),
"gcode_path": None,
})
split_records.append(piece_record)
next_records = _reindex_shape_records([*records[:pos], *split_records, *records[pos + 1:]])
slider, label, preview = preview_selected_split_piece(pieces, None)
status = (
f"Split Shape {source.get('idx', pos + 1)} into {len(pieces)} print-ready stacks "
f"({max(1, _coerce_int(columns, 2))} columns x {max(1, _coerce_int(rows, 1))} rows). \n"
f"Nozzles {first_nozzle}-{first_nozzle + len(pieces) - 1}; valves {first_valve}-{first_valve + len(pieces) - 1}."
)
if overlapping_layers:
status += " \nOverlapping Layers is enabled: split boundaries alternate by 1 pixel per layer with small blank margins for alignment."
return (
next_records,
_shape_settings_rows(next_records),
_spacing_table_update(next_records, existing_spacing, use_individual_spacing),
[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")],
_gcode_dropdown_update(next_records),
_gcode_dropdown_update(next_records, include_upload=True),
_dropdown_update(next_records),
[str(piece["zip_path"]) for piece in pieces],
pieces,
_split_piece_dropdown_update(pieces),
slider,
label,
preview,
status,
)
def _contour_tracing_sources(records: list[dict]) -> list[dict]:
sources: list[dict] = []
for record in records:
if not record.get("contour_tracing"):
continue
state = record.get("tiff_state") or {}
tiff_paths = state.get("tiff_paths") or []
source = {
"owner_idx": int(record.get("idx", len(sources) + 1)),
"tiff_paths": list(tiff_paths),
"zip_path": record.get("zip_path"),
}
if source["tiff_paths"] or source["zip_path"]:
sources.append(source)
return sources
def generate_dynamic_gcode(
records: list[dict] | None,
settings_table: Any,
all_g1: bool,
use_reference_motion: bool,
raster_pattern: str | None,
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
contour_sources = _contour_tracing_sources(records)
messages: list[str] = []
if contour_sources:
enabled = ", ".join(f"Shape {source['owner_idx']}" for source in contour_sources)
messages.append(f"Contour tracing enabled for {enabled}.")
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,
raster_pattern=raster_pattern,
contour_tiff_sets=contour_sources,
active_contour_owner=int(record.get("idx", 0)),
)
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
nozzle = _record_nozzle_number(record, idx)
parts.append({"idx": idx, "nozzle": nozzle, "color": record.get("color", _default_color(idx)), "parsed": parsed})
messages.append(f"Shape {idx} (Nozzle {nozzle}): {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,
layout_mode: str | None,
columns: Any,
rows: Any,
column_spacing: Any,
row_spacing: Any,
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."
offsets, spacings = _resolve_layout_from_spacing_controls(
parts,
layout_mode,
columns,
rows,
column_spacing,
row_spacing,
use_individual_spacing,
spacing_table,
)
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,
layout_mode: str | None,
columns: Any,
rows: Any,
column_spacing: Any,
row_spacing: Any,
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."
offsets, spacings = _resolve_layout_from_spacing_controls(
parts,
layout_mode,
columns,
rows,
column_spacing,
row_spacing,
use_individual_spacing,
spacing_table,
)
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,
layout_mode: str | None,
columns: Any,
rows: Any,
column_spacing: Any,
row_spacing: Any,
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
offsets, _spacings = _resolve_layout_from_spacing_controls(
parts,
layout_mode,
columns,
rows,
column_spacing,
row_spacing,
use_individual_spacing,
spacing_table,
)
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())
split_piece_states = gr.State([])
with gr.Tab("STL to TIFF Slicer"):
gr.Markdown(
"""
# 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():
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",
)
shape_settings = gr.Dataframe(
headers=SHAPE_SETTINGS_HEADERS,
value=[],
row_count=(0, "dynamic"),
column_count=(len(SHAPE_SETTINGS_HEADERS), "fixed"),
datatype=SHAPE_SETTINGS_DATATYPES,
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.Accordion("Multi-Nozzle Split", open=False, elem_classes=["settings-accordion"]):
with gr.Row():
split_source = gr.Dropdown(label="Source Shape", choices=[], value=None, allow_custom_value=False)
split_refresh_sources = gr.Button("Refresh Source Shapes", variant="secondary", size="sm")
with gr.Row():
split_columns = gr.Number(label="Columns (X)", value=2, minimum=1, step=1)
split_rows = gr.Number(label="Rows (Y)", value=1, minimum=1, step=1)
split_start_nozzle = gr.Number(label="Starting Nozzle", value=1, minimum=1, step=1)
split_start_valve = gr.Number(label="Starting Valve", value=4, minimum=1, step=1)
split_overlapping_layers = gr.Checkbox(label="Overlapping Layers", value=False)
split_button = gr.Button("Split Selected Shape into Grid Pieces", variant="primary")
split_status = gr.Markdown("Generate a TIFF stack, then split it for multi-nozzle printing.")
split_downloads = gr.File(label="Download Split TIFF ZIPs", file_count="multiple", interactive=False)
with gr.Row():
with gr.Column(scale=1, min_width=260):
split_piece_source = gr.Dropdown(label="Preview Generated Piece", choices=[], value=None, allow_custom_value=False)
with gr.Row():
split_piece_prev = gr.Button("Prev", scale=1, min_width=90, size="sm")
split_piece_next = gr.Button("Next", scale=1, min_width=90, size="sm")
split_piece_slider = gr.Slider(label="Piece Slice Index", minimum=0, maximum=0, value=0, step=1, interactive=False)
split_piece_label = gr.Markdown("No split stack generated yet.")
with gr.Column(scale=2, min_width=420):
split_piece_preview = gr.Image(label="Generated Piece Preview", type="pil", image_mode="RGB", height=330)
with gr.Accordion("Selected Shape Preview", open=False, elem_classes=["settings-accordion"]):
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("")
with gr.Accordion("Reference TIFF Stack Preview", open=False, elem_classes=["settings-accordion"]):
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, nozzle, 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).",
value=True,
)
gcode_all_g1 = gr.Checkbox(label="Move at one constant speed (no fast travel moves)", value=True)
gcode_raster_pattern = gr.Dropdown(
label="Raster Pattern",
choices=list(RASTER_PATTERN_CHOICES),
value=RASTER_PATTERN_SAME_DIRECTION,
allow_custom_value=False,
)
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.Accordion("Nozzle Spacing", open=False, elem_classes=["settings-accordion"]):
nozzle_layout_mode = gr.Radio(
label="Spacing Mode",
choices=[NOZZLE_LAYOUT_GRID, NOZZLE_LAYOUT_PAIR_TABLE],
value=NOZZLE_LAYOUT_GRID,
)
with gr.Group(visible=True) as nozzle_grid_group:
with gr.Row():
nozzle_grid_preset = gr.Dropdown(
label="Common Layout",
choices=NOZZLE_LAYOUT_PRESETS,
value="Custom",
allow_custom_value=False,
)
nozzle_grid_columns = gr.Number(label="Grid Columns", value=2, minimum=1, step=1)
nozzle_grid_rows = gr.Number(label="Grid Rows", value=2, minimum=1, step=1)
nozzle_grid_column_spacing = gr.Number(label="Column Gap (X, mm)", value=0.0, step=0.1)
nozzle_grid_row_spacing = gr.Number(label="Row Gap (Y, mm)", value=0.0, step=0.1)
with gr.Group(visible=False) as nozzle_custom_group:
nozzle_use_individual_spacing = gr.Checkbox(label="Use Different Values for Each Nozzle Connection", value=False)
nozzle_spacing_table = gr.Dataframe(
headers=NOZZLE_SPACING_HEADERS,
value=[["Same spacing", "All neighboring nozzles", 5.0, 0.0]],
row_count=(1, "fixed"),
column_count=(len(NOZZLE_SPACING_HEADERS), "fixed"),
interactive=True,
label="Custom 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(
"⚠️ 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("")
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)
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.")
with gr.Row():
pp_gif_duration = gr.Slider(label="Duration (s)", minimum=2.0, maximum=20.0, value=6.0, step=1.0, min_width=150)
pp_gif_fps = gr.Slider(label="Frames per second", minimum=5, maximum=30, value=10, step=1, min_width=150)
with gr.Row():
pp_elev = gr.Slider(label="Elevation angle", minimum=0, maximum=90, value=22, step=1, min_width=150)
pp_azim = gr.Slider(label="Azimuth angle", minimum=-180, maximum=180, value=-60, step=5, min_width=150)
pp_gif_travel_opacity = gr.Slider(label="Travel opacity in GIF (0 = hidden)", minimum=0.0, maximum=1.0, value=0.15, step=0.05)
pp_export_button = gr.Button("Export Animation as GIF", variant="primary")
pp_gif_file = gr.File(label="Download GIF", interactive=False)
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")
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).then(
fn=lambda records: _dropdown_update(records),
inputs=[shape_records],
outputs=[split_source],
queue=False,
)
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).then(
fn=lambda records: _dropdown_update(records),
inputs=[shape_records],
outputs=[split_source],
queue=False,
)
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]).then(
fn=lambda records: _dropdown_update(records),
inputs=[shape_records],
outputs=[split_source],
queue=False,
)
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],
).then(
fn=lambda records: _dropdown_update(records),
inputs=[shape_records],
outputs=[split_source],
queue=False,
)
preview_inputs = [shape_records, selected_shape, shape_settings, model_opacity, scale_mode]
shape_settings.change(
fn=normalize_shape_settings_and_spacing,
inputs=[shape_records, shape_settings, scale_mode, nozzle_spacing_table, nozzle_use_individual_spacing],
outputs=[shape_records, shape_settings, nozzle_spacing_table],
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_state,
ref_slice_slider,
ref_slice_label,
ref_slice_preview,
],
).then(
fn=lambda records: _dropdown_update(records),
inputs=[shape_records],
outputs=[split_source],
queue=False,
)
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)
split_refresh_sources.click(fn=lambda records: _dropdown_update(records), inputs=[shape_records], outputs=[split_source], queue=False)
split_button.click(
fn=split_selected_shape_for_grid,
inputs=[
shape_records,
split_source,
shape_settings,
nozzle_spacing_table,
nozzle_use_individual_spacing,
split_columns,
split_rows,
split_overlapping_layers,
split_start_nozzle,
split_start_valve,
],
outputs=[
shape_records,
shape_settings,
nozzle_spacing_table,
tiff_downloads,
gcode_downloads,
gcode_text_source,
gcode_source,
split_source,
split_downloads,
split_piece_states,
split_piece_source,
split_piece_slider,
split_piece_label,
split_piece_preview,
split_status,
],
).then(
fn=generate_dynamic_reference_stack,
inputs=[shape_records],
outputs=[ref_state, ref_slice_slider, ref_slice_label, ref_slice_preview],
)
split_piece_source.change(fn=preview_selected_split_piece, inputs=[split_piece_states, split_piece_source], outputs=[split_piece_slider, split_piece_label, split_piece_preview], queue=False)
split_piece_slider.release(fn=jump_to_selected_split_piece, inputs=[split_piece_states, split_piece_source, split_piece_slider], outputs=[split_piece_label, split_piece_preview], queue=False)
split_piece_prev.click(fn=lambda pieces, selected, idx: shift_selected_split_piece(pieces, selected, idx, -1), inputs=[split_piece_states, split_piece_source, split_piece_slider], outputs=[split_piece_slider, split_piece_label, split_piece_preview], queue=False)
split_piece_next.click(fn=lambda pieces, selected, idx: shift_selected_split_piece(pieces, selected, idx, 1), inputs=[split_piece_states, split_piece_source, split_piece_slider], outputs=[split_piece_slider, split_piece_label, split_piece_preview], queue=False)
gcode_button.click(
fn=generate_dynamic_gcode,
inputs=[shape_records, shape_settings, gcode_all_g1, gcode_use_ref_motion, gcode_raster_pattern, 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_layout_mode.change(
fn=update_nozzle_spacing_mode,
inputs=[nozzle_layout_mode],
outputs=[nozzle_grid_group, nozzle_custom_group],
queue=False,
)
nozzle_grid_preset.change(
fn=update_nozzle_grid_preset,
inputs=[nozzle_grid_preset, shape_records, nozzle_grid_columns, nozzle_grid_rows],
outputs=[nozzle_grid_columns, nozzle_grid_rows],
queue=False,
)
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_layout_mode,
nozzle_grid_columns,
nozzle_grid_rows,
nozzle_grid_column_spacing,
nozzle_grid_row_spacing,
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_layout_mode,
nozzle_grid_columns,
nozzle_grid_rows,
nozzle_grid_column_spacing,
nozzle_grid_row_spacing,
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_layout_mode,
nozzle_grid_columns,
nozzle_grid_rows,
nozzle_grid_column_spacing,
nozzle_grid_row_spacing,
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_dynamic_demo()
if __name__ == "__main__":
demo.launch(ssr_mode=False)