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