Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions multiace/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -605,7 +605,23 @@
"tier_name_base": "Name·Grundton",
"tier_name_canon": "Name·Synonym",
"tier_no_slot": "kein Slot ⚠",
"title": "G-Code Preflight"
"title": "G-Code Preflight",
"est_title": "Schätzung",
"est_badge": "geschätzt",
"est_conf_degraded": "unvollständige Daten",
"est_conf_calibrated": "kalibriert",
"est_updating": "wird aktualisiert...",
"est_slicer": "Slicer-Zeit",
"est_total": "Mit multiACE",
"est_added": "Durch Wechsel hinzugefügt",
"est_filament": "Filament",
"est_purge": "Spülung",
"est_no_window": "ohne bekanntes Zeitfenster",
"est_timeline": "Wechsel-Zeitleiste",
"est_colour": "Farbe",
"est_print": "Druck",
"est_sum": "Gesamt",
"est_assumptions": "Annahmen"
},
"queue": {
"clear_errors": "Fehler löschen",
Expand Down Expand Up @@ -1040,7 +1056,9 @@
"swap_kind": {
"first_load": "Erstes Laden",
"same_ace": "Gleiche MMU",
"cross_ace_inline": "MMU-übergreifend"
"cross_ace_inline": "MMU-übergreifend",
"cross_ace_bg": "Hintergrund",
"head_change": "Kopfwechsel"
}
}
},
Expand Down
22 changes: 20 additions & 2 deletions multiace/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,23 @@
"tier_name_base": "name·base",
"tier_name_canon": "name·synonym",
"tier_no_slot": "no slot ⚠",
"title": "G-code preflight"
"title": "G-code preflight",
"est_title": "Estimate",
"est_badge": "estimated",
"est_conf_degraded": "partial data",
"est_conf_calibrated": "calibrated",
"est_updating": "updating...",
"est_slicer": "Slicer time",
"est_total": "With multiACE",
"est_added": "Added by swaps",
"est_filament": "Filament",
"est_purge": "Purge",
"est_no_window": "without a known window",
"est_timeline": "Swap timeline",
"est_colour": "Colour",
"est_print": "Print",
"est_sum": "Total",
"est_assumptions": "Assumptions"
},
"queue": {
"clear_errors": "Clear errors",
Expand Down Expand Up @@ -1039,7 +1055,9 @@
"swap_kind": {
"first_load": "First load",
"same_ace": "Same MMU",
"cross_ace_inline": "Cross-MMU"
"cross_ace_inline": "Cross-MMU",
"cross_ace_bg": "Background",
"head_change": "Head change"
}
}
},
Expand Down
22 changes: 20 additions & 2 deletions multiace/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,23 @@
"not_feasible": "不可行",
"slicer": "切片",
"printer": "打印机",
"match_tier": "匹配"
"match_tier": "匹配",
"est_title": "预估",
"est_badge": "估算值",
"est_conf_degraded": "数据不完整",
"est_conf_calibrated": "已校准",
"est_updating": "更新中...",
"est_slicer": "切片软件时间",
"est_total": "使用 multiACE",
"est_added": "换料增加",
"est_filament": "耗材",
"est_purge": "冲洗",
"est_no_window": "窗口未知",
"est_timeline": "换料时间线",
"est_colour": "颜色",
"est_print": "打印",
"est_sum": "合计",
"est_assumptions": "假设"
},
"queue": {
"send_all_tooltip": "将所有排队命令发送到打印机(之后可以关闭界面)",
Expand Down Expand Up @@ -335,7 +351,9 @@
"swap_kind": {
"first_load": "首次装载",
"same_ace": "同一 MMU",
"cross_ace_inline": "跨 MMU"
"cross_ace_inline": "跨 MMU",
"cross_ace_bg": "后台",
"head_change": "喷头切换"
}
}
},
Expand Down
74 changes: 73 additions & 1 deletion multiace/web/frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -6895,6 +6895,7 @@ createApp({
preflight.slicerSwaps = realSwapCount(
(preflight.report && preflight.report.events) || [],
_slicerEffectiveMapping());
scheduleEstimate("slicer");
}
function slicerSwapsDisplay() {
if (preflight.slicerSwaps !== null) return preflight.slicerSwaps;
Expand Down Expand Up @@ -7341,6 +7342,7 @@ createApp({
preflight.headSwaps = headSwapCount(
(preflight.report && preflight.report.events) || [],
_headEffectiveAssignment(), headCopiesEffective());
scheduleEstimate("loadout");
}
function headSwapsDisplay() {
if (preflight.headSwaps !== null) return preflight.headSwaps;
Expand Down Expand Up @@ -7811,6 +7813,76 @@ createApp({
for (const p of list) if (pfPlanSwaps(p) < pfPlanSwaps(best)) best = p;
return best === list[0] ? null : best;
}
// ---- estimate card ----------------------------------------------------
// Every number is a model estimate (never measured). A plan's estimate
// comes with the report; after a mapping edit the browser worker
// recomputes it for the plan being edited (pfEstLive), without a re-parse.
const pfEstLive = reactive({});
let pfEstTimer = null;
let pfEstSeq = 0;
function pfEst(p) {
const live = pfEstLive[p];
if (live && live.estimate) return live.estimate;
const pl = preflight.report && preflight.report.plans && preflight.report.plans[p];
return (pl && pl.estimate) || null;
}
function pfTimeline(p) {
const live = pfEstLive[p];
if (live && live.timeline) return live.timeline;
const pl = preflight.report && preflight.report.plans && preflight.report.plans[p];
return (pl && pl.timeline) || [];
}
function pfEstDelta(e) {
return (e && e.added_s !== null && e.added_s !== undefined) ? "+" + fmtDuration(e.added_s) : "";
}
// Per-kind share of the added time, for the timeline strip.
function pfTimelineSegs(p) {
const evs = pfTimeline(p).filter(ev => ev.kind !== "first_load" && Number(ev.seconds) > 0);
const total = evs.reduce((a, ev) => a + (Number(ev.seconds) || 0), 0) || 1;
return evs.map((ev, i) => ({
key: i, kind: ev.kind, t: ev.t,
pct: Math.max(0.4, 100 * (Number(ev.seconds) || 0) / total),
tip: "T" + ev.t + " - " + swapKindLabel(ev.kind)
+ " - " + fmtDuration(ev.seconds)
+ (ev.purge_mm ? " - " + Math.round(ev.purge_mm) + " mm" : ""),
}));
}
function pfEstCounts(e) {
const parts = [];
if (e.inline_swaps) parts.push(swapKindLabel("cross_ace_inline") + " x " + e.inline_swaps);
if (e.bg_swaps) parts.push(swapKindLabel("cross_ace_bg") + " x " + e.bg_swaps);
if (e.head_changes) parts.push(swapKindLabel("head_change") + " x " + e.head_changes);
return parts.join(" · ");
}
// Live recompute after a mapping/target edit. Only the in-browser path
// can do it (the server has no endpoint for it); otherwise the numbers
// keep showing the analysed assignment, flagged stale.
const pfEstStale = ref(false);
function scheduleEstimate(plan) {
if (!preflight.local || !preflightJobId) return;
pfEstStale.value = true;
clearTimeout(pfEstTimer);
pfEstTimer = setTimeout(async () => {
const seq = ++pfEstSeq;
const payload = {jobId: preflightJobId, plan};
if (plan === "slicer") {
payload.mapping = _slicerEffectiveMapping();
} else {
payload.targetIds = _headEffectiveAssignment();
payload.copyIds = headCopiesEffective();
}
try {
const j = await runPreflightWorker("estimate", payload);
if (seq !== pfEstSeq) return;
if (j.result) pfEstLive[plan] = j.result;
pfEstStale.value = false;
} catch (_) { /* keep the analysed numbers */ }
}, 200);
}
watch(() => preflight.report, () => {
for (const k of Object.keys(pfEstLive)) delete pfEstLive[k];
pfEstStale.value = false;
});
function pfPlanTitle(p) {
return t(preflight.report && preflight.report.head_mode
? "ui.preflight.head_plan_" + p : "ui.preflight.plan_" + p);
Expand Down Expand Up @@ -8837,7 +8909,7 @@ createApp({
headTargets, headTargetOptions, headEffectiveTargetId, headTargetLabel,
headTargetColor, chipBg, headTargetLabelById, onHeadTargetChange, headSwapsDisplay,
hmDropOpen, hmDdToggle, hmDdClose, hmDdPick,
pfPlanSel, pfPlanList, pfPlanFeasible, pfPlanSwaps, pfPlanFlush, pfPlanBg, pfSelected, pfRecommended, pfPlanTitle, pfGoLabel, pfPrintSelected,
pfPlanSel, pfEst, pfTimelineSegs, pfEstCounts, pfEstDelta, pfEstStale, pfPlanList, pfPlanFeasible, pfPlanSwaps, pfPlanFlush, pfPlanBg, pfSelected, pfRecommended, pfPlanTitle, pfGoLabel, pfPrintSelected,
headFeasible, headPlanFeasible, headPlanSwaps, headPlanBg, headPlanFlushG, headPlanBgLabel, headSlicerHex,
headSlicerMat, headProposalLabel,
headMaxCopies, headCopiesEffective, headCopyPlan, headCopyList, copySlotLabel, setPreflightCopies,
Expand Down
64 changes: 64 additions & 0 deletions multiace/web/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3679,6 +3679,37 @@ <h4>{{ t('ui.preflight.slicer_colors') }}</h4>
design; repeat it for the selected plan under the cards. -->
<p v-if="uiTheme === 'new' && pfSelected() && pfPlanFeasible(pfSelected()) && pfPlanBg(pfSelected())"
class="muted small nt-pf-bg">{{ headPlanBgLabel(pfSelected()) }}</p>
<div v-if="uiTheme === 'new' && pfSelected() && pfPlanFeasible(pfSelected()) && pfEst(pfSelected())"
class="pf-est" :class="{stale: pfEstStale}">
<div class="pf-est-head">
<b>{{ t('ui.preflight.est_title') }}</b>
<span class="pf-est-tag">{{ t('ui.preflight.est_badge') }}</span>
<span v-if="pfEst(pfSelected()).confidence !== 'modelled'" class="pf-est-tag warn">{{ t('ui.preflight.est_conf_' + pfEst(pfSelected()).confidence) }}</span>
<span v-if="pfEstStale" class="muted small">{{ t('ui.preflight.est_updating') }}</span>
</div>
<div class="pf-est-nums">
<span v-if="pfEst(pfSelected()).base_s !== null"><small>{{ t('ui.preflight.est_slicer') }}</small><b>{{ fmtDuration(pfEst(pfSelected()).base_s) }}</b></span>
<span v-if="pfEst(pfSelected()).total_s !== null"><small>{{ t('ui.preflight.est_total') }}</small><b>{{ fmtDuration(pfEst(pfSelected()).total_s) }}</b><i>{{ pfEstDelta(pfEst(pfSelected())) }}</i></span>
<span v-else><small>{{ t('ui.preflight.est_added') }}</small><b>{{ pfEstDelta(pfEst(pfSelected())) }}</b></span>
<span><small>{{ t('ui.preflight.est_filament') }}</small><b>{{ pfEst(pfSelected()).totals.g }}<u>g</u></b><i>{{ pfEst(pfSelected()).totals.m }} m</i></span>
<span><small>{{ t('ui.preflight.est_purge') }}</small><b>{{ pfEst(pfSelected()).purge.g }}<u>g</u></b><i>{{ pfEst(pfSelected()).purge.mm }} mm</i></span>
</div>
<p v-if="pfEstCounts(pfEst(pfSelected()))" class="muted small pf-est-counts">{{ pfEstCounts(pfEst(pfSelected())) }}<template v-if="pfEst(pfSelected()).unknown_window_swaps"> · {{ pfEst(pfSelected()).unknown_window_swaps }} {{ t('ui.preflight.est_no_window') }}</template></p>
<div v-if="pfTimelineSegs(pfSelected()).length" class="pf-est-tl" :aria-label="t('ui.preflight.est_timeline')">
<span v-for="sg in pfTimelineSegs(pfSelected())" :key="sg.key" class="pf-tl-seg" :class="'k-' + sg.kind"
:style="{flexBasis: sg.pct + '%'}" :title="sg.tip"></span>
</div>
<table v-if="pfEst(pfSelected()).per_color && pfEst(pfSelected()).per_color.length" class="pf-est-tbl">
<thead><tr><th>{{ t('ui.preflight.est_colour') }}</th><th>{{ t('ui.preflight.est_print') }}</th><th>{{ t('ui.preflight.est_purge') }}</th><th>{{ t('ui.preflight.est_sum') }}</th></tr></thead>
<tbody><tr v-for="c in pfEst(pfSelected()).per_color" :key="'ec-' + c.t">
<td>T{{ dispIdx(c.t) }}</td><td>{{ c.print_g }} g</td><td>{{ c.purge_g }} g</td><td>{{ (c.print_g + c.purge_g).toFixed(1) }} g</td>
</tr></tbody>
</table>
<details v-if="pfEst(pfSelected()).assumptions && pfEst(pfSelected()).assumptions.length" class="pf-est-ass">
<summary>{{ t('ui.preflight.est_assumptions') }}</summary>
<ul><li v-for="(a, i) in pfEst(pfSelected()).assumptions" :key="'as-' + i">{{ a }}</li></ul>
</details>
</div>
<template v-for="mode in ['slicer', 'optimize', 'layer']"
:key="'plan-' + mode">
<div v-if="preflight.report.plans[mode]" class="preflight-plan"
Expand All @@ -3689,6 +3720,7 @@ <h4>{{ t('ui.preflight.plan_' + mode) }}</h4>
class="muted small">
{{ mode === 'slicer' ? slicerSwapsDisplay() : preflight.report.plans[mode].swaps }}
{{ t('ui.preflight.swaps_label') }}
<span v-if="uiTheme === 'classic' && pfEst(mode) && pfEst(mode).total_s !== null">· ≈ {{ fmtDuration(pfEst(mode).total_s) }} ({{ pfEstDelta(pfEst(mode)) }}, {{ t('ui.preflight.est_badge') }})</span>
</span>
<span v-else class="preflight-infeasible">
{{ t('ui.preflight.not_feasible') }}
Expand Down Expand Up @@ -3850,6 +3882,37 @@ <h4>{{ t('ui.preflight.copies_title') }}</h4>
design; repeat it for the selected plan under the cards. -->
<p v-if="uiTheme === 'new' && pfSelected() && pfPlanFeasible(pfSelected()) && pfPlanBg(pfSelected())"
class="muted small nt-pf-bg">{{ headPlanBgLabel(pfSelected()) }}</p>
<div v-if="uiTheme === 'new' && pfSelected() && pfPlanFeasible(pfSelected()) && pfEst(pfSelected())"
class="pf-est" :class="{stale: pfEstStale}">
<div class="pf-est-head">
<b>{{ t('ui.preflight.est_title') }}</b>
<span class="pf-est-tag">{{ t('ui.preflight.est_badge') }}</span>
<span v-if="pfEst(pfSelected()).confidence !== 'modelled'" class="pf-est-tag warn">{{ t('ui.preflight.est_conf_' + pfEst(pfSelected()).confidence) }}</span>
<span v-if="pfEstStale" class="muted small">{{ t('ui.preflight.est_updating') }}</span>
</div>
<div class="pf-est-nums">
<span v-if="pfEst(pfSelected()).base_s !== null"><small>{{ t('ui.preflight.est_slicer') }}</small><b>{{ fmtDuration(pfEst(pfSelected()).base_s) }}</b></span>
<span v-if="pfEst(pfSelected()).total_s !== null"><small>{{ t('ui.preflight.est_total') }}</small><b>{{ fmtDuration(pfEst(pfSelected()).total_s) }}</b><i>{{ pfEstDelta(pfEst(pfSelected())) }}</i></span>
<span v-else><small>{{ t('ui.preflight.est_added') }}</small><b>{{ pfEstDelta(pfEst(pfSelected())) }}</b></span>
<span><small>{{ t('ui.preflight.est_filament') }}</small><b>{{ pfEst(pfSelected()).totals.g }}<u>g</u></b><i>{{ pfEst(pfSelected()).totals.m }} m</i></span>
<span><small>{{ t('ui.preflight.est_purge') }}</small><b>{{ pfEst(pfSelected()).purge.g }}<u>g</u></b><i>{{ pfEst(pfSelected()).purge.mm }} mm</i></span>
</div>
<p v-if="pfEstCounts(pfEst(pfSelected()))" class="muted small pf-est-counts">{{ pfEstCounts(pfEst(pfSelected())) }}<template v-if="pfEst(pfSelected()).unknown_window_swaps"> · {{ pfEst(pfSelected()).unknown_window_swaps }} {{ t('ui.preflight.est_no_window') }}</template></p>
<div v-if="pfTimelineSegs(pfSelected()).length" class="pf-est-tl" :aria-label="t('ui.preflight.est_timeline')">
<span v-for="sg in pfTimelineSegs(pfSelected())" :key="sg.key" class="pf-tl-seg" :class="'k-' + sg.kind"
:style="{flexBasis: sg.pct + '%'}" :title="sg.tip"></span>
</div>
<table v-if="pfEst(pfSelected()).per_color && pfEst(pfSelected()).per_color.length" class="pf-est-tbl">
<thead><tr><th>{{ t('ui.preflight.est_colour') }}</th><th>{{ t('ui.preflight.est_print') }}</th><th>{{ t('ui.preflight.est_purge') }}</th><th>{{ t('ui.preflight.est_sum') }}</th></tr></thead>
<tbody><tr v-for="c in pfEst(pfSelected()).per_color" :key="'ec-' + c.t">
<td>T{{ dispIdx(c.t) }}</td><td>{{ c.print_g }} g</td><td>{{ c.purge_g }} g</td><td>{{ (c.print_g + c.purge_g).toFixed(1) }} g</td>
</tr></tbody>
</table>
<details v-if="pfEst(pfSelected()).assumptions && pfEst(pfSelected()).assumptions.length" class="pf-est-ass">
<summary>{{ t('ui.preflight.est_assumptions') }}</summary>
<ul><li v-for="(a, i) in pfEst(pfSelected()).assumptions" :key="'as-' + i">{{ a }}</li></ul>
</details>
</div>
<template v-for="hp in ['loadout', 'optimize', 'layer', 'color']"
:key="'hplan-'+hp">
<div v-if="preflight.report.plans[hp]" class="preflight-plan"
Expand All @@ -3860,6 +3923,7 @@ <h4>{{ t('ui.preflight.head_plan_' + hp) }}</h4>
{{ headPlanSwaps(hp) }}<template v-if="hp === 'loadout' && headCopyPlan().suggestions.length"> → {{ headCopyPlan().swaps_after }}</template> {{ t('ui.preflight.swaps_label') }}
<span v-if="headPlanBg(hp)">· {{ headPlanBgLabel(hp) }}</span>
<span v-if="headPlanFlushG(hp) !== null">· {{ headPlanFlushG(hp) }} {{ t('ui.preflight.flush_g_label') }}</span>
<span v-if="uiTheme === 'classic' && pfEst(hp) && pfEst(hp).total_s !== null">· ≈ {{ fmtDuration(pfEst(hp).total_s) }} ({{ pfEstDelta(pfEst(hp)) }}, {{ t('ui.preflight.est_badge') }})</span>
</span>
<span v-else class="preflight-infeasible">
{{ t('ui.preflight.not_feasible') }}
Expand Down
27 changes: 27 additions & 0 deletions multiace/web/frontend/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -2309,3 +2309,30 @@ body:has(.panel) .overlay .dialog {
.devices-table tr.dev-free td { color: var(--muted); font-style: italic; }
.restart-hint { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; color: var(--accent); font-size: .9rem; }
.devices-table input.dev-name { width: 9rem; max-width: 100%; padding: .2rem .4rem; font-size: .9rem; }

/* preflight estimate card (every theme: colours come from the shared tokens) */
.pf-est { margin: 0 0 14px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 6px; background: var(--card, transparent); }
.pf-est.stale .pf-est-nums { opacity: .55; }
.pf-est-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.pf-est-tag { font-size: 11px; padding: 0 6px; border: 1px solid var(--border); border-radius: 3px; color: var(--muted); }
.pf-est-tag.warn { color: var(--warn, #d9a441); border-color: currentColor; }
.pf-est-nums { display: flex; flex-wrap: wrap; gap: 8px 26px; }
.pf-est-nums > span { display: flex; flex-direction: column; }
.pf-est-nums small { font-size: 11.5px; color: var(--muted); }
.pf-est-nums b { font-size: 22px; font-weight: 600; line-height: 1.15; }
.pf-est-nums b u { text-decoration: none; font-size: 13px; font-weight: 400; color: var(--muted); margin-left: 2px; }
.pf-est-nums i { font-style: normal; font-size: 12px; color: var(--muted); }
.pf-est-counts { margin: 8px 0 0; }
.pf-est-tl { display: flex; gap: 1px; height: 10px; margin: 10px 0; border-radius: 3px; overflow: hidden; background: var(--border); }
.pf-tl-seg { min-width: 2px; background: var(--muted); opacity: .8; }
.pf-tl-seg.k-cross_ace_inline { background: var(--warn, #d9a441); }
.pf-tl-seg.k-same_ace { background: var(--muted); }
.pf-tl-seg.k-cross_ace_bg { background: var(--ok, #4caf7d); }
.pf-tl-seg.k-head_change { background: var(--accent, #4a90d9); }
.pf-tl-seg.k-first_load { background: var(--border); }
.pf-est-tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; margin-top: 6px; }
.pf-est-tbl th { text-align: left; font-weight: 400; color: var(--muted); padding: 2px 8px 2px 0; }
.pf-est-tbl td { padding: 2px 8px 2px 0; }
.pf-est-ass { margin-top: 8px; font-size: 12px; color: var(--muted); }
.pf-est-ass summary { cursor: pointer; }
.pf-est-ass ul { margin: 6px 0 0 18px; padding: 0; }