Spaces:
Sleeping
Sleeping
Commit ·
594b22c
1
Parent(s): 8cc1ac6
Add Parallel Printing Visualization tab
Browse filesNew fourth tab plots all three shapes simultaneously as filament tubes,
offset along X so they do not overlap, and animates them printing in
parallel on a shared cumulative-path-length time axis (shorter parts
finish first). Features:
- per-part color dropdowns (live recolor)
- travel tubes per part with an opacity slider (0 = hidden)
- filament/travel width and gap-between-parts sliders
- full animation control bar (play/pause, speed, scrub, frame step),
driven by a dedicated multi-part engine independent of the single-plot
tab so that tab is unaffected
Adds build_parallel_figure() in gcode_viewer.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- app.py +370 -2
- gcode_viewer.py +109 -0
app.py
CHANGED
|
@@ -21,7 +21,7 @@ import numpy as np
|
|
| 21 |
from PIL import Image, ImageDraw, ImageFont
|
| 22 |
import trimesh
|
| 23 |
|
| 24 |
-
from gcode_viewer import build_toolpath_figure, parse_gcode_path
|
| 25 |
from stl_slicer import SliceStack, load_mesh, slice_stl_to_tiffs
|
| 26 |
from tiff_to_gcode import generate_snake_path_gcode
|
| 27 |
|
|
@@ -168,6 +168,43 @@ APP_CSS = """
|
|
| 168 |
#gcode-upload-col {
|
| 169 |
display: none;
|
| 170 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
"""
|
| 172 |
|
| 173 |
# Gradio 6.10's gr.Model3D leaves the Undo (reset view) button permanently
|
|
@@ -462,6 +499,173 @@ TOOLPATH_CONTROLS_HTML = """
|
|
| 462 |
"""
|
| 463 |
|
| 464 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
def _read_slice_preview(path: str) -> Image.Image:
|
| 466 |
with Image.open(path) as image:
|
| 467 |
preview = image.copy()
|
|
@@ -1177,6 +1381,61 @@ def rerender_toolpath_current_mode(
|
|
| 1177 |
)
|
| 1178 |
|
| 1179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1180 |
def update_toolpath_opacity(
|
| 1181 |
parsed: dict,
|
| 1182 |
travel_opacity: float,
|
|
@@ -1313,7 +1572,7 @@ def generate_reference_stack(
|
|
| 1313 |
return ref_state, slider, label, preview
|
| 1314 |
|
| 1315 |
def build_demo() -> gr.Blocks:
|
| 1316 |
-
with gr.Blocks(title="STL TIFF Slicer", css=APP_CSS, head=APP_HEAD + TOOLPATH_ANIM_HEAD) as demo:
|
| 1317 |
with gr.Tab("STL to TIFF Slicer"):
|
| 1318 |
gr.Markdown(
|
| 1319 |
"""
|
|
@@ -1959,6 +2218,115 @@ def build_demo() -> gr.Blocks:
|
|
| 1959 |
}"""
|
| 1960 |
)
|
| 1961 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1962 |
return demo
|
| 1963 |
|
| 1964 |
|
|
|
|
| 21 |
from PIL import Image, ImageDraw, ImageFont
|
| 22 |
import trimesh
|
| 23 |
|
| 24 |
+
from gcode_viewer import build_parallel_figure, build_toolpath_figure, parse_gcode_path
|
| 25 |
from stl_slicer import SliceStack, load_mesh, slice_stl_to_tiffs
|
| 26 |
from tiff_to_gcode import generate_snake_path_gcode
|
| 27 |
|
|
|
|
| 168 |
#gcode-upload-col {
|
| 169 |
display: none;
|
| 170 |
}
|
| 171 |
+
|
| 172 |
+
/* Parallel-printing animation controls share the look of the single-plot bar. */
|
| 173 |
+
#parallel-anim-controls {
|
| 174 |
+
display: flex;
|
| 175 |
+
align-items: center;
|
| 176 |
+
gap: 0.6rem;
|
| 177 |
+
flex-wrap: wrap;
|
| 178 |
+
padding: 0.4rem 0.2rem;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
#parallel-anim-controls button,
|
| 182 |
+
#parallel-anim-controls select {
|
| 183 |
+
background: var(--button-secondary-background-fill);
|
| 184 |
+
color: var(--button-secondary-text-color);
|
| 185 |
+
border: 1px solid var(--border-color-primary);
|
| 186 |
+
border-radius: 0.4rem;
|
| 187 |
+
padding: 0.25rem 0.7rem;
|
| 188 |
+
font-size: 0.85rem;
|
| 189 |
+
cursor: pointer;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
#parallel-anim-controls button:hover,
|
| 193 |
+
#parallel-anim-controls select:hover {
|
| 194 |
+
border-color: #f97316;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
#pp-scrub {
|
| 198 |
+
flex: 1 1 140px;
|
| 199 |
+
min-width: 120px;
|
| 200 |
+
accent-color: #f97316;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
#pp-readout {
|
| 204 |
+
font-size: 0.85rem;
|
| 205 |
+
color: var(--body-text-color-subdued);
|
| 206 |
+
flex-basis: 100%;
|
| 207 |
+
}
|
| 208 |
"""
|
| 209 |
|
| 210 |
# Gradio 6.10's gr.Model3D leaves the Undo (reset view) button permanently
|
|
|
|
| 499 |
"""
|
| 500 |
|
| 501 |
|
| 502 |
+
# Parallel-printing animation engine: drives multiple parts in one plot off a
|
| 503 |
+
# shared cumulative-length time axis. Independent of the single-plot engine so
|
| 504 |
+
# the G-Code Visualization tab is unaffected. Targets #parallel_plot / #pp-*.
|
| 505 |
+
PARALLEL_ANIM_HEAD = """
|
| 506 |
+
<script>
|
| 507 |
+
(function () {
|
| 508 |
+
var anim = { gd:null, meta:null, cache:null, playing:false, scrubbing:false,
|
| 509 |
+
cutoff:0, speed:1, lastTick:null, lastDraw:0, raf:null };
|
| 510 |
+
|
| 511 |
+
function findPlot() {
|
| 512 |
+
var c = document.getElementById('parallel_plot');
|
| 513 |
+
return c ? c.querySelector('.js-plotly-plot') : null;
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
function ensureInit() {
|
| 517 |
+
var gd = findPlot();
|
| 518 |
+
if (!gd || !gd.data || !gd.layout || !gd.layout.meta || !gd.layout.meta.animation) return false;
|
| 519 |
+
var meta = gd.layout.meta.animation;
|
| 520 |
+
if (gd === anim.gd && meta === anim.meta && anim.cache) return true;
|
| 521 |
+
|
| 522 |
+
var nameToIdx = {};
|
| 523 |
+
for (var i = 0; i < gd.data.length; i++) nameToIdx[gd.data[i].name] = i;
|
| 524 |
+
function snapMesh(idx, t) {
|
| 525 |
+
if (idx == null || idx < 0 || !t || !t.length) return null;
|
| 526 |
+
var tr = gd.data[idx];
|
| 527 |
+
return { i: Array.from(tr.i), j: Array.from(tr.j), k: Array.from(tr.k), t: t };
|
| 528 |
+
}
|
| 529 |
+
var parts = meta.parts.map(function (p) {
|
| 530 |
+
var printIdx = nameToIdx[p.printName]; if (printIdx == null) printIdx = -1;
|
| 531 |
+
var travelIdx = nameToIdx[p.travelName]; if (travelIdx == null) travelIdx = -1;
|
| 532 |
+
var nozzleIdx = nameToIdx[p.nozzleName]; if (nozzleIdx == null) nozzleIdx = -1;
|
| 533 |
+
return {
|
| 534 |
+
printIdx: printIdx, travelIdx: travelIdx, nozzleIdx: nozzleIdx,
|
| 535 |
+
printMesh: snapMesh(printIdx, p.print_face_t),
|
| 536 |
+
travelMesh: snapMesh(travelIdx, p.travel_face_t),
|
| 537 |
+
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
|
| 538 |
+
};
|
| 539 |
+
});
|
| 540 |
+
var times = [];
|
| 541 |
+
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]); });
|
| 542 |
+
times.sort(function (a, b) { return a - b; });
|
| 543 |
+
anim.cache = { parts: parts, times: times.filter(function (v, j) { return j === 0 || v !== times[j-1]; }) };
|
| 544 |
+
anim.gd = gd; anim.meta = meta; anim.cutoff = meta.total_length; anim.playing = false;
|
| 545 |
+
return true;
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
function upperBound(arr, v) {
|
| 549 |
+
var lo = 0, hi = arr.length;
|
| 550 |
+
while (lo < hi) { var m = (lo + hi) >> 1; if (arr[m] <= v) lo = m + 1; else hi = m; }
|
| 551 |
+
return lo;
|
| 552 |
+
}
|
| 553 |
+
function nozzlePos(path, cutoff) {
|
| 554 |
+
var n = upperBound(path.t, cutoff);
|
| 555 |
+
if (n <= 0) return [path.x[0], path.y[0], path.z[0]];
|
| 556 |
+
if (n >= path.t.length) { var m = path.t.length - 1; return [path.x[m], path.y[m], path.z[m]]; }
|
| 557 |
+
var t0 = path.t[n-1], t1 = path.t[n], f = t1 > t0 ? (cutoff - t0) / (t1 - t0) : 1;
|
| 558 |
+
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];
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
function applyCutoff() {
|
| 562 |
+
if (!ensureInit()) return;
|
| 563 |
+
var cutoff = anim.cutoff;
|
| 564 |
+
var meshIdxs=[], fis=[], fjs=[], fks=[], nozIdxs=[], nx=[], ny=[], nz=[];
|
| 565 |
+
anim.cache.parts.forEach(function (pt) {
|
| 566 |
+
[['travelMesh','travelIdx'],['printMesh','printIdx']].forEach(function (pair) {
|
| 567 |
+
var mesh = pt[pair[0]], idx = pt[pair[1]];
|
| 568 |
+
if (!mesh || idx < 0) return;
|
| 569 |
+
var nf = upperBound(mesh.t, cutoff);
|
| 570 |
+
meshIdxs.push(idx); fis.push(mesh.i.slice(0,nf)); fjs.push(mesh.j.slice(0,nf)); fks.push(mesh.k.slice(0,nf));
|
| 571 |
+
});
|
| 572 |
+
if (pt.nozzleIdx >= 0 && pt.path) {
|
| 573 |
+
var pos = nozzlePos(pt.path, cutoff);
|
| 574 |
+
nozIdxs.push(pt.nozzleIdx); nx.push([pos[0]]); ny.push([pos[1]]); nz.push([pos[2]]);
|
| 575 |
+
}
|
| 576 |
+
});
|
| 577 |
+
if (nozIdxs.length) Plotly.restyle(anim.gd, { x:nx, y:ny, z:nz }, nozIdxs);
|
| 578 |
+
if (meshIdxs.length) Plotly.restyle(anim.gd, { i:fis, j:fjs, k:fks }, meshIdxs);
|
| 579 |
+
syncUI();
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
function syncUI() {
|
| 583 |
+
if (!anim.meta) return;
|
| 584 |
+
var total = anim.meta.total_length || 1;
|
| 585 |
+
var frac = Math.max(0, Math.min(1, anim.cutoff / total));
|
| 586 |
+
var scrub = document.getElementById('pp-scrub');
|
| 587 |
+
if (scrub && !anim.scrubbing) scrub.value = String(Math.round(frac * 1000));
|
| 588 |
+
var readout = document.getElementById('pp-readout');
|
| 589 |
+
if (readout) readout.textContent = Math.round(frac * 100) + '% of build';
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
function setPlaying(on) {
|
| 593 |
+
anim.playing = on;
|
| 594 |
+
var btn = document.getElementById('pp-play');
|
| 595 |
+
if (btn) btn.innerHTML = on ? '⏸ Pause' : '▶ Play';
|
| 596 |
+
if (on) { anim.lastTick = null; anim.raf = requestAnimationFrame(tick); }
|
| 597 |
+
else if (anim.raf) { cancelAnimationFrame(anim.raf); anim.raf = null; }
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
function tick(ts) {
|
| 601 |
+
if (!anim.playing) return;
|
| 602 |
+
if (!ensureInit()) { setPlaying(false); return; }
|
| 603 |
+
if (anim.lastTick == null) anim.lastTick = ts;
|
| 604 |
+
var dt = (ts - anim.lastTick) / 1000; anim.lastTick = ts;
|
| 605 |
+
var rate = (anim.meta.total_length / 60) * anim.speed;
|
| 606 |
+
anim.cutoff = Math.min(anim.meta.total_length, anim.cutoff + dt * rate);
|
| 607 |
+
if (anim.cutoff >= anim.meta.total_length) { applyCutoff(); setPlaying(false); return; }
|
| 608 |
+
if (ts - anim.lastDraw >= 33) { anim.lastDraw = ts; applyCutoff(); }
|
| 609 |
+
anim.raf = requestAnimationFrame(tick);
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
document.addEventListener('click', function (e) {
|
| 613 |
+
var target = e.target.closest ? e.target.closest('#pp-play, #pp-restart, #pp-step-back, #pp-step-fwd') : null;
|
| 614 |
+
if (!target) return;
|
| 615 |
+
if (!ensureInit()) return;
|
| 616 |
+
var times = anim.cache.times || [];
|
| 617 |
+
if (target.id === 'pp-play') {
|
| 618 |
+
if (!anim.playing && anim.cutoff >= anim.meta.total_length) anim.cutoff = 0;
|
| 619 |
+
setPlaying(!anim.playing);
|
| 620 |
+
} else if (target.id === 'pp-step-fwd') {
|
| 621 |
+
setPlaying(false);
|
| 622 |
+
var i = upperBound(times, anim.cutoff);
|
| 623 |
+
if (i < times.length) { anim.cutoff = times[i]; applyCutoff(); }
|
| 624 |
+
} else if (target.id === 'pp-step-back') {
|
| 625 |
+
setPlaying(false);
|
| 626 |
+
var lo = 0, hi = times.length;
|
| 627 |
+
while (lo < hi) { var mid = (lo + hi) >> 1; if (times[mid] < anim.cutoff) lo = mid + 1; else hi = mid; }
|
| 628 |
+
anim.cutoff = lo > 0 ? times[lo - 1] : 0;
|
| 629 |
+
applyCutoff();
|
| 630 |
+
} else {
|
| 631 |
+
setPlaying(false); anim.cutoff = 0; applyCutoff();
|
| 632 |
+
}
|
| 633 |
+
});
|
| 634 |
+
document.addEventListener('input', function (e) {
|
| 635 |
+
if (e.target && e.target.id === 'pp-scrub') {
|
| 636 |
+
if (!ensureInit()) return;
|
| 637 |
+
setPlaying(false); anim.scrubbing = true;
|
| 638 |
+
anim.cutoff = (parseFloat(e.target.value) / 1000) * anim.meta.total_length;
|
| 639 |
+
applyCutoff(); anim.scrubbing = false;
|
| 640 |
+
}
|
| 641 |
+
});
|
| 642 |
+
document.addEventListener('change', function (e) {
|
| 643 |
+
if (e.target && e.target.id === 'pp-speed') anim.speed = parseFloat(e.target.value) || 1;
|
| 644 |
+
});
|
| 645 |
+
})();
|
| 646 |
+
</script>
|
| 647 |
+
"""
|
| 648 |
+
|
| 649 |
+
PARALLEL_CONTROLS_HTML = """
|
| 650 |
+
<div id="parallel-anim-controls">
|
| 651 |
+
<button id="pp-restart" type="button" title="Back to start">⏮</button>
|
| 652 |
+
<button id="pp-step-back" type="button" title="Step back one move">⏴</button>
|
| 653 |
+
<button id="pp-play" type="button">▶ Play</button>
|
| 654 |
+
<button id="pp-step-fwd" type="button" title="Step forward one move">⏵</button>
|
| 655 |
+
<select id="pp-speed" title="Playback speed">
|
| 656 |
+
<option value="0.25">0.25×</option>
|
| 657 |
+
<option value="0.5">0.5×</option>
|
| 658 |
+
<option value="1" selected>1×</option>
|
| 659 |
+
<option value="2">2×</option>
|
| 660 |
+
<option value="5">5×</option>
|
| 661 |
+
<option value="10">10×</option>
|
| 662 |
+
</select>
|
| 663 |
+
<input id="pp-scrub" type="range" min="0" max="1000" value="1000" step="1" title="Build progress">
|
| 664 |
+
<span id="pp-readout">Render the parallel print, then press Play.</span>
|
| 665 |
+
</div>
|
| 666 |
+
"""
|
| 667 |
+
|
| 668 |
+
|
| 669 |
def _read_slice_preview(path: str) -> Image.Image:
|
| 670 |
with Image.open(path) as image:
|
| 671 |
preview = image.copy()
|
|
|
|
| 1381 |
)
|
| 1382 |
|
| 1383 |
|
| 1384 |
+
PARALLEL_COLOR_CHOICES = [
|
| 1385 |
+
("Orange", "#ff7f0e"), ("Blue", "#1f77b4"), ("Green", "#2ca02c"),
|
| 1386 |
+
("Red", "#d62728"), ("Purple", "#9467bd"), ("Pink", "#e377c2"),
|
| 1387 |
+
("Teal", "#17becf"), ("Black", "#000000"),
|
| 1388 |
+
]
|
| 1389 |
+
|
| 1390 |
+
|
| 1391 |
+
def render_parallel(
|
| 1392 |
+
path1: str | None,
|
| 1393 |
+
path2: str | None,
|
| 1394 |
+
path3: str | None,
|
| 1395 |
+
color1: str,
|
| 1396 |
+
color2: str,
|
| 1397 |
+
color3: str,
|
| 1398 |
+
travel_opacity: float,
|
| 1399 |
+
filament_width: float,
|
| 1400 |
+
travel_width: float,
|
| 1401 |
+
gap: float,
|
| 1402 |
+
) -> tuple[Any, str, dict[str, Any]]:
|
| 1403 |
+
specs = [(1, path1, color1), (2, path2, color2), (3, path3, color3)]
|
| 1404 |
+
parts: list[dict] = []
|
| 1405 |
+
messages: list[str] = []
|
| 1406 |
+
|
| 1407 |
+
for idx, path, color in specs:
|
| 1408 |
+
if not path:
|
| 1409 |
+
messages.append(f"Shape {idx}: no G-code (generate it on the TIFF Slices to GCode tab).")
|
| 1410 |
+
continue
|
| 1411 |
+
try:
|
| 1412 |
+
parsed = parse_gcode_path(Path(path).read_text())
|
| 1413 |
+
except OSError as exc:
|
| 1414 |
+
messages.append(f"Shape {idx}: failed to read ({exc}).")
|
| 1415 |
+
continue
|
| 1416 |
+
if not parsed.get("point_count"):
|
| 1417 |
+
messages.append(f"Shape {idx}: no G0/G1 moves found.")
|
| 1418 |
+
continue
|
| 1419 |
+
parts.append({"idx": idx, "color": color, "parsed": parsed})
|
| 1420 |
+
messages.append(f"Shape {idx}: {parsed['point_count']} moves, {parsed.get('layer_count', 0)} layer(s).")
|
| 1421 |
+
|
| 1422 |
+
if not parts:
|
| 1423 |
+
return (
|
| 1424 |
+
None,
|
| 1425 |
+
"No shape G-code available. Generate G-code on the TIFF Slices to GCode tab first.",
|
| 1426 |
+
gr.update(visible=False),
|
| 1427 |
+
)
|
| 1428 |
+
|
| 1429 |
+
figure = build_parallel_figure(
|
| 1430 |
+
parts,
|
| 1431 |
+
gap=float(gap),
|
| 1432 |
+
filament_width=float(filament_width),
|
| 1433 |
+
travel_width=float(travel_width),
|
| 1434 |
+
travel_opacity=float(travel_opacity),
|
| 1435 |
+
)
|
| 1436 |
+
return figure, " \n".join(messages), gr.update(visible=True)
|
| 1437 |
+
|
| 1438 |
+
|
| 1439 |
def update_toolpath_opacity(
|
| 1440 |
parsed: dict,
|
| 1441 |
travel_opacity: float,
|
|
|
|
| 1572 |
return ref_state, slider, label, preview
|
| 1573 |
|
| 1574 |
def build_demo() -> gr.Blocks:
|
| 1575 |
+
with gr.Blocks(title="STL TIFF Slicer", css=APP_CSS, head=APP_HEAD + TOOLPATH_ANIM_HEAD + PARALLEL_ANIM_HEAD) as demo:
|
| 1576 |
with gr.Tab("STL to TIFF Slicer"):
|
| 1577 |
gr.Markdown(
|
| 1578 |
"""
|
|
|
|
| 2218 |
}"""
|
| 2219 |
)
|
| 2220 |
|
| 2221 |
+
with gr.Tab("Parallel Printing Visualization"):
|
| 2222 |
+
gr.Markdown(
|
| 2223 |
+
"### Parallel Printing Visualization\n"
|
| 2224 |
+
"Plots all three shapes side by side (offset in X) and animates "
|
| 2225 |
+
"them printing in parallel. Uses the G-code generated on the "
|
| 2226 |
+
"**TIFF Slices to GCode** tab."
|
| 2227 |
+
)
|
| 2228 |
+
with gr.Row():
|
| 2229 |
+
# --- Left column: controls ---
|
| 2230 |
+
with gr.Column(scale=1, min_width=340):
|
| 2231 |
+
parallel_render_button = gr.Button(
|
| 2232 |
+
"Render Parallel Print", variant="primary"
|
| 2233 |
+
)
|
| 2234 |
+
gr.Markdown(
|
| 2235 |
+
"⚠️ Building three tube plots can take a while "
|
| 2236 |
+
"for high-resolution models.",
|
| 2237 |
+
elem_id="parallel-render-warning",
|
| 2238 |
+
)
|
| 2239 |
+
parallel_anim_controls = gr.HTML(PARALLEL_CONTROLS_HTML, visible=False)
|
| 2240 |
+
with gr.Row():
|
| 2241 |
+
pp_color_1 = gr.Dropdown(
|
| 2242 |
+
label="Shape 1 color", choices=PARALLEL_COLOR_CHOICES,
|
| 2243 |
+
value="#ff7f0e", allow_custom_value=False, min_width=120,
|
| 2244 |
+
)
|
| 2245 |
+
pp_color_2 = gr.Dropdown(
|
| 2246 |
+
label="Shape 2 color", choices=PARALLEL_COLOR_CHOICES,
|
| 2247 |
+
value="#1f77b4", allow_custom_value=False, min_width=120,
|
| 2248 |
+
)
|
| 2249 |
+
pp_color_3 = gr.Dropdown(
|
| 2250 |
+
label="Shape 3 color", choices=PARALLEL_COLOR_CHOICES,
|
| 2251 |
+
value="#2ca02c", allow_custom_value=False, min_width=120,
|
| 2252 |
+
)
|
| 2253 |
+
pp_travel_opacity = gr.Slider(
|
| 2254 |
+
label="Travel opacity (0 = hidden)",
|
| 2255 |
+
minimum=0.0, maximum=1.0, value=0.2, step=0.05,
|
| 2256 |
+
)
|
| 2257 |
+
with gr.Row():
|
| 2258 |
+
pp_filament_width = gr.Slider(
|
| 2259 |
+
label="Filament width (mm)", minimum=0.1, maximum=3.0,
|
| 2260 |
+
value=0.8, step=0.05, min_width=150,
|
| 2261 |
+
)
|
| 2262 |
+
pp_travel_width = gr.Slider(
|
| 2263 |
+
label="Travel width (mm)", minimum=0.05, maximum=3.0,
|
| 2264 |
+
value=0.2, step=0.05, min_width=150,
|
| 2265 |
+
)
|
| 2266 |
+
pp_gap = gr.Slider(
|
| 2267 |
+
label="Gap between parts (mm)",
|
| 2268 |
+
minimum=0.0, maximum=50.0, value=5.0, step=0.5,
|
| 2269 |
+
)
|
| 2270 |
+
parallel_status = gr.Markdown("")
|
| 2271 |
+
# --- Right column: the plot ---
|
| 2272 |
+
with gr.Column(scale=3, min_width=500):
|
| 2273 |
+
parallel_plot = gr.Plot(label="Parallel Tool Paths", elem_id="parallel_plot")
|
| 2274 |
+
|
| 2275 |
+
parallel_render_inputs = [
|
| 2276 |
+
gcode_file_1, gcode_file_2, gcode_file_3,
|
| 2277 |
+
pp_color_1, pp_color_2, pp_color_3,
|
| 2278 |
+
pp_travel_opacity, pp_filament_width, pp_travel_width, pp_gap,
|
| 2279 |
+
]
|
| 2280 |
+
parallel_render_button.click(
|
| 2281 |
+
fn=render_parallel,
|
| 2282 |
+
inputs=parallel_render_inputs,
|
| 2283 |
+
outputs=[parallel_plot, parallel_status, parallel_anim_controls],
|
| 2284 |
+
)
|
| 2285 |
+
# Width and gap changes rebuild the tubes server-side.
|
| 2286 |
+
for _slider in (pp_filament_width, pp_travel_width, pp_gap):
|
| 2287 |
+
_slider.release(
|
| 2288 |
+
fn=render_parallel,
|
| 2289 |
+
inputs=parallel_render_inputs,
|
| 2290 |
+
outputs=[parallel_plot, parallel_status, parallel_anim_controls],
|
| 2291 |
+
)
|
| 2292 |
+
# Color changes recolor a part's print/travel/nozzle traces client-side.
|
| 2293 |
+
for _picker, _idx in ((pp_color_1, 1), (pp_color_2, 2), (pp_color_3, 3)):
|
| 2294 |
+
_picker.change(
|
| 2295 |
+
fn=None,
|
| 2296 |
+
inputs=[_picker],
|
| 2297 |
+
outputs=[],
|
| 2298 |
+
js="""(color) => {
|
| 2299 |
+
const c = document.getElementById("parallel_plot");
|
| 2300 |
+
if (!c) return [];
|
| 2301 |
+
const pd = c.querySelector(".js-plotly-plot");
|
| 2302 |
+
if (!pd || !pd.data) return [];
|
| 2303 |
+
pd.data.forEach((t, i) => {
|
| 2304 |
+
if (t.name === "Shape %d" || t.name === "Travel %d") {
|
| 2305 |
+
Plotly.restyle(pd, {"color": color}, [i]);
|
| 2306 |
+
} else if (t.name === "Nozzle %d") {
|
| 2307 |
+
Plotly.restyle(pd, {"marker.color": color}, [i]);
|
| 2308 |
+
}
|
| 2309 |
+
});
|
| 2310 |
+
return [];
|
| 2311 |
+
}""" % (_idx, _idx, _idx),
|
| 2312 |
+
)
|
| 2313 |
+
# Travel opacity changes apply to all travel meshes client-side.
|
| 2314 |
+
pp_travel_opacity.release(
|
| 2315 |
+
fn=None,
|
| 2316 |
+
inputs=[pp_travel_opacity],
|
| 2317 |
+
outputs=[],
|
| 2318 |
+
js="""(op) => {
|
| 2319 |
+
const c = document.getElementById("parallel_plot");
|
| 2320 |
+
if (!c) return [];
|
| 2321 |
+
const pd = c.querySelector(".js-plotly-plot");
|
| 2322 |
+
if (!pd || !pd.data) return [];
|
| 2323 |
+
pd.data.forEach((t, i) => {
|
| 2324 |
+
if (/^Travel \\d+$/.test(t.name)) Plotly.restyle(pd, {"opacity": op}, [i]);
|
| 2325 |
+
});
|
| 2326 |
+
return [];
|
| 2327 |
+
}""",
|
| 2328 |
+
)
|
| 2329 |
+
|
| 2330 |
return demo
|
| 2331 |
|
| 2332 |
|
gcode_viewer.py
CHANGED
|
@@ -512,6 +512,115 @@ def build_toolpath_figure(
|
|
| 512 |
return fig
|
| 513 |
|
| 514 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
def render_gcode_file(path: str | Path) -> tuple[go.Figure, dict]:
|
| 516 |
text = Path(path).read_text()
|
| 517 |
parsed = parse_gcode_path(text)
|
|
|
|
| 512 |
return fig
|
| 513 |
|
| 514 |
|
| 515 |
+
def build_parallel_figure(
|
| 516 |
+
parts: list[dict],
|
| 517 |
+
gap: float = 5.0,
|
| 518 |
+
filament_width: float = 0.8,
|
| 519 |
+
travel_width: float = 0.2,
|
| 520 |
+
travel_opacity: float = 0.2,
|
| 521 |
+
print_opacity: float = 1.0,
|
| 522 |
+
) -> go.Figure:
|
| 523 |
+
"""Render several parsed shapes side by side as tubes, offset along X so they
|
| 524 |
+
don't overlap, with a shared-time animation timeline (cumulative path length).
|
| 525 |
+
|
| 526 |
+
`parts` is a list of {"idx": int, "color": str, "parsed": dict}. Each part's
|
| 527 |
+
print and travel tubes and nozzle marker are added as separate traces named
|
| 528 |
+
by idx so the client-side animation/recolor can address them.
|
| 529 |
+
"""
|
| 530 |
+
fig = go.Figure()
|
| 531 |
+
anim_parts: list[dict] = []
|
| 532 |
+
total_length = 0.0
|
| 533 |
+
bx0 = by0 = bz0 = float("inf")
|
| 534 |
+
bx1 = by1 = bz1 = float("-inf")
|
| 535 |
+
|
| 536 |
+
running_x = 0.0
|
| 537 |
+
for part in parts:
|
| 538 |
+
idx = part["idx"]
|
| 539 |
+
color = part["color"]
|
| 540 |
+
parsed = part["parsed"]
|
| 541 |
+
moves = parsed.get("moves") or []
|
| 542 |
+
if not moves:
|
| 543 |
+
continue
|
| 544 |
+
|
| 545 |
+
(pxmin, pymin, pzmin), (pxmax, pymax, pzmax) = parsed["bounds"]
|
| 546 |
+
width = pxmax - pxmin
|
| 547 |
+
x_off = running_x - pxmin
|
| 548 |
+
running_x += width + gap
|
| 549 |
+
|
| 550 |
+
print_tube = _build_path_tube(moves, radius=max(filament_width, 0.05) / 2.0, kind="print")
|
| 551 |
+
travel_tube = _build_path_tube(moves, radius=max(travel_width, 0.05) / 2.0, kind="travel")
|
| 552 |
+
path = _path_arrays(moves)
|
| 553 |
+
|
| 554 |
+
px = [v + x_off for v in print_tube["x"]]
|
| 555 |
+
tx = [v + x_off for v in travel_tube["x"]]
|
| 556 |
+
path_x = [v + x_off for v in path["x"]]
|
| 557 |
+
|
| 558 |
+
if travel_tube["i"]:
|
| 559 |
+
fig.add_trace(
|
| 560 |
+
go.Mesh3d(
|
| 561 |
+
x=tx, y=travel_tube["y"], z=travel_tube["z"],
|
| 562 |
+
i=travel_tube["i"], j=travel_tube["j"], k=travel_tube["k"],
|
| 563 |
+
color=color, opacity=travel_opacity, name=f"Travel {idx}",
|
| 564 |
+
showlegend=False, hoverinfo="skip",
|
| 565 |
+
lighting=dict(ambient=0.6, diffuse=0.8, specular=0.1, roughness=0.6),
|
| 566 |
+
)
|
| 567 |
+
)
|
| 568 |
+
if print_tube["i"]:
|
| 569 |
+
fig.add_trace(
|
| 570 |
+
go.Mesh3d(
|
| 571 |
+
x=px, y=print_tube["y"], z=print_tube["z"],
|
| 572 |
+
i=print_tube["i"], j=print_tube["j"], k=print_tube["k"],
|
| 573 |
+
color=color, opacity=print_opacity, name=f"Shape {idx}",
|
| 574 |
+
showlegend=True, hoverinfo="skip",
|
| 575 |
+
lighting=dict(ambient=0.55, diffuse=0.8, specular=0.15, roughness=0.6),
|
| 576 |
+
)
|
| 577 |
+
)
|
| 578 |
+
fig.add_trace(
|
| 579 |
+
go.Scatter3d(
|
| 580 |
+
x=[path_x[-1]], y=[path["y"][-1]], z=[path["z"][-1]],
|
| 581 |
+
mode="markers", name=f"Nozzle {idx}",
|
| 582 |
+
marker=dict(size=4, color=color), showlegend=False, hoverinfo="skip",
|
| 583 |
+
)
|
| 584 |
+
)
|
| 585 |
+
|
| 586 |
+
part_total = path["t"][-1] if path["t"] else 0.0
|
| 587 |
+
total_length = max(total_length, part_total)
|
| 588 |
+
anim_parts.append({
|
| 589 |
+
"printName": f"Shape {idx}",
|
| 590 |
+
"travelName": f"Travel {idx}",
|
| 591 |
+
"nozzleName": f"Nozzle {idx}",
|
| 592 |
+
"print_face_t": print_tube["face_t"],
|
| 593 |
+
"travel_face_t": travel_tube["face_t"],
|
| 594 |
+
"path_x": path_x, "path_y": path["y"], "path_z": path["z"], "path_t": path["t"],
|
| 595 |
+
})
|
| 596 |
+
|
| 597 |
+
bx0 = min(bx0, pxmin + x_off); bx1 = max(bx1, pxmax + x_off)
|
| 598 |
+
by0 = min(by0, pymin); by1 = max(by1, pymax)
|
| 599 |
+
bz0 = min(bz0, pzmin); bz1 = max(bz1, pzmax)
|
| 600 |
+
|
| 601 |
+
if not anim_parts:
|
| 602 |
+
fig.update_layout(height=700)
|
| 603 |
+
return fig
|
| 604 |
+
|
| 605 |
+
pad = max(bx1 - bx0, by1 - by0, bz1 - bz0, 1.0) * 0.05
|
| 606 |
+
fig.update_layout(
|
| 607 |
+
meta={"animation": {"total_length": total_length, "parts": anim_parts}},
|
| 608 |
+
height=700,
|
| 609 |
+
uirevision="parallel",
|
| 610 |
+
scene=dict(
|
| 611 |
+
xaxis_title="X (mm)", yaxis_title="Y (mm)", zaxis_title="Z (mm)",
|
| 612 |
+
xaxis_range=[bx0 - pad, bx1 + pad],
|
| 613 |
+
yaxis_range=[by0 - pad, by1 + pad],
|
| 614 |
+
zaxis_range=[bz0 - pad, bz1 + pad],
|
| 615 |
+
aspectmode="data",
|
| 616 |
+
),
|
| 617 |
+
margin=dict(l=0, r=0, t=30, b=0),
|
| 618 |
+
legend=dict(orientation="h", yanchor="bottom", y=1.0, xanchor="left", x=0.0),
|
| 619 |
+
title=f"Parallel print — {len(anim_parts)} part(s)",
|
| 620 |
+
)
|
| 621 |
+
return fig
|
| 622 |
+
|
| 623 |
+
|
| 624 |
def render_gcode_file(path: str | Path) -> tuple[go.Figure, dict]:
|
| 625 |
text = Path(path).read_text()
|
| 626 |
parsed = parse_gcode_path(text)
|