from __future__ import annotations from pathlib import Path from typing import Any import gradio as gr from PIL import Image from stl_slicer import SliceStack, load_mesh, slice_stl_to_tiffs ViewerState = dict[str, Any] def _read_slice_preview(path: str) -> Image.Image: with Image.open(path) as image: return image.copy() def _empty_state() -> ViewerState: return {"tiff_paths": [], "z_values": []} 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: return { "tiff_paths": [str(path) for path in stack.tiff_paths], "z_values": stack.z_values, } def _format_summary(stack: SliceStack, source_name: str) -> str: (x_min, y_min, z_min), (x_max, y_max, z_max) = stack.bounds return "\n".join( [ "### Slice Stack Ready", f"- Source: `{source_name}`", f"- TIFF count: `{len(stack.tiff_paths)}`", f"- Image size: `{stack.image_size[0]} x {stack.image_size[1]}` pixels", f"- Layer height: `{stack.layer_height}`", f"- Pixel size: `{stack.pixel_size}`", f"- Bounds: `x={x_min:.3f}..{x_max:.3f}`, `y={y_min:.3f}..{y_max:.3f}`, `z={z_min:.3f}..{z_max:.3f}`", ] ) def _format_model_status(source_name: str) -> str: return "\n".join( [ "### Model Loaded", f"- Source: `{source_name}`", "- Rotate the model in the 3D viewer, then choose slice settings and generate the TIFF stack.", ] ) def _format_model_details(source_name: str, mesh) -> str: extents = mesh.extents return "\n".join( [ "### Model Details", f"- Source: `{source_name}`", f"- Extents: `{extents[0]:.3f} x {extents[1]:.3f} x {extents[2]:.3f}`", f"- Faces: `{len(mesh.faces)}`", f"- Vertices: `{len(mesh.vertices)}`", f"- Watertight: `{'yes' if mesh.is_watertight else 'no'}`", ] ) 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 _render_selected_slice(state: ViewerState, index: int) -> tuple[str, Image.Image | None, str | None]: tiff_paths = state.get("tiff_paths", []) if not tiff_paths: return "No slice stack loaded yet.", None, None bounded_index = max(0, min(int(index), len(tiff_paths) - 1)) selected_path = tiff_paths[bounded_index] return ( _slice_label(state, bounded_index), _read_slice_preview(selected_path), selected_path, ) def load_model_assets(stl_file: str | None): if not stl_file: return ( "Upload an STL file to begin.", _empty_state(), _reset_slider(), "No slice stack loaded yet.", None, None, None, None, "No model loaded yet.", ) mesh = load_mesh(stl_file) source_name = Path(stl_file).name return ( _format_model_status(source_name), _empty_state(), _reset_slider(), "No slice stack loaded yet.", None, None, None, stl_file, _format_model_details(source_name, mesh), ) def generate_stack( stl_file: str | None, layer_height: float, pixel_size: float, progress: gr.Progress = gr.Progress(), ): if not stl_file: raise gr.Error("Upload an STL file before generating slices.") def report_progress(current: int, total: int) -> None: progress(current / total, desc=f"Rendering slice {current} of {total}") stack = slice_stl_to_tiffs( stl_file, layer_height=layer_height, pixel_size=pixel_size, progress_callback=report_progress, ) state = _stack_to_state(stack) label, preview, selected_path = _render_selected_slice(state, 0) slider_update = gr.update( minimum=0, maximum=max(0, len(stack.tiff_paths) - 1), value=0, step=1, interactive=len(stack.tiff_paths) > 1, ) return ( _format_summary(stack, Path(stl_file).name), state, slider_update, label, preview, str(stack.zip_path), selected_path, ) def jump_to_slice(state: ViewerState, index: float) -> tuple[str, Image.Image | None, str | None]: return _render_selected_slice(state, int(index)) def shift_slice(state: ViewerState, index: float, delta: int) -> tuple[int, str, Image.Image | None, str | None]: tiff_paths = state.get("tiff_paths", []) if not tiff_paths: return 0, "No slice stack loaded yet.", None, None new_index = max(0, min(int(index) + delta, len(tiff_paths) - 1)) label, preview, selected_path = _render_selected_slice(state, new_index) return new_index, label, preview, selected_path def build_demo() -> gr.Blocks: with gr.Blocks(title="STL TIFF Slicer") as demo: gr.Markdown( """ # STL to TIFF Slicer Upload an STL to inspect it in a rotatable 3D viewer. Then choose a layer height and XY pixel size, generate the TIFF stack, and browse the slices below. """ ) state = gr.State(_empty_state()) with gr.Row(): with gr.Column(scale=1): stl_file = gr.File( label="STL File", file_types=[".stl"], type="filepath", ) layer_height = gr.Number( label="Layer Height", value=0.1, minimum=0.0001, step=0.01, ) pixel_size = gr.Number( label="Pixel Size", value=0.05, minimum=0.0001, step=0.01, ) generate_button = gr.Button("Generate TIFF Stack", variant="primary") download_zip = gr.File(label="Download All TIFFs (ZIP)") current_tiff = gr.File(label="Current TIFF Slice") with gr.Column(scale=2): summary = gr.Markdown("Upload an STL file to begin.") model_details = gr.Markdown("No model loaded yet.") model_viewer = gr.Model3D( label="Interactive 3D Viewer", display_mode="solid", clear_color=(0.94, 0.95, 0.97, 1.0), height=360, ) slice_label = gr.Markdown("No slice stack loaded yet.") slice_preview = gr.Image( label="Slice Preview", type="pil", image_mode="L", height=420, ) with gr.Row(): prev_button = gr.Button("Previous Slice") next_button = gr.Button("Next Slice") slice_slider = gr.Slider( label="Slice Index", minimum=0, maximum=0, value=0, step=1, interactive=False, ) stl_file.change( fn=load_model_assets, inputs=stl_file, outputs=[ summary, state, slice_slider, slice_label, slice_preview, download_zip, current_tiff, model_viewer, model_details, ], ) generate_button.click( fn=generate_stack, inputs=[stl_file, layer_height, pixel_size], outputs=[ summary, state, slice_slider, slice_label, slice_preview, download_zip, current_tiff, ], ) slice_slider.release( fn=jump_to_slice, inputs=[state, slice_slider], outputs=[slice_label, slice_preview, current_tiff], queue=False, ) prev_button.click( fn=lambda state_value, index: shift_slice(state_value, index, -1), inputs=[state, slice_slider], outputs=[slice_slider, slice_label, slice_preview, current_tiff], queue=False, ) next_button.click( fn=lambda state_value, index: shift_slice(state_value, index, 1), inputs=[state, slice_slider], outputs=[slice_slider, slice_label, slice_preview, current_tiff], queue=False, ) return demo demo = build_demo() if __name__ == "__main__": demo.launch(ssr_mode=False)