diff --git a/.gitignore b/.gitignore index 695cb8f3b33..7bb24ce4951 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,6 @@ resources/profiles/user/default deps_src/build/ .claude/ .hermes/ -CLAUDE.md \ No newline at end of file +CLAUDE.md +# UI reference snapshots — third-party imagery, design reference only (see the folder's README) +ui-snapshots-inspiration/ diff --git a/docs/ace-mmu/15-printer-panel.md b/docs/ace-mmu/15-printer-panel.md new file mode 100644 index 00000000000..da010035d65 --- /dev/null +++ b/docs/ace-mmu/15-printer-panel.md @@ -0,0 +1,128 @@ +# The U1 printer panel (Prepare / Preview) + +Interactive mockup: [printer-panel-mockup.html](printer-panel-mockup.html) · +Visual standard: [16-ace-visuals.md](16-ace-visuals.md) + +> **Status: design only. No code has landed.** This branch carries the docs and mockups; +> every implementation step below is still to do. The ACE machinery referenced here — +> `ace_head_capacity` / `ace_head_unit`, `AceMmuProvider`, `sync_ace_topology`, +> `AceMmuPlan` — lives on `feat/ace-mmu-slicing` and does not exist on +> `develop/add-multiace-support` yet. Start from [NEXT.md](NEXT.md). + +**Prepare and Preview share one sidebar.** The Printer section at the top of it is a +single widget seen in two tabs, so this is one change in two places — and anything +added to it is also on screen while reading a sliced preview, where vertical space is +contested. + +## What is wrong today + +Measured by running the app, not by reading it (`.claude/tools/start.sh headless`). + +| # | Defect | Where | +|---|--------|-------| +| 1 | **Four tabs hold one control each.** `Nozzle 1..4`, each with a `Diameter` combo; changing any one writes all four and switches the whole preset, because the U1 refuses mixed diameters. Three tabs exist to be clicked and show nothing new. | `Sidebar::update_nozzle_settings`, Plater.cpp:8682 | +| 2 | **Three names for one thing.** Sidebar says *Nozzle 1–4*, Printer Settings says *Toolhead 1–4*, the assignment dialog says *T1–T4*. | Plater.cpp:8814, Tab.cpp:4808, `resources/web/aceplan` | +| 3 | **No topology.** The preset knows head 4 is fed by ACE 1 with four slots (`ace_head_capacity`, `ace_head_unit`); the panel never says so. | — | +| 4 | **No contents, and no way to ask.** The spool colours at each head are known when the printer is on the LAN — they already drive the filament sync — but never reach this panel. | `append_ace_filament_list`, Plater.cpp:773 | +| 5 | **Two half-syncs.** The sidebar glyph syncs nozzle diameters only; multiACE topology sync is a separate button buried in Printer Settings › Multimaterial. | Plater.cpp:2214, Tab.cpp:4652 | + +## The shape: a mature multi-head panel, for four heads + +Taken piece by piece from the reference slicer this panel is modelled on - a two-nozzle +machine's printer section, which solves most of the same problems already. + +- **Three cards across the top** — printer (thumbnail over preset combo), plate + (texture swatch + ⓘ, absorbing today's `Bed type` row), and **Sync info**. +- **A green corner tick** on any card that agrees with the connected machine. On a head + box it means the ACE wiring matches what the printer reports — a claim we can make, + because `sync_ace_topology` already computes exactly that diff. +- **A bordered box per head**, the reference's Left/Right Nozzle panes wrapped **2×2**: an + `ACE` row (badge when a unit feeds it, `Stock feeder` otherwise, adjust button always) + and a `Diameter` row. No `Flow` row — this fork deleted the control and there is no + setting behind it. +- **One `Nozzle` row**, not four tabs. Per-head diameters return only if the U1 ever + allows mixed sets; a machine reporting differing diameters still routes to the + existing `NozzleDiameterSelectDialog`. +- **Sync in two steps** — *Successfully synchronized nozzle, ACE mode and ACE unit + information* → **[Continue to sync filaments] [Cancel]**, anchored over the viewport. + Step two opens the existing `Sidebar::show_sync_filament_dialog`. + +## ACE mode — the printer's own switch + +Verified against firmware (`/printer/gcode/help` on 192.168.2.242): + +``` +SET_ACE_MODE MODE=normal|multi|head [HEAD=n] +ACE_SET_HEAD_ACE HEAD=0..3 ACE=0..3 "each ACE head is wired to exactly one ACE" +ACE_SET_HEAD_FEEDER HEAD=0..3 ENABLE=0|1 "(head mode only)" +``` + +The panel mirrors it as a labelled dropdown — **Normal** / **Per toolhead** / +**Combined** — with the raw `SET_ACE_MODE MODE=…` in the tooltip. Only in **head** mode +does per-toolhead wiring mean anything, so the head boxes grey their ACE rows in Normal. +Sync info reads the mode back along with the wiring. + +## The assign popover + +A **choice**, not a count: the firmware offers exactly two macros, so the list has +exactly two kinds of row and a tick rather than a spinner. + +``` +Which ACE feeds Toolhead 4? + ⬡ Stock feeder One spool, loaded at the head ( ) + ▤ ACE 1 · ACE 2 Pro 4 slots · connected · 39% RH (✓) +``` + +Units are named as the printer names them — `protocol: "v2"` → **ACE 2 Pro**, `"v1"` → +**ACE Pro** — the same mapping `resources/web/multiace/index.html` already uses. Between +them the rows write exactly `ace_head_capacity` and `ace_head_unit`. + +**One unit may feed several heads.** `ACE_SET_HEAD_ACE` binds a head to one ACE; it says +nothing about an ACE feeding one head, and `head_ace` is a map from head to unit. Ticking +the same unit on a second head is therefore legal, each row then reads *also feeds +Toolhead N*, and a warning states the real capacity. + +## Two defects this uncovered + +1. **A shared unit double-counts capacity.** `AceMmuPlan.hpp` sums capacity per head + (`total_cap += cap[h]`) and enforces it per head (`if (++load[h] > cap[h])`). Two + heads on one 4-slot unit read as **8 places** when there are 4, so the + infeasible-plate refusal — whose whole purpose is catching this — would pass a plate + that cannot be laid out. Needs a per-unit pool constraint beside the per-head one. +2. **Combined mode cannot be emitted.** `ace_head_capacity` already offers *6 slots* and + *8 slots*, but `GCode.cpp`'s `unit_of_head()` returns the single `ace_head_unit[h]` + and the plan's slot is an index *within the head*. Slot 5 of an 8-slot head emits + `ACE= SLOT=5` where the machine needs `ACE= SLOT=1` — wrong + unit, wrong slot, wrong colour. Those enum values are unsafe until the emitter maps + slot → (unit, slot). + +## Touch points + +| Piece | Where | State | +|-------|-------|-------| +| The panel | `Plater.cpp:2199–2565` | Title bar, preset card, Bed type row, nozzle notebook — built once in the Sidebar constructor | +| The head boxes | `Sidebar::update_nozzle_settings`, `Plater.cpp:8682` | Rebuilds one page per `nozzle_diameter` entry; becomes a 2×2 grid | +| Sync, nozzles | `Plater.cpp:2214–2350` | Queries the machine, `NozzleDiameterSelectDialog` on mixed diameters. Keep; becomes half the press | +| Sync, topology | `TabPrinter::sync_ace_topology`, `Tab.cpp:4652` | Reads `/multiace/api/state`, diffs per head, reports. Lift out of `TabPrinter` so the sidebar can call it | +| Sync, filaments | `Sidebar::show_sync_filament_dialog`, `Plater.cpp:8467` | Already lists U1 toolheads *and* ACE slots. What *Continue to sync filaments* opens | +| Topology | `ace_head_capacity`, `ace_head_unit` | Per-head `coInts` in the printer preset — the panel works with the printer off | +| Live contents | `AceMmuProvider`, `AceSnapshot` | Units, slots, colours, humidity, per-head bindings. One `fetch_once()` | + +**Before building:** the U1 connects as a `PrintHost` through the webview, not as a +`MachineObject`, so this panel cannot lean on `MachineObject::poll_ace_ams()`. It +resolves a host with `AceMmuProvider::resolve_connected_host()` and reads on demand — +the other reason press-to-sync fits the U1 better than a background poll. + +## Shipping order + +Each is one branch off `develop/add-multiace-support`, one squashed PR. + +1. **Panel structure** — three cards, 2×2 head boxes, one Nozzle row, `Toolhead N` + naming, plate card absorbing Bed type. No ACE; no new config. Self-contained. +2. **Topology in the preset** — `ace_head_capacity` / `ace_head_unit`, the Multimaterial + settings page, `sync_ace_topology`. +3. **The panel's ACE row** — badge, assign popover, ACE mode dropdown, on top of 1 + 2. +4. **Sync info, two-step** — nozzle + topology in one press, chaining to the filament sync. +5. **Per-unit capacity pool** — the shared-unit fix. Before any user can share a unit. +6. **Combined mode** — the emitter's slot → (unit, slot) map. Until then the mode is + listed and disabled, with the reason on it. diff --git a/docs/ace-mmu/16-ace-visuals.md b/docs/ace-mmu/16-ace-visuals.md new file mode 100644 index 00000000000..ccddcc102aa --- /dev/null +++ b/docs/ace-mmu/16-ace-visuals.md @@ -0,0 +1,115 @@ +# The ACE visual standard + +Interactive sheet: [ace-visual-standard.html](ace-visual-standard.html) + +An ACE was drawn four different ways. The device page gave it 60 px circular spools; the +assignment dialog 96 px cards in a teal-banded box; the printer panel 7×15 px bars; and +the filament-mapping popup — which reads the ACE through `amsList` — **Orca's real AMS +widgets**. Four languages for one object. + +That last one is the way out. The ACE is already projected onto `Ams`/`AmsTray`, so the +AMS widgets are the standard, and **the geometry below is lifted from +`src/slic3r/GUI/Widgets/AMSItem.hpp` rather than invented** — which is what stops it +drifting again. + +## The three forms + +One treatment — **outlined chassis, solid bays, one stroke weight** — in three +proportions. Four bays is not a variable: `/api/state` returns `"slots": [/* exactly 4 */]` +and `SLOT_COUNT = 4` is a constant. There is no single-bay form; an AMS Lite has one +spool, an ACE never does. + +| Form | Size | Where | Function | +|------|------|-------|----------| +| **Badge** | 44×26 fill | a head box | `ace_badge()` | +| **Glyph** | 44×26 line, stroke 1.6 | a popover row, a label | `ace_glyph()` | +| **Glyph, square** | 24×24 line, stroke 1.6 | a tab, a menu, a `ScalableButton` | `ace_glyph_square()` | + +**Badge** — hood, four bays, base drawn *over* them; the base is slightly wider than the +hood, which is what makes it read as a cabinet rather than a bar chart. Bays are 5×14 +capsules at x 6/15/24/33 — **padding 4 = gap 4**, matching the proportions of the reference's +own icon. Colour and emptiness are all that survive at this size, so the badge carries +colour only; an empty bay is white against the grey hood, with no outline. Trust and +staleness live wherever the badge is a control. + +**Glyph** — the badge's own silhouette in line: one stepped path, hood shoulders on top, +base stepping out at the bottom, bays filled. + +**Glyph, square** — body and four bays, no hood or base step. It is deliberately *not* +the same silhouette: the family is carried by the bay treatment and the stroke, because +the square has a third of the width to say the same thing in. If the two ever sit side by +side and the mismatch shows, the fallback is the wide drawing letterboxed into the square. + +## The box the spools sit in + +Orca's AMS already owns the neutrals, and uses them for exactly these roles: + +- `AMS_CONTROL_DEF_BLOCK_BK_COLOUR` **#EEEEEE** — the band, and an empty tube +- `AMS_CONTROL_DEF_LIB_BK_COLOUR` **#F8F8F8** — the box the tubes stand in +- `AMS_CONTROL_BRAND_COLOUR` **#009688** — hover, 2 px +- `AMS_CONTROL_DISABLE_COLOUR` **#CECECE** — a unit configured but not answering + +All go through `StateColor::darkModeColorFor`, so dark mode is not a second palette. + +**The spool object is `AMSLib`** — 58×80 (`AMS_CAN_LIB_SIZE`), a well inset by 4, the +filament colour drawn **from the bottom up to how much is left**. Not a swatch on a card: +a level in a tube, so a row reads as an inventory. Selection is 2 px in *the filament's +own colour* (`AMSLib`'s rule); hover is the brand teal. Label ink follows the fill's +luminance, the same `< 0.6` test `AMSLib` uses for its badge. + +## Moisture and temperature + +`AMSHumidity`, unchanged: a pill (radius = half the height) on `#EEEEEE`, the +`hum_level1..5` droplet at 16 px, a 1 px `#C2C2C2` divider, then the dryer glyph +(`ams_drying` / `ams_is_drying`). `AMS_HUMIDITY_SIZE` 93×26 with a percentage, +`AMS_HUMIDITY_NO_PERCENT_SIZE` 60×26 without. + +`AMSinfo` already handles the ACE's exact case: `humidity_raw = -1` selects the numbered +droplet, anything else the plain droplet plus the number. Bucket the raw percentage +1 = ≤20, 2 = ≤35, 3 = ≤50, 4 = ≤65, 5 = >65 to pick the glyph. + +**Temperature is the one addition.** The AMS carries `current_temperature` but never +draws it here; the ACE reports `temp` per unit and it matters while drying. It goes in +the same pill behind a second divider — one chip, not three. Absent when unreported, +never zeroed. + +## Where a level comes from + +An ACE slot has **no remain field**: `/api/state`'s `slots[]` carries material, brand, +colour and source, and nothing about quantity. But a Spoolman-backed printer binds them: + +```jsonc +"spool_mode": "spoolman", +"spool_binding": { "0_0": "15", "0_1": "10", "0_3": "16" }, +"spools": { "15": { "weight_g": 500.1, "used_mm": 0.0, "density": 1.27, ... } } +``` + +So bound slots can drive the column honestly, and an unbound one is drawn full but +hatched and labelled *amount unknown* rather than pretending to be full. + +**What is not available is a percentage.** Spoolman knows the initial weight; this +payload does not, so a column scaled to 1 kg would call an 843 g spool 84% when it may be +a full 850 g one. Show the grams and treat the column as a gauge — or fetch +`remaining_weight` from Spoolman directly and scale it properly, which is its own piece +of work. `AceSlot` parses none of the binding today; wiring it through is the +prerequisite for the column meaning anything. + +## Adoption + +| Surface | Draws now | Becomes | +|---------|-----------|---------| +| Filament mapping popup (`AmsMappingPopup.cpp`) | Orca's AMS widgets, via the `amsList` projection | **Nothing** — it is already the standard, and the reference | +| Device / AMS tab (`AMSControl`, `AmsItem`) | Orca's AMS widgets, fed by the projection | **Nothing**, beyond naming the unit *ACE 2 Pro* rather than *AMS* | +| Printer panel (`Plater.cpp` sidebar) | 7×15 px bars in an ad-hoc strip | **Badge** in the head box | +| Assignment dialog (`resources/web/aceplan`) | 96 px `.pos` cards in a teal `.acebox` | **Spool box** + `AMSLib` columns; keep the drag targets | +| U1 + multiACE page (`resources/web/multiace`) | 60 px circular spools; 36 px circular swatches | **Spool box** + `AMSLib` columns. The circles are the biggest departure and the one worth losing — nothing else in Orca draws filament round | + +**Note on `AMSPreview`.** The 82×27 strip of 14×14 cubes is real, shipping code, but it +has only two call sites — `AMSControl.cpp:1173` (the unit selector) and +`CalibrationWizardPresetPage.cpp:617`. `AMS_ITEM_CUBE_SIZE` appears nowhere else: the +cube is internal to that widget and is never drawn alone. It is documented here so the +two native surfaces are not diverged from, not as a form to build with. + +**One arithmetic snag** if `AMSPreview` is ever reused: padding 7 plus four 14 px cubes +plus three 5 px gaps is 85, not the 82 the constant states. Callers size the preview +themselves today. Fix it once rather than per surface. diff --git a/docs/ace-mmu/17-plate-template.md b/docs/ace-mmu/17-plate-template.md new file mode 100644 index 00000000000..0d8b2f5438f --- /dev/null +++ b/docs/ace-mmu/17-plate-template.md @@ -0,0 +1,118 @@ +# The U1 plate template + +Interactive sheet: [plate-thumbnails-options.html](plate-thumbnails-options.html) · +Used by: [15-printer-panel.md](15-printer-panel.md) (the plate card) + +One silhouette, four plates, two ways of filling it. The same relationship +[16-ace-visuals.md](16-ace-visuals.md) has to the ACE: a shape that is fixed, so every +surface that draws a build plate draws the same object. + +## The silhouette + +**Measured, not traced.** A first attempt was drawn by eye from the product photos and was +wrong in every dimension — deep crenellations where the plate has small nicks. So the +product PNG was decoded to raw pixels (pure Python: `zlib` + manual unfiltering, no imaging +library needed), thresholded against the background, and the top and bottom edges sampled +column by column. + +| Property | Measured | +|----------|----------| +| Plate | 391 × 413 px → **aspect 0.947**, slightly taller than wide | +| Top notches | three, centred at **x 26 / 50 / 74** | +| Notch size | ~**6 wide**, **2.9 deep** | +| Bottom tongue | **x 23 → 77.5**, protruding **2.9** below the side edges | +| Corner radius | **~2** | + +All as percentages of the plate's own box: width 100, height 105.6. **Do not re-trace by +eye.** + +## Drawing it: emphasised, deliberately + +At 44 × 40 — the plate card's thumbnail — a 2.9 % notch is **just over one pixel**. Drawn +truthfully the U1 plate is a rounded square with three nicks nobody can resolve, and the +silhouette contributes nothing. Exaggerating is a normal icon convention, but it is a +decision, so it was taken explicitly rather than arrived at by sloppy tracing. + +Four points along that axis were drawn and compared (*As measured*, *Emphasised*, +*Exaggerated*, *Plain*). **Emphasised** was chosen: the measured notches roughly doubled, +enough to register as a notched plate at a glance without reading as a different object. + +``` +platePath(notchDepth, notchHalfWidth, tongueDepth, cornerRadius) + measured (3.06, 3.0, 3.06, 2) + emphasised (5.5, 3.8, 4.2, 2) ← chosen +``` + +The chosen path, in a `0 0 100 105.6` viewBox: + +``` +M2 0 H22.2 L26 5.50 L29.8 0 H46.2 L50 5.50 L53.8 0 H70.2 L74 5.50 L77.8 0 +H98 A2 2 0 0 1 100 2 V99.40 A2 2 0 0 1 98 101.40 +H79.5 L77 105.60 H23 L20.5 101.40 H2 A2 2 0 0 1 0 99.40 V2 A2 2 0 0 1 2 0 Z +``` + +**Aspect is preserved, never stretched.** The plate is taller than wide and the card slot +is 44 × 40, so the drawing letterboxes. Squashing it would change the notch proportions, +which is the one thing the silhouette exists to carry. + +## Filling it + +**Photograph Snapmaker's own plates. Draw everything else generically.** + +The three plates Snapmaker sells for the U1 are first-party products for their bed types, +so a photograph is simply what the plate looks like. Anything else is drawn: the nearest +real product is somebody else's, and using it would brand a **bed type** — an abstract +setting — with a vendor the app never otherwise mentions. Whoever selects *Cool Steel +Plate* may own any plate, or none. + +| Bed type | Enum | Fill | Source | +|----------|------|------|--------| +| Textured PEI Plate | `btPTE` | photo | Snapmaker product shot | +| Smooth PEI Plate | `btPEI` | photo | Snapmaker product shot | +| Graphic Effect Plate | `btGESP` | photo | Snapmaker product shot | +| Cool Steel Plate | `btSuperTack` | **drawn** | cool blue, soft sheen, no maker's marks | + +That is the U1's whole default set. The advanced-mode plates +(`support_multi_bed_types`: Cool Plate, Engineering, Textured Cool) are out of scope; when +they arrive they are **drawn**, by the same rule. + +**Sampling a photograph.** Take the middle of the flat product shot, zoomed ~150–195 % so +the crop lands inside the plate's surface, then clip to the silhouette. Never fit the whole +photo — the background and the plate's own edge would come with it, and the silhouette is +already doing that job. *Smooth PEI is matte black*, not amber; only Textured PEI is the +bronze people picture when they hear "PEI". + +**Drawing a plate.** Paths and gradients only — icon SVGs go through **nanosvg** +(`BitmapCache.cpp:18`), which has no filters, no `` and no CSS. A base gradient +plus one or two soft highlight sweeps is enough; do not invent surface detail that claims +to be a particular product. + +## Sizes and formats + +| Where | Size | +|-------|------| +| Plate card thumbnail | **44 × 40** | +| Plate picker cell | **40 × 36** | + +Photographs ship as **PNG at 1× / 2×** — the sources are `.webp` and `.jpg`, which Orca's +icon path cannot read. Drawn plates ship as **SVG**. + +## Two traps + +**Key off the enum, never the label.** The same `BedType` is named differently per printer: +`btPEI` is *Smooth High Temp Plate* generically and *Smooth PEI Plate* on the U1; +`btSuperTack` is *Cool Plate (SuperTack)* generically and *Cool Steel Plate* on the U1. +Match on the label and the U1 shows the wrong vendor's plate under a Snapmaker name. + +**The card cannot be narrow without a picture.** The reference's plate card is 92 px, so the label +clips to *Textur…* and the picture carries the identification. Without art, a 92 px card is +just clipped text — worse than the full-width `Bed type` row it replaces. Which is why the +row stays as it is until these exist. + +## Open + +**Licensing.** The three photographs are Snapmaker's, used here as design reference rather +than as shipped art. Shipping them in an **AGPL-3.0** repository needs permission. What can ship: ask Snapmaker, photograph the plates yourself, +or draw all four. The drawn cool plate has no such problem and is the model for anything +that has to be replaced — the silhouette and the sampling rules are unaffected either way, +so it is a swap of the fill, not a redesign. diff --git a/docs/ace-mmu/NEXT.md b/docs/ace-mmu/NEXT.md new file mode 100644 index 00000000000..78c8e2e407e --- /dev/null +++ b/docs/ace-mmu/NEXT.md @@ -0,0 +1,134 @@ +# Where this stopped, and what to do next + +**Branch:** `feat/u1-printer-panel`, cut from `origin/develop/add-multiace-support`. +**Contents:** design docs and mockups only. **No code.** Read this before touching anything. + +## What was produced + +| File | What it is | +|------|------------| +| [15-printer-panel.md](15-printer-panel.md) | The panel design: five measured defects, the reference shape adapted to four heads, the ACE mode switch, two defects it uncovered, touch points, shipping order | +| [16-ace-visuals.md](16-ace-visuals.md) | The ACE visual standard: three forms under one rule, the spool box, the moisture pill, where a fill level comes from | +| [17-plate-template.md](17-plate-template.md) | The U1 plate template: the measured silhouette, the path the app draws, and when a plate is photographed rather than drawn | +| [printer-panel-mockup.html](printer-panel-mockup.html) | The panel, interactive: six machine states, the sync flow, the assign popover, the mode dropdown | +| [ace-visual-standard.html](ace-visual-standard.html) | The badge and its twins, the spool box, the pill — with the rejected alternates kept as the record | +| [plate-thumbnails-options.html](plate-thumbnails-options.html) | The four plates in the card, the silhouette variants that were weighed, and the advanced-mode ones noted | + +## Decisions taken — do not reopen without a reason + +- **Panel shape:** taken from the reference slicer this is modelled on. Three cards across the + top; a bordered box per head wrapped 2×2; one `Nozzle` diameter row, not four tabs. +- **Naming:** **Toolhead N**, everywhere. Printer Settings already says it; the sidebar + said *Nozzle N* and the assignment dialog *T1–T4*. +- **No `Flow` row.** This fork deleted the control; there is no setting behind it. +- **ACE mode** is the printer's own three-way switch, mirrored as a labelled dropdown. +- **The assign popover is a choice, not a count** — two macros, so two kinds of row and a + tick. Units named as the printer names them (*ACE 2 Pro* / *ACE Pro*). +- **ACE visuals:** badge **A · Cabinet** (44×26 fill), glyph **O2 · Solid bays** (44×26 + line), square **S4 · Front face** (24×24 line). One rule: outlined chassis, solid bays. +- **Plate visuals:** one measured silhouette, drawn **Emphasised**; photograph Snapmaker's + own plates, draw everything else generically. Full spec in + [17-plate-template.md](17-plate-template.md). +- **No single-bay glyph.** Every ACE unit has exactly four slots. + +## Open — the only things still undecided + +**1. Licensing of the plate photographs.** Three of the four plate thumbnails are built +from Snapmaker's product photography, used here as design reference rather than as +shipped art. Shipping them inside an **AGPL-3.0** repository needs permission. What can ship: ask Snapmaker, photograph the +plates yourself, or draw all four the way Cool Steel already is. **This blocks the plate +card, nothing else** — and it is a swap of the fill, not a redesign: the silhouette, +sampling and sizes in [17-plate-template.md](17-plate-template.md) hold either way. + +**2. Whether the dev tooling comes over.** `.claude/tools/` lives on +`feat/ace-mmu-slicing` and is absent here. Every verification step below assumes it. See +*Traps*. + +Everything else about the panel, the ACE visuals and the plate template is decided and +written down. Where a decision was close, the alternates are kept in the mockups as the +record rather than deleted. + +## Shipping order + +Each step is one branch off `develop/add-multiace-support`, one squashed PR. + +| # | PR | Depends on | +|---|-----|-----------| +| 0 | **These docs** (this branch) | — | +| 1 | **Panel structure** — collapse the four identical nozzle tabs to one `Nozzle` row; move sync out of the title bar onto a labelled card | — | +| 2 | **Topology in the preset** — `ace_head_capacity` / `ace_head_unit`, the Multimaterial settings page, `sync_ace_topology` | — | +| 3 | **The panel's ACE row** — badge, assign popover, ACE mode dropdown | 1, 2 | +| 4 | **Sync info, two-step** — nozzle + topology in one press, chaining to the filament sync | 3 | +| 5 | **Per-unit capacity pool** — the shared-unit fix. Must land before a user can share a unit | 2 | +| 6 | **Combined mode** — the emitter's slot → (unit, slot) map. Until then the mode is listed and disabled | 2 | +| — | **Plate card** — assets plus a card. Independent of everything above | a decision | + +### PR 1, concretely + +Both changes are in `src/slic3r/GUI/Plater.cpp` and need no new config. + +1. `Sidebar::update_nozzle_settings` — check whether every `nozzle_diameter` value is + equal; build **one** page named `Nozzle` when they are, one per head named + **Toolhead N** when they are not. Safe: every existing use of + `m_nozzle_diameter_lists` iterates the whole list and writes the same value, so a + single entry is fine. +2. Move `m_printerinfo_syncbtn` off `m_panel_printer_title` onto a `StaticBox` card beside + the printer card, with a `Sync info` label and the whole card as the click target. Keep + the handler and the U1-only show/hide — retarget the show/hide to the card. + +This was written once and reverted; it is straightforward, but it was **never compiled or +run**, so treat it as a sketch, not a patch. + +## Facts established here — measured, not inferred + +- **The live printer** at `192.168.2.242` reports `mode: "head"`, `device_count: 1`, one + connected **ACE 2 Pro** (`protocol: "v2"`) at 39% RH feeding **Toolhead 4**, holding four + Kingroon/Generic PETG spools (`#83AFFF`, `#8FA7C8`, `#632C2C`, `#C47053`). +- **The mode switch is real firmware:** `SET_ACE_MODE MODE=normal|multi|head [HEAD=n]`, + with `ACE_SET_HEAD_ACE` / `ACE_SET_HEAD_FEEDER` documented "(head mode only)". Read from + `/printer/gcode/help`. +- **One unit may feed several heads.** `ACE_SET_HEAD_ACE` binds a head to one ACE and says + nothing about the reverse; `head_ace` is a map from head to unit. +- **A slot has no remain field.** Quantity comes from Spoolman via `spool_binding` + (`{"0_0":"15","0_1":"10","0_3":"16"}` → `weight_g` 500.1 / 997.7 / 843.1). One of the + four slots is unbound. A *percentage* is not derivable — the initial weight is not in + the payload. +- **Icon SVGs go through nanosvg** (`BitmapCache.cpp:18`): paths and gradients only, no + filters, no ``, no CSS. Anything photographic must ship as PNG. +- **The repo is AGPL-3.0**, so shipped assets must be licence-compatible. Snapmaker's + product photography is not. +- **The U1 connects as a `PrintHost`** through the webview, not as a `MachineObject`, so + the panel cannot use `MachineObject::poll_ace_ams()`. Resolve a host with + `AceMmuProvider::resolve_connected_host()` and read on demand. +- **Two defects found in existing code**, both in `15-printer-panel.md`: a shared unit + double-counts capacity in `AceMmuPlan.hpp`; Combined mode emits the wrong `ACE=`/`SLOT=` + in `GCode.cpp`. + +## Traps on this branch + +- **`develop/add-multiace-support` has no ACE code and no `docs/ace-mmu/` tree.** It is + upstream v2.3.6. Everything from `feat/ace-mmu-slicing` — the provider, planner, + dialogs, docs 01–14 — is absent. +- **`.claude/tools/` is absent too.** The headless-X harness, crash catcher and page + checker live on `feat/ace-mmu-slicing`. Bringing them over early is worth its own PR: + every step below is verified with them, and GUI claims made without them have been wrong + three times in this feature. +- **Switching branches invalidates the build** — the first build after a switch is a full + 622-target rebuild, not an incremental one. Budget for it. +- **Profile edits need a version bump**, or the app keeps its cached copy in + `~/.config/Snapmaker_Orca/system/` and the change silently does nothing. + +## How to verify anything in the GUI + +Reproduce, don't theorise. Three crashes in this feature were misdiagnosed from reading +code; each was settled in one run with the headless harness. Once `.claude/tools/` is on +this branch: + +``` +./.claude/tools/start.sh headless # Xvfb :99 + Orca, crash catcher armed +./.claude/tools/start.sh shot x.png # screenshot the virtual display +./.claude/tools/start.sh click X Y # click on it +./.claude/tools/start.sh trace # resolve the last crash to file:line +``` + +It runs against a copy of `~/.config/Snapmaker_Orca`, so it cannot disturb real presets. diff --git a/docs/ace-mmu/README.md b/docs/ace-mmu/README.md new file mode 100644 index 00000000000..766a5bfe308 --- /dev/null +++ b/docs/ace-mmu/README.md @@ -0,0 +1,59 @@ +# multiACE on the Snapmaker U1 — design docs + +Design and research notes for running one or more Anycubic **ACE Pro / ACE 2 Pro** +filament changers on a **Snapmaker U1** from this slicer, via the printer-side +[multiACE](https://github.com/decay71/multiACE) service. + +Each document is written against **verified reality** — read from the running app, the +live printer at its REST endpoint, or the firmware's own macro help — rather than from +the code alone. Where something is unverified, it says so. + +## Start here + +**[NEXT.md](NEXT.md)** — what has been decided, what is still open, the shipping order, +and the traps on this branch. Read it before touching anything. + +## Documents + +| # | Document | Contents | +|---|----------|----------| +| 15 | [15-printer-panel.md](15-printer-panel.md) | **The U1 printer panel** in Prepare/Preview: the five defects, the reference shape applied to four heads, the ACE mode switch, two defects it uncovered, and the shipping order | +| 16 | [16-ace-visuals.md](16-ace-visuals.md) | **The ACE visual standard**: one way to draw an ACE, taken from Orca's own AMS widget geometry — badge, glyph, spool box, moisture pill | +| 17 | [17-plate-template.md](17-plate-template.md) | **The U1 plate template**: the measured silhouette, the emphasised path the app draws, and when a plate is photographed rather than drawn | + +## Mockups + +Self-contained HTML, interactive. Open directly, or all at once with +`./.claude/tools/start.sh mockups`. + +| Mockup | For | +|--------|-----| +| [printer-panel-mockup.html](printer-panel-mockup.html) | [15](15-printer-panel.md) — the panel, every machine state, the sync flow and the assign popover | +| [ace-visual-standard.html](ace-visual-standard.html) | [16](16-ace-visuals.md) — the badge, its outlined and square twins, the spool box, the moisture pill | +| [plate-thumbnails-options.html](plate-thumbnails-options.html) | [17](17-plate-template.md) — the four plates in the card, the silhouette options that were weighed, and the advanced-mode ones noted | + +Each mockup is the specification the code is built against: when the two disagree, the +mockup is updated in the same commit, not left to drift. + +## Status + +**Design only. No code has landed on this branch.** Every implementation step is listed in +[NEXT.md](NEXT.md). + +## Numbering + +Documents are numbered in the order they were written, not in reading order — the number +is a stable handle for cross-references. **15 and 16 are the first of this set to land on +`develop/add-multiace-support`**; documents 01–14 cover the provider, data model, +slicing, planner and dialogs, and arrive with the PRs that implement them. + +## Conventions + +- **Measure, don't infer.** Three crashes in this feature were misdiagnosed from reading + code; each was settled in one run with `.claude/tools/start.sh` (headless X, crash + catcher, `THROW_LOG=1`). GUI claims are made from screenshots, printer claims from the + printer. +- **Status vocabulary:** *done* = observed working end to end · *built* = written and + compiles, not yet observed · *gap* = not written. +- **Say what is not known.** A doc that hides an unverified assumption costs more than + one that names it. diff --git a/docs/ace-mmu/ace-visual-standard.html b/docs/ace-mmu/ace-visual-standard.html new file mode 100644 index 00000000000..7a852ec0b65 --- /dev/null +++ b/docs/ace-mmu/ace-visual-standard.html @@ -0,0 +1,1006 @@ + + +The ACE visual standard + + +
+ +
+

The ACE visual standard

+

An ACE is drawn four different ways today. The device page gives it 60 px circular + spools; the assignment dialog gives it 96 px cards in a teal-banded box; the new + printer panel gives it 7×15 px bars in a rounded strip; and the filament-mapping + popup — which reads the ACE through amsList — already gives it + Orca's real AMS widgets. Four languages for one object.

+

That last one is the way out. The ACE is projected onto Ams/AmsTray + already, so the AMS widgets are the standard — the geometry below is lifted from + src/slic3r/GUI/Widgets/AMSItem.hpp rather than invented, which is what stops it + drifting again. Everything is drawn here at true size: 1 px = 1 DIP.

+

Four states are the ACE's own and have no AMS equivalent — an inferred spool + identity, a configured but absent unit, the toolhead a unit feeds, and a + temperature reading. Each is marked ACE where it appears.

+
+ +
+
+ Badge + + + + + + + +
+
+ Outline + + + + + + +
+
+ Square + + + + + + +
+
+ Slot 3 + + + + + + + +
+
+ Unit + + + + + +
+
+ Remaining + + + + + +
+
+ Moisture + + + + + + +
+
+ + +
+
1The badge — four spools in a box
+
+

Five ways to draw it — all 44×26

+

The icon the reference slicer puts beside AMS in a nozzle box is not + AMSPreview — not a row of cubes but a little cabinet seen head-on, and + Orca ships no equivalent (ams_icon.svg is a 155×128 illustration). So here are + five candidates, from the faithful copy to the ones that lean on what this fork already draws. + Pick one and the whole page follows.

+
+

Each is drawn to the same 44×26 box and the same + four bays, so they are interchangeable in code — one function, one argument. Pick with + the Badge row in the deck and the rest of this page follows, including the states below. + Every one is shown twice: enlarged, and at true size in the row it actually lives in.

+

A is the faithful distillation of that icon — hood, bays, base + drawn over them. B keeps that silhouette but draws the chassis in line rather than fill, + so the colours carry and the grey mass goes away. C drops the chassis entirely and stands + the bays on AMSPreview's own ground, which is the least new invention of the five. + D is the ACE's front face: four bays cut into one body. E uses spool ends, which is + what the U1 + multiACE page already draws — picking it would make that page the + standard rather than the exception.

+
+

The outlined twin — the same cabinet, in line. + Where the badge says what is in it, the outline says what it is: it names an + ACE in a popover row, a settings label, a menu. So it is not a separate drawing — it is + A's own silhouette, one stepped path with the hood's shoulders on top and the base + stepping out at the bottom. Four ways to treat the bays inside it:

+
+

And a square twin, for icon slots. The cabinet is + 44×26 — it cannot go where the app wants a square: a tab, a menu row, a + ScalableButton. Squaring it is a real trade, because the ACE's four bays sit in a + row, and a row is wide. Four ways to spend the square, each shown at 48, at 24 and 16 + true size, and in the tab it would most likely appear in:

+
+

The trade, plainly. S1 changes nothing and + pays for it in size — at 16 px the bays are under a pixel. S2 fills the square + but the bays thin out. S3 adds an outline that competes with the cabinet's own. + S4 is the most legible small, at the cost of the hood-and-base step.

+ +
+

Settled — three forms, one rule

+

The picks turned out to share a rule, + which is a better outcome than three unrelated shapes: + outlined chassis, solid bays, at one stroke weight.

+
+

What the square costs, stated. S4 drops the + hood and the base step, so the square is not the same silhouette as the wide glyph + — the family is carried by the bay treatment and the stroke, not by the outline. That is + a deliberate trade: the two live in different places (a tab or menu icon versus a popover row + or label) and the square has a third of the width to say the same thing in. If they ever end + up side by side and the mismatch shows, S1 is the fallback, because it is literally the + wide drawing scaled down.

+

Three definitions, not eight. The alternates stay on this page as the + record of what was weighed; the code ships + ace_badge(), ace_glyph() and ace_glyph_square() + and nothing else.

+
+

There is no single-bay variant. The reference needs one + because an AMS Lite holds one spool; every ACE unit has exactly four slots — + /api/state returns "slots": [/* exactly 4 */] and + SLOT_COUNT = 4 is a constant, not a configuration. A one-bay glyph would depict a + machine that does not exist, so the family has four bays or none (O4), and nothing + between.

+
+
+ + +
+
2Moisture and temperature
+
+

The pill — 93×26 with a percentage, 60×26 without

+

AMSHumidity, unchanged: a pill (radius = half the height) on + AMS_CONTROL_DEF_BLOCK_BK_COLOUR, the hum_level1..5 droplet at 16 px, + then a 1 px #C2C2C2 divider and the dryer glyph when the unit can dry. + All of it already exists — icons, sizes, sentinel values.

+
+

The ACE reports a raw percentage where the AMS often reports only a 1–5 + bucket, and AMSinfo already handles exactly that split: humidity_raw = -1 + selects the numbered droplet, anything else selects the plain droplet plus the number. Your unit + reports humidity: 39, so it takes the second form. Bucket it as + 1 = ≤20, 2 = ≤35, 3 = ≤50, 4 = ≤65, + 5 = >65 to pick the glyph.

+

Temperature ACE is the one addition. The AMS carries + current_temperature in its info struct but never draws it in this widget; the ACE + reports temp per unit and it matters while drying. It goes in the same pill behind a + second divider, so there is still one chip to look at rather than three. When the unit reports no + temperature the segment is absent, not zeroed.

+

Drying swaps the sun (ams_drying) for the animated glyph + (ams_is_drying) exactly as support_drying() does, and the remaining time + belongs in the tooltip, not the pill — the pill is a glance, not a readout.

+
+
+ + +
+
3The box the spools sit in
+
+

Four spool objects in a light grey box

+

This is the AMS shape: a light grey box with four colour columns + standing in it. The column is AMSLib — 58×80 + (AMS_CAN_LIB_SIZE), a well inset by 4, and the filament colour drawn + from the bottom up to how much is left. Not a swatch on a card: a level in a tube, so a + row of them reads as an inventory at a glance.

+

Three greys, all from the AMS palette, each doing one job: #EEEEEE is + the band and also the empty tube, #F8F8F8 is the box the tubes + stand in. Selection is 2 px in the filament's own colour, which is + AMSLib's rule rather than the brand teal — hover is the teal.

+
+
+ A · Strip — 82×27 +
+ AMSPreview · inline: a head box, a mapping row, a list item +
+
+ B · Unit box — band + ground +
+ the ACE as an object: identity, the head it feeds, moisture, four slots +
+
+ C · Spool objects — 58×80 each +
+ AMS_CAN_LIB_SIZE · the column is how much is left +
+
+

The band carries identity, the ground carries contents. Left to right the band + is: unit number in a brand-coloured square, the model name the printer gives it + (protocol: "v2"ACE 2 Pro, "v1"ACE Pro), the + toolhead it feeds ACE, and the moisture pill hard right — which is + where the reference puts it on the AMS.

+

Where the level comes from — and the one thing to settle. The AMS has + material_remain 0–100 and draws it directly. An ACE slot has no such + field: /api/state's slots[] carries material, brand, colour and + source, and nothing about quantity. But your printer runs spool_mode: "spoolman", + and spool_binding maps slots to Spoolman ids — + {"0_0":"15", "0_1":"10", "0_3":"16"} — each carrying a real + weight_g: 500 g, 998 g and 843 g. So three of + your four slots can drive the column honestly; S3 is not bound, which is why it is drawn + full but hatched and labelled amount unknown rather than pretending to be full.

+

What is not available is a percentage: Spoolman knows the initial weight, + this payload does not, so a column scaled to 1 kg would call an 843 g spool 84% when it + may be a full 850 g one. Show the grams, and treat the column as a gauge rather than a + measurement — or fetch remaining_weight from Spoolman directly and scale + it properly, which is its own small piece of work. + AceSlot parses none of this today ACE, so wiring the + binding through is the prerequisite for the column meaning anything.

+

A unit that is configured but not answering ACE has no + AMS equivalent: an AMS is only reported when present, whereas an ACE lives in the preset and the + printer may be off, on cloud, or short of a unit. It takes + AMS_CONTROL_DISABLE_COLOUR through the same Enable(false) path the AMS + widgets already have — grey cubes, grey badge, grey text — so it is legibly there + but unreadable, rather than absent or, worse, empty.

+
+
+ + +
+
4The numbers, and where they come from
+
+

One source, two implementations

+

The ACE is drawn in native wx (the sidebar, the mapping popup) and in webviews + (the assignment dialog, the U1 + multiACE page). So the standard is one spec with two + implementations, and the numbers are pinned to the existing macros so the two cannot drift.

+
+ + + + + + + + + + + + + + + + + + + + +
Thingwx macroValueCSS token
ACE badge— (new)44×26 fillace_badge()
ACE glyph— (new)44×26 line, stroke 1.6ace_glyph()
ACE glyph, square— (new)24×24 line, stroke 1.6ace_glyph_square()
Slot cubeAMS_ITEM_CUBE_SIZE14×14, r2internal to AMSPreview — never drawn alone
StripAMS_ITEM_SIZE82×27, r3, pad 7, gap 5.strip
Strip + moistureAMS_ITEM_HUMIDITY_SIZE120×27.strip + .pill
Slot cardAMS_CAN_LIB_SIZE58×80.lib
Moisture pillAMS_HUMIDITY_SIZE93×26, r13.pill
… level onlyAMS_HUMIDITY_NO_PERCENT_SIZE60×26.pill.short
Band groundAMS_CONTROL_DEF_BLOCK_BK_COLOUR#EEEEEE--ams-block-bk
Spool groundAMS_CONTROL_DEF_LIB_BK_COLOUR#F8F8F8--ams-lib-bk
SelectionAMS_CONTROL_BRAND_COLOUR#009688, 2px--ams-brand
UnreadableAMS_CONTROL_DISABLE_COLOUR#CECECE--ams-disable
Pill divider— (literal in AMSHumidity)#C2C2C2, 20px--ams-rule
Dropletshum_level1..5_{light,dark}16px SVGsame files
Dryerams_drying / ams_is_drying16px SVGsame files
+
+

One arithmetic snag to settle in code: padding 7 plus four 14 px cubes + plus three 5 px gaps is 85, not the 82 the constant states. The AMS gets away with it because + callers size the preview themselves. For the ACE, fix it once — gap 4 lands exactly on + 82 — rather than letting each surface pick.

+
+
+ + +
+
5What changes where
+
+

Every surface that draws an ACE

+
+ + + + + + + + + + + + + + + + + + + +
SurfaceDraws nowBecomes
Filament mapping popup
AmsMappingPopup.cpp
Orca's AMS widgets, via the amsList projectionNothing. It is already the standard — this is the reference.
Printer panel
Plater.cpp sidebar
7×15 px bars in an ad-hoc rounded stripA — Strip (82×27), four 14 px cubes. Same width as the AMS row it mirrors.
U1 + multiACE page
resources/web/multiace
60 px circular spools with a hub; 36 px circular swatchesB — Unit box with C — slot cards. The circles are the biggest + departure and the one worth losing: nothing else in Orca draws filament round.
Assignment dialog
resources/web/aceplan
96 px .pos cards in a teal-banded .aceboxB + C, keeping the drag targets. Closest already; mostly re-tokening.
Device / AMS tab
AMSControl, AmsItem
Orca's AMS widgets, fed by the projectionNothing, beyond naming the unit ACE 2 Pro rather than AMS.
+
+

Order I would take it: the strip ships with the printer panel, since that PR + is drawing one anyway and it is the cheapest place to prove the tokens. The unit box and slot cards + then land as one restyle PR across the two web pages, which is where the four languages actually + collapse into one. The two native surfaces need no work at all — which is the argument for + this standard rather than a new one.

+
+
+ +
+ + diff --git a/docs/ace-mmu/plate-thumbnails-options.html b/docs/ace-mmu/plate-thumbnails-options.html new file mode 100644 index 00000000000..07ed350f1bc --- /dev/null +++ b/docs/ace-mmu/plate-thumbnails-options.html @@ -0,0 +1,421 @@ + + +U1 build plates in the UI + + +
+
+

U1 build plates in the UI

+

Snapmaker sells three plates for the U1 — Textured PEI, Smooth PEI and Graphic + Effect — and both the EU and US stores list exactly those, one variant each. Their product + photography is below, sampled at the plate's centre so what you see is the surface + rather than the silhouette.

+

And a fourth, from elsewhere. Cool Steel Plate is in the U1's default list but + Snapmaker does not sell it separately, so there was no photo for it. BIQU's + CryoGrip + Pro for the Snapmaker U1 fills that slot: a double-sided 7-layer composite in two + finishes — Frostbite (coarse) and Glacier (fine) — and, for Glacier, five + colours. Same U1 mount, so the measured silhouette carries over unchanged.

+

Scope: these three, plus Cool Steel. The app can offer more — turning on + support_multi_bed_types swaps the U1's list for a longer one that includes Cool + Plate, Engineering Plate and the rest of the upstream-lineage set. Those are + deliberately out of scope here; §3 records what they are and what they would need, so + the decision is on paper rather than rediscovered later.

+
+ +
+
1Three photographed, one drawn
+
+

Photographed, cropped to surface

+

The first three sample the middle of Snapmaker's own flat product shot; Cool + Steel is drawn, because Snapmaker does not sell one. Worth noting because I had guessed + wrong before looking: Smooth PEI is matte black, not amber — only Textured PEI + is the bronze people picture when they hear "PEI".

+
+
+

The shape is measured, not traced. My first + attempt was drawn by eye and was wrong in every dimension — deep crenellations where + the plate has small nicks. So the product PNG was decoded to raw pixels and the top and + bottom edges sampled per column. The plate is 391×413 (aspect 0.947); the three + top notches sit at x 26 / 50 / 74, each about 6 wide and 2.9 deep; the + bottom has a tongue from x 23 to 77.5 protruding 2.9 below the sides; + corners are radius ~2. All as percentages of the plate's own box.

+

How literal to draw it was a real choice. A notch 2.9% deep on a + 40 px-tall thumbnail is just over one pixel — drawn truthfully, the plate + is a rounded square with three nicks nobody will see. Exaggerating is a normal icon + convention but it is a decision, so it was made deliberately: Emphasised, the + measured notches roughly doubled. The alternates stay as the record of what was weighed.

+
+

Only Snapmaker's own plates are photographed. The first three are + first-party products for their bed types, so a photo is simply what the plate looks like. + Cool Steel is drawn instead — Snapmaker does not sell it separately, and the + nearest real product is a third party's (BIQU's CryoGrip Pro, which does fit the U1). Using + that art would brand a bed type with a vendor the app never otherwise mentions, and + a user selecting Cool Steel Plate may own any plate or none. So it is a generic cool + plate: a cool blue with a soft sheen, no maker's marks, no invented texture pretending to be + a particular surface. The same rule applies to the advanced-mode plates if they are + ever added.

+

Shape as well as surface. A square crop showed the material but could + have been any plate on any printer. These are clipped to the U1 plate's own outline + — three shallow notches along the top and a wide tongue along the bottom — so + the three plates share a shape that says they belong to the same machine, which is most of + what a thumbnail can usefully carry.

+

Aspect is preserved, not stretched. The plate is square, the card slot + is 44×40, so the drawing letterboxes rather than squashing — the notches stay the + shape they are on the real plate. The texture underneath is still sampled from the middle of + the photo, so no background or perspective creeps in at the edges.

+
+
+ +
+
2In the panel
+
+

The plate card, and the picker behind it

+

The card is 92 px — the width the reference slicer uses — so the label clips to + Textur… and the picture carries the identification. Click a plate below to change + the card.

+
+

This is why the card can be narrow. With a picture, a clipped label is + a caption; without one it is just clipped text, which is why the row stays full-width until + the art exists.

+
+
+ +
+
3Noted, not now — the advanced-mode plates
+
+

What else the enum can offer, and why it waits

+

PrintConfig.cpp holds three bed-type lists, not one: the + generic upstream set of six, the U1's own set (// U1 only 4), and a seven-entry + set used when support_multi_bed_types is on (// U1 use 7 …). + Only the first three rows below are plates Snapmaker sells for the U1 — the rest appear + only in that advanced mode, and are left alone for now.

+
+ + + +
EnumName on the U1Name genericallyThumbStatus
+
+

The trap to remember when they are picked up. The same + BedType value is named differently depending on the printer: + btPEI is Smooth High Temp Plate generically but Smooth PEI Plate + on the U1, and btSuperTack is Cool Plate (SuperTack) generically but + Cool Steel Plate on the U1. So a thumbnail must key off the enum value, never + the label — otherwise the U1 shows another vendor's plate under a Snapmaker name, or nothing at all.

+

Cool Steel is the awkward one and worth flagging now: it sits in the + U1's default list of four, yet Snapmaker does not sell it separately — it ships + with the printer, so there is no product page to photograph from. If the U1's default set + should be complete, that is the one plate you would have to shoot yourself.

+
+
+ +
+
4Before any of this ships
+
+

These images are Snapmaker's. They are used here + as a design reference rather than as shipped art. Shipping them inside an AGPL-3.0 + repository is a different question and + the answer is probably no without permission — so the options that actually ship are: ask + Snapmaker, photograph the plates yourself, or draw them. + decide before PR

+

And a format note. Two of the three sources are .webp, + which Orca's icon path cannot read — BitmapCache.cpp goes through nanosvg for + SVG and wxWidgets handlers for raster. Whatever is chosen ships as PNG at 1×/2×, or + as SVG if drawn.

+
+
+
+ + diff --git a/docs/ace-mmu/printer-panel-mockup.html b/docs/ace-mmu/printer-panel-mockup.html new file mode 100644 index 00000000000..9c888a90ea8 --- /dev/null +++ b/docs/ace-mmu/printer-panel-mockup.html @@ -0,0 +1,919 @@ + + +The U1 printer panel + + +
+ +
+

The U1 printer panel

+

Prepare and Preview share one sidebar, so the Printer section at the top of it is a single + widget seen in two tabs. This rebuilds it to the shape the reference slicer uses: three cards across the top, + a bordered box per head, a green corner tick where the app agrees with the machine, an edit + popover behind the ACE row, and the two-step Sync info flow that ends by offering to sync + filaments.

+

The panel below is interactive, and the Your U1 scenario carries real values — + the ACE, its mode and its four PETG spools, read from 192.168.2.242 on 21 Aug 2026. + They are a snapshot, baked into this page: it is a static file and does not talk to the + printer, and as a published artifact it could not reach your LAN even if it tried. Press + Sync info, open a head's ACE widget, choose an ACE mode, and switch the machine + underneath it. The notes further down record what each piece takes from that reference.

+

One row has no equivalent there, because the U1 needs it: ACE mode. The printer has its + own three-way switch — SET_ACE_MODE MODE=normal|multi|head — and it + decides whether per-toolhead wiring means anything at all. The panel mirrors it, and Sync info + reads it. That switch is also the answer to “can one unit feed more than one head”: + in Per toolhead mode, yes — tick the same unit on two heads.

+
+ + +
+
ProposedA mature multi-head panel, for four heads
+ +
+
+ Machine + + + + + + + + +
+

+
+ +
+
+
+
Plate 1
+
+
+
+ + +
+
ReferencePiece by piece, against the reference
+
+ +
+
+

Three cards, and a tick that means something

+

Printer, plate, and Sync info sit side by side at equal height; the printer card + carries its thumbnail above the preset combo, the plate card an , and the + green corner triangle marks a card that agrees with the connected machine.

+

Ours is the same three. The plate card takes over today's Bed type row, + which frees the whole row it used. The corner tick appears on the printer card when the + selected preset matches the machine's reported model and nozzle size — and on a head + box when its ACE wiring matches what the printer reports. That is a claim we can actually + make, because sync_ace_topology already computes exactly that diff.

+
+
+ +
+
+

A box per head, two across

+

The reference draws Left Nozzle and Right Nozzle as two bordered panes, each with an + AMS row, a Diameter combo and a Flow combo. The AMS row shows a small + thumbnail of the loaded spools when a unit is attached, and a chevron when none is.

+

Ours is four of the same box, wrapped 2×2. Same label position, same row + grammar, same widths — the ACE row shows the unit's spool colours as a strip, + or a chevron on a stock feeder. Flow is absent: this fork deleted it deliberately and there + is no setting behind it.

+
+
+ +
+
+

The popover that sets what is attached

+

Clicking the pencil beside AMS drops a panel: a sentence, then a grey inset listing + each kind of unit with a spinner — AMS (4 slots), AMS (1 slot).

+

Ours keeps the sentence and the inset, and swaps the spinner for a tick. A count + is the wrong control here: multiACE binds a head to one named unit + (ACE_SET_HEAD_ACE HEAD=n ACE=u) or puts it on its own feeder + (ACE_SET_HEAD_FEEDER), so the list is a choice — one row per macro, + one tick. Units carry the printer's own names, ACE 1 · ACE 2 Pro, with slots, + connection and humidity beneath. Between them the two rows write exactly + ace_head_capacity and ace_head_unit. Open Toolhead 4's + ACE widget in the panel above to use it.

+
+
+ +
+
+

Sync in two steps, not one

+

The reference's Sync info reads nozzle and AMS-count information, then says so and offers + Continue to sync filaments beside Cancel — the count sync and the + contents sync are separate presses, chained by an offer.

+

Ours chains the two that already exist. Step one is today's nozzle query plus + sync_ace_topology in one press; step two hands off to + Sync Filament Information, which already lists the four toolheads and every ACE slot. + Press Sync info in the panel above to walk it, including what it says when the + printer is off and when the preset turns out to be wrong.

+
+
+ +
+
+

And the half of the screenshot below the fold

+

The same screenshot continues into Project Filaments: a numbered, colour-coded badge + per filament in two columns, a menu on each, add/remove/sync/settings in the + sub-header, a Purging volumes pill on the title bar, and a corner tick on each + filament that matches what the AMS holds.

+

That is a different section and a different PR. This fork already has it as + Filament Management — badges, combos, per-filament menus, flushing volumes and + Color Mixing — so it is a restyle rather than a rebuild, and it is the natural + follow-on once the printer section lands. The panel above ends on its title bar to show + where the seam is.

+
+
+ +
+
+ + +
+

The four places it cannot follow the reference, and what it does instead

+
    +
  1. Four heads, not two nozzles. The reference's two boxes sit side by side across the sidebar. + Four at that width would be 110 px each — too narrow for a labelled combo. So the same + box is wrapped 2×2, which keeps those proportions and costs one extra row of + height. That row lands in Preview too, where the sidebar competes with the sliced preview: + the head boxes are the section to collapse first if it proves too tall.
  2. + +
  3. An ACE is not an AMS, and the popover is a choice rather than a count. The reference counts + interchangeable units by kind, so a spinner fits. multiACE binds a head to a named unit + with ACE_SET_HEAD_ACE HEAD=h ACE=a, or puts it on its own feeder with + ACE_SET_HEAD_FEEDER HEAD=h ENABLE=1 — two macros, so two kinds of row and a + tick rather than a number. Units are named as the printer names them: ACE 1 · ACE 2 + Pro from protocol: "v2", ACE Pro from "v1", with slot + count, connection and humidity underneath.
  4. + +
  5. No Flow row. The reference's third row picks a flow calibration; this fork removed the control + and there is no setting behind it. Adding a dead combo to look like the screenshot would be + worse than the gap. The head box has two rows, not three.
  6. + +
  7. Contents are LAN-only. The reference reads AMS state over its cloud, so its panel is populated + whenever the printer is online. The multiACE endpoint answers only on the local network, so over + a cloud connection there is no snapshot at all. The panel then draws the preset's topology + — which is real, and is what offline slicing uses — with contents muted rather than + blank, so it does not change height when a printer appears.
  8. +
+
+ +
+

One unit, several heads — and the two bugs it uncovers

+

Read from the printer's own firmware + (/printer/gcode/help on 192.168.2.242), not inferred:

+
    +
  1. The mode is the printer's, so the panel should not invent one. + SET_ACE_MODE MODE=normal|multi|head. Normal is stock feeders and no ACE; + head wires each head individually; multi pools units onto one ACE head. Only in + head mode does per-toolhead assignment mean anything — which is why the mode row + sits above the head boxes and greys them out in Normal. The live machine is in + mode: "head".
  2. + +
  3. Sharing is legal, and the popover is where it happens. ACE_SET_HEAD_ACE + says each head is wired to exactly one ACE — it says nothing about each ACE + feeding exactly one head, and head_ace is a map from head to unit, so several + heads may name the same one. In the panel: tick ACE 1 on Toolhead 4, then tick it on + Toolhead 1. Each row then reads also feeds Toolhead 4, and a warning appears + under the boxes. Try the Shared unit scenario.
  4. + +
  5. Bug 1 — a shared unit double-counts capacity. AceMmuPlan.hpp sums + capacity per head (total_cap += cap[h]) and enforces it per head + (if (++load[h] > cap[h])). Two heads on one 4-slot unit therefore read as + 8 places when there are 4. The infeasible-plate refusal — the whole point of which + is to catch exactly this — would wave through a plate that cannot be laid out. The fix is + a per-unit pool constraint beside the per-head one; the panel already computes and shows the + honest number.
  6. + +
  7. Bug 2 — Combined mode cannot be emitted today. ace_head_capacity + already offers ACE – 6 slots and ACE – 8 slots, but + GCode.cpp's unit_of_head() returns the single + ace_head_unit[h], and the plan's slot is an index within the head. So slot + 5 of an 8-slot head emits ACE=<first unit> SLOT=5 where the machine needs + ACE=<second unit> SLOT=1 — wrong unit, wrong slot, wrong colour. Those + two enum values are not safe until the emitter maps slot → (unit, slot).
  8. +
+

So the sequencing I would take: + ship the panel with Normal and Per toolhead, and leave Combined in the list + but disabled with the reason on it, until the emitter fix lands. Sharing can ship with the panel + — it is drawn honestly and warns — but the planner's per-unit pool should be the very + next PR, because a wrong capacity is a silently wrong plate.

+
+ + +
+

What already exists

+
+ + + + + + + + + + + + + + + + + + +
PieceWhereState
The panelPlater.cpp:2199–2565Title bar, preset card, Bed type row, nozzle notebook — built once in the Sidebar constructor. The three-card row replaces the first two.
The head boxesSidebar::update_nozzle_settings, Plater.cpp:8682Already rebuilds one page per nozzle_diameter entry. Becomes a 2×2 grid of boxes instead of a notebook.
Sync, step one (nozzles)Plater.cpp:2214–2350Queries the machine, opens NozzleDiameterSelectDialog on mixed diameters, selects the matching preset. Keep as-is; it becomes half the press.
Sync, step one (topology)TabPrinter::sync_ace_topology, Tab.cpp:4652Reads /multiace/api/state, diffs per head, writes both options and reports what changed. Lift out of TabPrinter so the sidebar can call it too.
Sync, step two (filaments)Sidebar::show_sync_filament_dialog, Plater.cpp:8467The non-destructive filament sync, already listing U1 toolheads and ACE slots via append_ace_filament_list. This is what Continue to sync filaments opens.
The topologyace_head_capacity, ace_head_unitPer-head coInts in the printer preset, so the panel and offline slicing share one source.
Live contentsAceMmuProvider, AceSnapshotUnits, slots, colours, materials, humidity, per-head ace/slot bindings. One fetch_once().
+
+

One thing to know before building: the U1 connects as a + PrintHost through the webview, not as a MachineObject, so this panel + cannot lean on MachineObject::poll_ace_ams() the way the AMS UI does. It resolves a + host with AceMmuProvider::resolve_connected_host() and reads on demand — which + is the other reason a press-to-sync flow fits the U1 better than a background poll.

+
+ +
+ + diff --git a/resources/images/plate_cool.png b/resources/images/plate_cool.png new file mode 100644 index 00000000000..37721a8db1b Binary files /dev/null and b/resources/images/plate_cool.png differ diff --git a/resources/images/plate_cool_steel.png b/resources/images/plate_cool_steel.png new file mode 100644 index 00000000000..076f01b7e82 Binary files /dev/null and b/resources/images/plate_cool_steel.png differ diff --git a/resources/images/plate_engineering.png b/resources/images/plate_engineering.png new file mode 100644 index 00000000000..a88a9b1ac75 Binary files /dev/null and b/resources/images/plate_engineering.png differ diff --git a/resources/images/plate_graphic_effect.png b/resources/images/plate_graphic_effect.png new file mode 100644 index 00000000000..e2f56d484ff Binary files /dev/null and b/resources/images/plate_graphic_effect.png differ diff --git a/resources/images/plate_smooth_pei.png b/resources/images/plate_smooth_pei.png new file mode 100644 index 00000000000..3dd6f8fa5f3 Binary files /dev/null and b/resources/images/plate_smooth_pei.png differ diff --git a/resources/images/plate_textured_cool.png b/resources/images/plate_textured_cool.png new file mode 100644 index 00000000000..a12ca08719d Binary files /dev/null and b/resources/images/plate_textured_cool.png differ diff --git a/resources/images/plate_textured_pei.png b/resources/images/plate_textured_pei.png new file mode 100644 index 00000000000..a447e8029a8 Binary files /dev/null and b/resources/images/plate_textured_pei.png differ diff --git a/resources/profiles/Snapmaker.json b/resources/profiles/Snapmaker.json index 2ded4236485..51f0733d553 100644 --- a/resources/profiles/Snapmaker.json +++ b/resources/profiles/Snapmaker.json @@ -1,6 +1,6 @@ { "name": "Snapmaker", - "version": "02.02.55.02", + "version": "02.02.55.07", "force_update": "0", "description": "Snapmaker configurations", "machine_model_list": [ diff --git a/resources/profiles/Snapmaker/process/0.10mm Color Mixing @Snapmaker U1 (0.4 Nozzle).json b/resources/profiles/Snapmaker/process/0.10mm Color Mixing @Snapmaker U1 (0.4 nozzle).json similarity index 100% rename from resources/profiles/Snapmaker/process/0.10mm Color Mixing @Snapmaker U1 (0.4 Nozzle).json rename to resources/profiles/Snapmaker/process/0.10mm Color Mixing @Snapmaker U1 (0.4 nozzle).json diff --git a/src/libslic3r/AceMmuState.hpp b/src/libslic3r/AceMmuState.hpp new file mode 100644 index 00000000000..b5652c23f2d --- /dev/null +++ b/src/libslic3r/AceMmuState.hpp @@ -0,0 +1,322 @@ +#ifndef slic3r_AceMmuState_hpp_ +#define slic3r_AceMmuState_hpp_ + +// Pure, GUI-independent model of the multiACE printer-side state, parsed from the +// raw `/multiace/api/state` payload (see docs/ace-mmu/02-multiace-printer-api.md). +// +// This lives in libslic3r (not the GUI layer) so it can be unit-tested without +// pulling in wxWidgets or MachineObject; the GUI-side AceMmuProvider includes it +// and projects an AceSnapshot onto MachineObject::amsList. + +#include "nlohmann/json.hpp" + +#include +#include +#include +#include + +namespace Slic3r { namespace AceMmu { + +inline constexpr int SLOT_COUNT = 4; // slots per ACE unit; T = ace*4 + slot + +// One slot within an ACE unit -> maps to an Orca AmsTray. +struct AceSlot +{ + int idx = 0; // 0..3 -> AmsTray::id + bool occupied = false; // state != empty && raw != 0 + std::string state; // empty|ready|loading|unloading|error|feeding|assist|unknown + int raw = 0; // gate_status int (0 = empty) + int rfid = 0; // 0 = none, 2 = RFID read OK + std::string material; // "PLA" + std::string brand; // vendor + std::string sku; + std::string subtype; // e.g. Matte / Silk + std::string color_rrggbb; // lowercase "#rrggbb" or empty + std::string source; // rfid|override|derived|empty|null + + // Identity is trustworthy when it came from a spool tag or an explicit + // override (mirrors multiACE's own lookup_live_slots filter). + bool identity_trusted() const { return source == "rfid" || source == "override"; } +}; + +// One ACE unit -> maps to an Orca Ams. +struct AceUnit +{ + int idx = 0; // 0..3 -> Ams::id + bool connected = false; + std::string protocol; // "" | v1 | v2 (ACE Pro vs ACE 2 Pro) + std::string status; // raw ace status string + std::optional temp; // internal temperature, if reported + std::optional humidity; // raw humidity percent (0..100), if reported + std::optional dryer_remaining_minutes; + std::vector slots; +}; + +// One physical extruder/toolhead (the "loaded" view: which (ACE,slot) feeds it). +struct AceToolhead +{ + int idx = 0; // 0..3 -> "T1".."T4" + std::optional ace; // which ACE unit currently feeds this head + std::optional slot; // which slot + std::string material; + std::string color_rrggbb; // "#rrggbb" or empty + bool filament_detected = false; + bool manual = false; // hand-fed / TPU bypass + bool feeder = false; // stock feeder mode + std::string source; +}; + +struct AceSnapshot +{ + int device_count = 0; // authoritative "how many ACE units to expose" + int active_device = 0; + int ace_head = -1; // index of the active toolhead (-1 unknown) + std::string mode; // normal|multi|head + std::string printer_state; // idle|busy|printing|paused|complete|error + std::string ace_status; // top-level ACE status (string on real firmware) + std::optional ace_temp; + std::vector units; + std::vector toolheads; + + const AceUnit* find_unit(int idx) const + { + for (const AceUnit& u : units) + if (u.idx == idx) + return &u; + return nullptr; + } +}; + +// Orca's flat tray index: T = unit*4 + slot, identical to multiACE's toolhead +// numbering and to `MachineObject::tray_index = ams_id*4 + tray_id`. +inline int ace_tray_index(int unit_idx, int slot_idx) { return unit_idx * SLOT_COUNT + slot_idx; } + +// `ams_exist_bits`: bit `unit.idx` set for every connected ACE unit. +inline long ace_ams_exist_bits(const AceSnapshot& snap) +{ + long bits = 0; + for (const AceUnit& u : snap.units) + if (u.connected && u.idx >= 0) + bits |= (1L << u.idx); + return bits; +} + +// `tray_exist_bits`: bit `unit.idx*4 + slot.idx` set for every occupied slot. +inline long ace_tray_exist_bits(const AceSnapshot& snap) +{ + long bits = 0; + for (const AceUnit& u : snap.units) { + if (u.idx < 0) + continue; + for (const AceSlot& s : u.slots) + if (s.occupied && s.idx >= 0) + bits |= (1L << ace_tray_index(u.idx, s.idx)); + } + return bits; +} + +// "#83afff" -> "83AFFFFF" (Orca stores colour as 8-hex RRGGBBAA). Empty/invalid +// input yields an empty string so callers can fall back to a default. +inline std::string ace_color_to_rrggbbaa(const std::string& hash_rrggbb) +{ + std::string s = hash_rrggbb; + if (!s.empty() && s.front() == '#') + s.erase(s.begin()); + if (s.size() != 6) + return {}; + for (char& c : s) { + if (!std::isxdigit(static_cast(c))) + return {}; + c = static_cast(std::toupper(static_cast(c))); + } + return s + "FF"; +} + +namespace detail { + +// A "status-ish" JSON value may be a string ("ready") or an int on some +// firmware; normalise to a string. Null/absent -> empty. +inline std::string as_status_string(const nlohmann::json& parent, const char* key) +{ + if (!parent.contains(key) || parent.at(key).is_null()) + return {}; + const nlohmann::json& v = parent.at(key); + if (v.is_string()) + return v.get(); + if (v.is_number_integer()) + return std::to_string(v.get()); + if (v.is_number_unsigned()) + return std::to_string(v.get()); + return {}; +} + +inline std::string opt_string(const nlohmann::json& parent, const char* key) +{ + if (!parent.contains(key) || !parent.at(key).is_string()) + return {}; + return parent.at(key).get(); +} + +inline int opt_int(const nlohmann::json& parent, const char* key, int fallback) +{ + if (!parent.contains(key) || !parent.at(key).is_number_integer()) + return fallback; + return parent.at(key).get(); +} + +inline std::optional opt_number(const nlohmann::json& parent, const char* key) +{ + if (!parent.contains(key) || !parent.at(key).is_number()) + return std::nullopt; + return parent.at(key).get(); +} + +inline std::optional opt_int_null(const nlohmann::json& parent, const char* key) +{ + if (!parent.contains(key) || !parent.at(key).is_number_integer()) + return std::nullopt; + return parent.at(key).get(); +} + +inline bool opt_bool(const nlohmann::json& parent, const char* key) +{ + if (!parent.contains(key) || !parent.at(key).is_boolean()) + return false; + return parent.at(key).get(); +} + +inline AceSlot parse_slot(const nlohmann::json& j, int index_fallback) +{ + AceSlot slot; + slot.idx = opt_int(j, "idx", index_fallback); + slot.state = opt_string(j, "state"); + slot.raw = opt_int(j, "raw", 0); + slot.rfid = opt_int(j, "rfid", 0); + slot.material = opt_string(j, "material"); + slot.brand = opt_string(j, "brand"); + slot.sku = opt_string(j, "sku"); + slot.subtype = opt_string(j, "subtype"); + slot.color_rrggbb = opt_string(j, "color"); + slot.source = opt_string(j, "source"); + + const bool has_raw = j.contains("raw") && j.at("raw").is_number_integer(); + slot.occupied = !slot.state.empty() && slot.state != "empty" && (!has_raw || slot.raw != 0); + return slot; +} + +inline std::optional parse_dryer_remaining(const nlohmann::json& unit) +{ + if (!unit.contains("dryer") || !unit.at("dryer").is_object()) + return std::nullopt; + const nlohmann::json& d = unit.at("dryer"); + // multiACE reports the dryer countdown under "remain_time" in SECONDS; expose it + // as whole minutes. (ACE_DRY's DURATION param, by contrast, is in minutes.) + if (auto v = opt_int_null(d, "remain_time")) + return std::optional(*v / 60); + if (auto v = opt_int_null(d, "remaining")) + return std::optional(*v / 60); + return std::nullopt; +} + +inline AceUnit parse_unit(const nlohmann::json& j, int index_fallback) +{ + AceUnit unit; + unit.idx = opt_int(j, "idx", index_fallback); + unit.connected = j.contains("connected") && j.at("connected").is_boolean() ? j.at("connected").get() : false; + unit.protocol = opt_string(j, "protocol"); + unit.status = as_status_string(j, "status"); + unit.temp = opt_number(j, "temp"); + unit.humidity = opt_int_null(j, "humidity"); + unit.dryer_remaining_minutes = parse_dryer_remaining(j); + + if (j.contains("slots") && j.at("slots").is_array()) { + int i = 0; + for (const nlohmann::json& sj : j.at("slots")) { + if (sj.is_object()) + unit.slots.emplace_back(parse_slot(sj, i)); + ++i; + } + } + return unit; +} + +inline AceToolhead parse_toolhead(const nlohmann::json& j, int index_fallback) +{ + AceToolhead th; + th.idx = opt_int(j, "idx", index_fallback); + th.ace = opt_int_null(j, "ace"); + th.slot = opt_int_null(j, "slot"); + th.material = opt_string(j, "material"); + th.color_rrggbb = opt_string(j, "color"); + th.filament_detected = opt_bool(j, "filament_detected"); + th.manual = opt_bool(j, "manual"); + th.feeder = opt_bool(j, "feeder"); + th.source = opt_string(j, "source"); + return th; +} + +} // namespace detail + +// Parse a raw multiACE `/api/state` (or `/api/aces`) document. Tolerant of +// missing/extra fields and nulls: unknown shapes degrade to empty/defaults +// rather than throwing, so a partial payload never clears good inventory. +inline AceSnapshot parse_ace_state(const nlohmann::json& j) +{ + AceSnapshot snap; + if (!j.is_object()) + return snap; + + snap.device_count = detail::opt_int(j, "device_count", 0); + snap.active_device = detail::opt_int(j, "active_device", 0); + snap.ace_head = detail::opt_int(j, "ace_head", -1); + snap.mode = detail::opt_string(j, "mode"); + snap.printer_state = detail::opt_string(j, "printer_state"); + snap.ace_status = detail::as_status_string(j, "ace_status"); + snap.ace_temp = detail::opt_number(j, "ace_temp"); + + if (j.contains("aces") && j.at("aces").is_array()) { + int i = 0; + for (const nlohmann::json& uj : j.at("aces")) { + if (uj.is_object()) + snap.units.emplace_back(detail::parse_unit(uj, i)); + ++i; + } + } + + if (j.contains("toolheads") && j.at("toolheads").is_array()) { + int i = 0; + for (const nlohmann::json& tj : j.at("toolheads")) { + if (tj.is_object()) + snap.toolheads.emplace_back(detail::parse_toolhead(tj, i)); + ++i; + } + } + + // "head_ace" maps head index -> feeding ACE unit (object with string keys, e.g. + // {"0":0,"3":0}). Fill any toolhead whose own "ace" was null so an ACE-fed head + // knows which unit feeds it (the per-head "ace"/"slot" fields are often null). + if (j.contains("head_ace") && j.at("head_ace").is_object()) { + const nlohmann::json& ha = j.at("head_ace"); + for (AceToolhead& th : snap.toolheads) { + if (th.ace.has_value()) + continue; + const std::string key = std::to_string(th.idx); + if (ha.contains(key) && ha.at(key).is_number_integer()) + th.ace = ha.at(key).get(); + } + } + + // device_count is authoritative; if absent, fall back to the array length. + if (snap.device_count == 0) + snap.device_count = static_cast(snap.units.size()); + return snap; +} + +inline AceSnapshot parse_ace_state(const std::string& text) +{ + return parse_ace_state(nlohmann::json::parse(text, nullptr, /*allow_exceptions=*/false)); +} + +}} // namespace Slic3r::AceMmu + +#endif // slic3r_AceMmuState_hpp_ diff --git a/src/libslic3r/AceMmuTopology.hpp b/src/libslic3r/AceMmuTopology.hpp new file mode 100644 index 00000000000..4245370578a --- /dev/null +++ b/src/libslic3r/AceMmuTopology.hpp @@ -0,0 +1,104 @@ +#ifndef slic3r_AceMmuTopology_hpp_ +#define slic3r_AceMmuTopology_hpp_ + +// The mapping between what the printer reports about its ACE wiring and what the printer preset +// stores about it - `ace_mode`, `ace_head_unit`, `ace_head_capacity`. +// +// Pure, and deliberately out of the GUI: the sidebar's Sync info writes these values, the sidebar's +// corner ticks diff against them, and TabPrinter's Multimaterial page reads the same thing. One +// function, so a tick cannot claim an agreement the next sync would undo. + +#include "AceMmuState.hpp" +#include "Config.hpp" +#include "PrintConfig.hpp" + +#include +#include + +namespace Slic3r { namespace AceMmu { + +// What the preset would hold if it were made to agree with the machine. +struct AceTopology +{ + AceMode mode = amNormal; + std::vector unit; // per head; -1 for a head on its own stock feeder + std::vector cap; // per head; 1 for a stock feeder +}; + +// The firmware's own three words, through the same map the preset value is stored with, so "head" +// means amHead here and in the gcode. An unknown word is Normal: no ACE claimed. +inline AceMode ace_mode_from_string(const std::string &mode) +{ + const auto &values = ConfigOptionEnum::get_enum_values(); + const auto it = values.find(mode); + return it == values.end() ? amNormal : AceMode(it->second); +} + +// How many slots a unit offers. Four unless the machine says otherwise - SLOT_COUNT is a constant +// in the protocol, but a unit that reported a shorter list is believed over the constant. +inline int ace_unit_capacity(const AceSnapshot &snap, int unit_idx) +{ + for (const AceUnit &u : snap.units) + if (u.idx == unit_idx && !u.slots.empty()) + return int(u.slots.size()); + return SLOT_COUNT; +} + +inline AceTopology ace_topology_of(const AceSnapshot &snap, size_t head_count) +{ + AceTopology topo; + topo.mode = ace_mode_from_string(snap.mode); + + for (size_t h = 0; h < head_count; ++h) { + int cap = 1, unit = -1; // no ACE reported for this head means its own feeder + for (const AceToolhead &th : snap.toolheads) { + if (size_t(th.idx) != h) + continue; + // `feeder` is the flag to trust, not `ace`. head_ace carries a default unit for every + // head, so a machine with one ACE still reports {0:0, 1:1, 2:2, 3:0} there; on the live + // U1 heads 1-3 are feeders, and reading their head_ace as wiring would invent two units + // that are not plugged in. + if (!th.feeder && th.ace.has_value()) { + unit = *th.ace; + cap = ace_unit_capacity(snap, unit); + } + break; + } + topo.cap.push_back(cap); + topo.unit.push_back(unit); + } + return topo; +} + +// Whether head `h` in the preset says what the printer says. The mode decides whether per-head +// wiring means anything at all, so it is compared first; a head on its own feeder carries no unit, +// so the unit is only compared when there is one. +inline bool ace_head_agrees(const ConfigBase &cfg, const AceTopology &topo, size_t h) +{ + const auto *ace_mode = cfg.option>("ace_mode"); + if (!ace_mode || AceMode(ace_mode->value) != topo.mode) + return false; + if (h >= topo.cap.size()) + return false; + + const auto *head_unit = cfg.option("ace_head_unit"); + const auto *head_cap = cfg.option("ace_head_capacity"); + const int was_cap = (head_cap && h < head_cap->values.size()) ? head_cap->values[h] : 1; + const int was_unit = (head_unit && h < head_unit->values.size()) ? head_unit->values[h] : -1; + return was_cap == topo.cap[h] && (topo.cap[h] <= 1 || was_unit == topo.unit[h]); +} + +// How the printer names a unit: protocol v2 is an ACE 2 Pro, v1 an ACE Pro. The same mapping +// resources/web/multiace/index.html uses, so the panel and the machine's own page agree. +inline std::string ace_unit_model(const AceUnit &unit) +{ + if (unit.protocol == "v2") + return "ACE 2 Pro"; + if (unit.protocol == "v1") + return "ACE Pro"; + return {}; +} + +}} // namespace Slic3r::AceMmu + +#endif // slic3r_AceMmuTopology_hpp_ diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 8882ce9a3af..bca6c0a2212 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -986,7 +986,7 @@ static std::vector s_Preset_printer_options { "printer_technology", "printable_area", "bed_exclude_area","bed_custom_texture", "bed_custom_model", "gcode_flavor", "fan_kickstart", "fan_speedup_time", "fan_speedup_overhangs", - "single_extruder_multi_material", "manual_filament_change", "machine_start_gcode", "machine_end_gcode", "before_layer_change_gcode", "printing_by_object_gcode", "layer_change_gcode", "time_lapse_gcode", "change_filament_gcode", "change_extrusion_role_gcode", + "single_extruder_multi_material", "ace_mode", "ace_head_capacity", "ace_head_unit", "manual_filament_change", "machine_start_gcode", "machine_end_gcode", "before_layer_change_gcode", "printing_by_object_gcode", "layer_change_gcode", "time_lapse_gcode", "change_filament_gcode", "change_extrusion_role_gcode", "printer_model", "printer_variant", "printable_height", "extruder_clearance_radius", "extruder_clearance_height_to_lid", "extruder_clearance_height_to_rod", "nozzle_height", "default_print_profile", "inherits", diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index d97af9e9822..df9c19897f1 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -435,6 +435,14 @@ static t_config_enum_values s_keys_map_PrinterStructure { }; CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(PrinterStructure) +// The firmware's own words, so the stored value is the SET_ACE_MODE argument verbatim. +static t_config_enum_values s_keys_map_AceMode { + {"normal", int(AceMode::amNormal)}, + {"head", int(AceMode::amHead)}, + {"multi", int(AceMode::amMulti)} +}; +CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(AceMode) + static t_config_enum_values s_keys_map_PerimeterGeneratorType{ { "classic", int(PerimeterGeneratorType::Classic) }, { "arachne", int(PerimeterGeneratorType::Arachne) } @@ -3978,6 +3986,72 @@ void PrintConfigDef::init_fff_params() def->max = 100; def->set_default_value(new ConfigOptionFloats { 0.4 }); + def = this->add("ace_mode", coEnum); + def->label = L("ACE mode"); + def->tooltip = L("How the printer wires its ACE units to its toolheads, mirroring the machine's own " + "three-way switch (SET_ACE_MODE MODE=normal|multi|head). \"Normal\" means stock " + "feeders only and no ACE. \"Per toolhead\" is the one that makes per-head wiring " + "mean anything: each head is either its own feeder or wired to exactly one unit. " + "\"Combined\" pools several units onto a single head."); + def->enum_keys_map = &ConfigOptionEnum::get_enum_values(); + def->enum_values.push_back("normal"); + def->enum_values.push_back("head"); + def->enum_values.push_back("multi"); + def->enum_labels.push_back(L("Normal")); + def->enum_labels.push_back(L("Per toolhead")); + def->enum_labels.push_back(L("Combined")); + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionEnum(amNormal)); + + def = this->add("ace_head_unit", coInts); + // Stored 0-based because that is the ACE= argument the firmware takes, but never shown + // that way: every other surface calls the first unit "ACE 1". Closed list - a unit that + // does not exist is not a value worth being able to type. + def->gui_type = ConfigOptionDef::GUIType::i_enum_open; + def->enum_values.push_back("-1"); + def->enum_values.push_back("0"); + def->enum_values.push_back("1"); + def->enum_values.push_back("2"); + def->enum_values.push_back("3"); + def->enum_labels.push_back(L("None")); + def->enum_labels.push_back(L("ACE 1")); + def->enum_labels.push_back(L("ACE 2")); + def->enum_labels.push_back(L("ACE 3")); + def->enum_labels.push_back(L("ACE 4")); + def->label = L("ACE unit"); + def->tooltip = L("Which ACE unit feeds this toolhead. Each ACE-fed head is wired to exactly one unit " + "(ACE_SET_HEAD_ACE on the printer), and this value becomes the ACE= argument of every " + "filament swap, so a wrong choice addresses the wrong hardware. Units are numbered as " + "they appear on the device page: ACE 1 is the first unit. \"None\" is what a stock " + "feeder shows, since no ACE addresses it."); + def->mode = comAdvanced; + def->min = -1; + // None by default: the matching default for ace_head_capacity is a stock feeder, and + // showing "ACE 1" there claims a unit that is not feeding anything. + def->set_default_value(new ConfigOptionInts { -1 }); + + def = this->add("ace_head_capacity", coInts); + // Labelled choices rather than a bare number: "1" meaning "no ACE at all" is exactly + // the kind of magic value that got a head wired to the first ACE configured as unit 1. + def->gui_type = ConfigOptionDef::GUIType::i_enum_open; + def->enum_values.push_back("1"); + def->enum_values.push_back("2"); + def->enum_values.push_back("4"); + def->enum_values.push_back("6"); + def->enum_values.push_back("8"); + def->enum_labels.push_back(L("Stock feeder")); + def->enum_labels.push_back(L("ACE - 2 slots")); + def->enum_labels.push_back(L("ACE - 4 slots")); + def->enum_labels.push_back(L("ACE - 6 slots")); + def->enum_labels.push_back(L("ACE - 8 slots")); + def->label = L("Fed by"); + def->tooltip = L("How this toolhead gets filament. \"Stock feeder\" is the head's own side feeder - " + "one spool, no ACE. Otherwise it is the number of slots the ACE unit presents to this " + "head (sum them when several units are combined onto one head)."); + def->mode = comAdvanced; + def->min = 1; + def->set_default_value(new ConfigOptionInts { 1 }); + def = this->add("notes", coString); def->label = L("Configuration notes"); def->tooltip = L("You can put here your personal notes. This text will be added to the G-code " @@ -6429,7 +6503,7 @@ void PrintConfigDef::init_extruder_option_keys() { // ConfigOptionFloats, ConfigOptionPercents, ConfigOptionBools, ConfigOptionStrings m_extruder_option_keys = { - "nozzle_diameter", "min_layer_height", "max_layer_height", "extruder_offset", + "nozzle_diameter", "ace_head_capacity", "ace_head_unit", "min_layer_height", "max_layer_height", "extruder_offset", "retraction_length", "z_hop", "z_hop_types", "z_hop_when_prime", "travel_slope", "retract_lift_above", "retract_lift_below", "retract_lift_enforce", "retraction_speed", "deretraction_speed", "retract_before_wipe", "retract_restart_extra", "retraction_minimum_travel", "wipe", "wipe_distance", "retract_when_changing_layer", "retract_length_toolchange", "retract_restart_extra_toolchange", "extruder_colour", diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 68d18bccf2c..e2ab5b31aa9 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -320,6 +320,15 @@ static std::unordered_mapNozzleTypeStrToEumn = { {"brass", NozzleType::ntBrass} }; +// How the printer wires its ACE units to its toolheads. These are the firmware's own three +// words - SET_ACE_MODE MODE=normal|multi|head - so the value can be sent and read back without +// a translation table, and matches AceSnapshot::mode as the printer reports it. +enum AceMode { + amNormal = 0, // stock feeders only, no ACE + amHead, // each head is a feeder, or wired to exactly one ACE + amMulti // units pooled onto a single ACE head +}; + // BBS enum PrinterStructure { psUndefine=0, @@ -488,6 +497,7 @@ CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(PrintHostType) CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(AuthorizationType) CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(WipeTowerWallType) CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(PerimeterGeneratorType) +CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(AceMode) #undef CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS @@ -1236,6 +1246,9 @@ PRINT_CONFIG_CLASS_DEFINE( ((ConfigOptionBool, auxiliary_fan)) ((ConfigOptionBool, support_air_filtration)) ((ConfigOptionEnum,printer_structure)) + ((ConfigOptionEnum, ace_mode)) + ((ConfigOptionInts, ace_head_capacity)) + ((ConfigOptionInts, ace_head_unit)) ((ConfigOptionBool, support_chamber_temp_control)) diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 95dfc829ebc..774f41b0f47 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -91,6 +91,14 @@ set(SLIC3R_GUI_SOURCES GUI/DailyTips.hpp GUI/DesktopIntegrationDialog.cpp GUI/DesktopIntegrationDialog.hpp + GUI/AceMmuProvider.cpp + GUI/AceMmuProvider.hpp + GUI/AceBadge.cpp + GUI/AceBadge.hpp + GUI/AceAssignPopup.cpp + GUI/AceAssignPopup.hpp + GUI/SMAccountPersist.cpp + GUI/SMAccountPersist.hpp GUI/DeviceManager.cpp GUI/DeviceManager.hpp GUI/Downloader.cpp diff --git a/src/slic3r/GUI/AceAssignPopup.cpp b/src/slic3r/GUI/AceAssignPopup.cpp new file mode 100644 index 00000000000..e10b6c1355a --- /dev/null +++ b/src/slic3r/GUI/AceAssignPopup.cpp @@ -0,0 +1,256 @@ +#include "AceAssignPopup.hpp" + +#include "AceBadge.hpp" +#include "GUI.hpp" +#include "GUI_App.hpp" +#include "I18N.hpp" +#include "Widgets/Label.hpp" +#include "Widgets/StaticBox.hpp" +#include "libslic3r/AceMmuTopology.hpp" + +#include +#include + +namespace Slic3r { namespace GUI { + +static const wxColour ROW_HOVER = wxColour(0xF2, 0xF7, 0xF4); +static const wxColour ROW_INK = wxColour(0x4A, 0x52, 0x58); +static const wxColour ROW_DIM = wxColour(0x6B, 0x72, 0x76); +static const wxColour ORCA_GREEN = wxColour(0x00, 0xAE, 0x42); +static const wxColour TICK_OFF = wxColour(0xCE, 0xCE, 0xCE); + +// The stock feeder's mark: one spool loaded straight at the head. Deliberately not an ACE form - +// the whole point of the row is that no unit addresses it. +class FeederMark : public wxWindow +{ +public: + explicit FeederMark(wxWindow *parent, int size_dip = 24) : wxWindow(parent, wxID_ANY) + { + const int h = FromDIP(size_dip); + m_size = wxSize(h, h); // square, to sit in the same column as the S4 glyph + SetBackgroundStyle(wxBG_STYLE_PAINT); + SetMinSize(m_size); + SetMaxSize(m_size); + Bind(wxEVT_PAINT, [this](wxPaintEvent &) { + wxAutoBufferedPaintDC dc(this); + dc.SetBackground(wxBrush(GetParent()->GetBackgroundColour())); + dc.Clear(); + const wxSize sz = GetSize(); + const int r = std::min(sz.x, sz.y) / 2 - FromDIP(3); + const wxPoint c(sz.x / 2, sz.y / 2); + dc.SetPen(wxPen(ROW_DIM, std::max(1, FromDIP(2)))); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.DrawCircle(c, r); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(ROW_DIM)); + dc.DrawCircle(c, std::max(1, r / 3)); + }); + } + wxSize DoGetBestSize() const override { return m_size; } + +private: + wxSize m_size; +}; + +// The tick: a filled disc with a check when chosen, an empty ring when not. Same grammar as the +// mode dropdown, so one reading covers both lists. +class ChoiceTick : public wxWindow +{ +public: + ChoiceTick(wxWindow *parent, bool on) : wxWindow(parent, wxID_ANY), m_on(on) + { + const int d = FromDIP(19); + m_size = wxSize(d, d); + SetBackgroundStyle(wxBG_STYLE_PAINT); + SetMinSize(m_size); + SetMaxSize(m_size); + Bind(wxEVT_PAINT, [this](wxPaintEvent &) { + wxAutoBufferedPaintDC dc(this); + dc.SetBackground(wxBrush(GetParent()->GetBackgroundColour())); + dc.Clear(); + const wxSize sz = GetSize(); + const int r = std::min(sz.x, sz.y) / 2 - 1; + const wxPoint c(sz.x / 2, sz.y / 2); + if (m_on) { + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(ORCA_GREEN)); + dc.DrawCircle(c, r); + wxPen pen(*wxWHITE, std::max(1, FromDIP(2))); + pen.SetCap(wxCAP_ROUND); + pen.SetJoin(wxJOIN_ROUND); + dc.SetPen(pen); + const wxPoint check[3] = {{c.x - r / 2, c.y}, {c.x - r / 6, c.y + r / 2}, {c.x + r / 2, c.y - r / 2}}; + dc.DrawLines(3, check); + } else { + dc.SetPen(wxPen(TICK_OFF, std::max(1, FromDIP(1)))); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.DrawCircle(c, r); + } + }); + } + wxSize DoGetBestSize() const override { return m_size; } + +private: + bool m_on; + wxSize m_size; +}; + +AceAssignPopup::AceAssignPopup(wxWindow *parent, + size_t head_idx, + const std::vector &head_unit, + const std::vector &head_cap, + const AceMmu::AceSnapshot &snap, + bool snap_valid) + : PopupWindow(parent, wxBORDER_NONE), m_head_idx(head_idx) +{ + SetBackgroundColour(*wxWHITE); + + const int cur_unit = head_idx < head_unit.size() ? head_unit[head_idx] : -1; + const int cur_cap = head_idx < head_cap.size() ? head_cap[head_idx] : 1; + const bool on_feeder = cur_cap <= 1; + + wxBoxSizer *sizer = new wxBoxSizer(wxVERTICAL); + sizer->AddSpacer(FromDIP(10)); + + const wxString title = wxString::Format(_L("Which ACE feeds Toolhead %d?"), int(head_idx) + 1); + auto *question = new Label(this, Label::Head_14, title); + question->SetForegroundColour(ROW_INK); + // Label applies the font after wxStaticText has already cached a best size for the default + // one, so a heading font measures short and the sizer clips the tail - here, the head number. + // Measure it again through the label's own font and say so. + question->SetMinSize(wxSize(question->GetTextExtent(title).x + FromDIP(2), -1)); + sizer->Add(question, 0, wxLEFT | wxRIGHT, FromDIP(14)); + sizer->AddSpacer(FromDIP(8)); + + add_row(this, sizer, -1, 1, _L("Stock feeder"), _L("One spool, loaded at the head"), on_feeder, nullptr); + + // With a reading, the units the machine reports. Without one, the four `ace_head_unit` accepts: + // the preset has to be settable with the printer switched off, and inventing detail for a unit + // nobody has spoken to would be worse than offering it plainly. + std::vector unit_indices; + if (snap_valid) { + for (const AceMmu::AceUnit &u : snap.units) + if (u.idx >= 0) + unit_indices.push_back(u.idx); + } + if (unit_indices.empty()) + for (int i = 0; i < 4; ++i) + unit_indices.push_back(i); + + for (int idx : unit_indices) { + const AceMmu::AceUnit *live = snap_valid ? snap.find_unit(idx) : nullptr; + const int cap = live ? AceMmu::ace_unit_capacity(snap, idx) : AceMmu::SLOT_COUNT; + + wxString title = wxString::Format(_L("ACE %d"), idx + 1); + if (live) { + const std::string model = AceMmu::ace_unit_model(*live); + if (!model.empty()) + title += " " + wxString::FromUTF8("\xC2\xB7") + " " + wxString::FromUTF8(model); + } + + // What the row can honestly say about this unit. + wxString detail = wxString::Format(_L("%d slots"), cap); + if (live) { + detail += " " + wxString::FromUTF8("\xC2\xB7") + " " + + (live->connected ? _L("connected") : _L("not answering")); + if (live->humidity) + detail += " " + wxString::FromUTF8("\xC2\xB7") + " " + wxString::Format(_L("%d%% RH"), *live->humidity); + } else if (snap_valid) { + detail += " " + wxString::FromUTF8("\xC2\xB7") + " " + _L("not reported by the printer"); + } + + // A unit may feed more than one head; naming the others is what makes the shared capacity + // legible before the choice rather than after it. + std::vector also; + for (size_t h = 0; h < head_unit.size(); ++h) + if (h != head_idx && head_unit[h] == idx && h < head_cap.size() && head_cap[h] > 1) + also.push_back(int(h) + 1); + for (size_t i = 0; i < also.size(); ++i) + detail += (i == 0 ? "\n" + _L("Also feeds") + " " : ", ") + wxString::Format(_L("Toolhead %d"), also[i]); + + add_row(this, sizer, idx, cap, title, detail, !on_feeder && cur_unit == idx, live); + } + + sizer->AddSpacer(FromDIP(10)); + SetSizerAndFit(sizer); +} + +void AceAssignPopup::add_row(wxWindow *parent, wxSizer *sizer, int unit, int cap, const wxString &title, + const wxString &detail, bool ticked, const AceMmu::AceUnit *live) +{ + auto *row = new wxPanel(parent, wxID_ANY); + row->SetBackgroundColour(ticked ? ROW_HOVER : parent->GetBackgroundColour()); + row->SetCursor(wxCURSOR_HAND); + + wxWindow *mark = nullptr; + if (unit < 0) { + mark = new FeederMark(row); + } else { + // S4, the square front face - doc 16 gives the wide filled badge to a head box and the + // square line form to a menu, which is what this list is. It carries no slot colours by + // design: the row is choosing a unit, and what is loaded in it is the detail line's job. + auto *badge = new AceBadge(row, 24, AceBadge::Form::SquareFace); + badge->SetInk(ROW_INK); + if (live) + badge->SetUnit(*live); + else + badge->SetUnknown(); + mark = badge; + } + + auto *name = new Label(row, Label::Body_13, title, LB_PROPAGATE_MOUSE_EVENT); + name->SetForegroundColour(ROW_INK); + auto *sub = new Label(row, Label::Body_10, detail, LB_PROPAGATE_MOUSE_EVENT); + sub->SetForegroundColour(ROW_DIM); + + auto *tick = new ChoiceTick(row, ticked); + + wxBoxSizer *text = new wxBoxSizer(wxVERTICAL); + text->Add(name, 0, wxEXPAND); + text->Add(sub, 0, wxEXPAND | wxTOP, FromDIP(1)); + + wxBoxSizer *hs = new wxBoxSizer(wxHORIZONTAL); + hs->Add(mark, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(14)); + hs->Add(text, 1, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(10)); + hs->Add(tick, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, FromDIP(12)); + row->SetSizer(hs); + + // The whole row is the target, and every child that could swallow a click forwards it: a + // 19px tick is not a hit box, and the badge is the most obvious thing to aim at. + const auto choose = [this, unit, cap](wxMouseEvent &) { + auto cb = m_choice; + Dismiss(); + if (cb) + cb(unit, cap); + }; + row->Bind(wxEVT_LEFT_UP, choose); + mark->Bind(wxEVT_LEFT_UP, choose); + tick->Bind(wxEVT_LEFT_UP, choose); + + const wxColour base = ticked ? ROW_HOVER : parent->GetBackgroundColour(); + for (wxWindow *w : {static_cast(row), mark, static_cast(tick)}) { + w->Bind(wxEVT_ENTER_WINDOW, [row, base](wxMouseEvent &e) { e.Skip(); row->SetBackgroundColour(ROW_HOVER); row->Refresh(); }); + w->Bind(wxEVT_LEAVE_WINDOW, [row, base](wxMouseEvent &e) { e.Skip(); row->SetBackgroundColour(base); row->Refresh(); }); + } + + sizer->Add(row, 0, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(1)); +} + +void AceAssignPopup::OnDismiss() { CallAfter([this]() { Destroy(); }); } + +void AceAssignPopup::popup_at(wxWindow *anchor) +{ + // Below the control that opened it, left edges aligned, pulled back onto the screen if the + // sidebar sits near the bottom. + wxPoint pos = anchor->ClientToScreen(wxPoint(0, anchor->GetSize().y + 2)); + const wxSize sz = GetSize(); + const wxRect area = wxDisplay(wxDisplay::GetFromWindow(anchor) == wxNOT_FOUND ? 0 : wxDisplay::GetFromWindow(anchor)).GetClientArea(); + if (pos.y + sz.y > area.GetBottom()) + pos.y = std::max(area.GetTop(), anchor->ClientToScreen(wxPoint(0, 0)).y - sz.y - 2); + if (pos.x + sz.x > area.GetRight()) + pos.x = std::max(area.GetLeft(), area.GetRight() - sz.x); + Position(pos, wxSize(0, 0)); + Popup(); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/AceAssignPopup.hpp b/src/slic3r/GUI/AceAssignPopup.hpp new file mode 100644 index 00000000000..0aa31a2496f --- /dev/null +++ b/src/slic3r/GUI/AceAssignPopup.hpp @@ -0,0 +1,58 @@ +#ifndef slic3r_GUI_AceAssignPopup_hpp_ +#define slic3r_GUI_AceAssignPopup_hpp_ + +// Which ACE feeds one toolhead. +// +// A choice, not a count. The firmware offers exactly two macros for this - +// +// ACE_SET_HEAD_FEEDER HEAD=0..3 ENABLE=0|1 (head mode only) +// ACE_SET_HEAD_ACE HEAD=0..3 ACE=0..3 "wired to exactly one ACE" +// +// - so the list has exactly two kinds of row and a tick, rather than the slot spinner the +// Multimaterial page offers. Between them the rows write exactly `ace_head_unit` and +// `ace_head_capacity`. See docs/ace-mmu/15-printer-panel.md. +// +// One unit may feed several heads: ACE_SET_HEAD_ACE binds a head to a unit and says nothing about +// the reverse. Ticking a unit already feeding another head is therefore legal, the row says so, +// and the panel states what the shared capacity really is. + +#include "Widgets/PopupWindow.hpp" +#include "libslic3r/AceMmuState.hpp" + +#include +#include + +namespace Slic3r { namespace GUI { + +class AceAssignPopup : public PopupWindow +{ +public: + // `snap_valid` is false until Sync info has read the machine. The popover still works then - + // the preset is what lets slicing run with the printer switched off - but the rows carry no + // live detail and the list falls back to the four units `ace_head_unit` accepts. + AceAssignPopup(wxWindow *parent, + size_t head_idx, + const std::vector &head_unit, + const std::vector &head_cap, + const AceMmu::AceSnapshot &snap, + bool snap_valid); + + // (unit, capacity) as the preset stores them: unit -1 with capacity 1 is the stock feeder. + void on_choice(std::function cb) { m_choice = std::move(cb); } + + void popup_at(wxWindow *anchor); + + // wxPopupTransientWindow does not own itself; the picker in filamentsync/ sets the precedent. + void OnDismiss() override; + +private: + void add_row(wxWindow *parent, wxSizer *sizer, int unit, int cap, const wxString &title, + const wxString &detail, bool ticked, const AceMmu::AceUnit *live); + + std::function m_choice; + size_t m_head_idx = 0; +}; + +}} // namespace Slic3r::GUI + +#endif // slic3r_GUI_AceAssignPopup_hpp_ diff --git a/src/slic3r/GUI/AceBadge.cpp b/src/slic3r/GUI/AceBadge.cpp new file mode 100644 index 00000000000..e672b56e3e7 --- /dev/null +++ b/src/slic3r/GUI/AceBadge.cpp @@ -0,0 +1,141 @@ +#include "AceBadge.hpp" + +#include +#include + +namespace Slic3r { namespace GUI { + +// The neutrals are Orca's own AMS roles, so the badge sits in the same palette as every other +// filament surface rather than introducing a second one (docs/ace-mmu/16-ace-visuals.md). +static const wxColour ACE_HOOD = wxColour(0xE8, 0xE8, 0xE8); +static const wxColour ACE_BASE = wxColour(0xCF, 0xCF, 0xCF); +static const wxColour ACE_EMPTY = wxColour(0xFF, 0xFF, 0xFF); +static const wxColour ACE_DISABLE = wxColour(0xCE, 0xCE, 0xCE); // AMS_CONTROL_DISABLE_COLOUR +static const wxColour ACE_BLOCK = wxColour(0xEE, 0xEE, 0xEE); // AMS_CONTROL_DEF_BLOCK_BK_COLOUR + +// "#83AFFF" -> wxColour. Anything else is no colour at all, which draws as an empty bay. +static std::optional parse_hash_rgb(const std::string &s) +{ + if (s.size() != 7 || s.front() != '#') + return std::nullopt; + wxColour c(wxString::FromUTF8(s)); + if (!c.IsOk()) + return std::nullopt; + return c; +} + +static const wxColour ACE_INK = wxColour(0x6B, 0x6B, 0x6B); // the outlined forms' default stroke + +AceBadge::AceBadge(wxWindow *parent, int height_dip, Form form) + : wxWindow(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize), m_form(form), m_ink(ACE_INK) +{ + const int h = FromDIP(height_dip); + const double box = m_form == Form::SquareFace ? 24.0 : 26.0; + m_scale = double(h) / box; + m_size = m_form == Form::SquareFace ? wxSize(h, h) + : wxSize(int(std::lround(44.0 * m_scale)), h); + + SetBackgroundStyle(wxBG_STYLE_PAINT); + SetMinSize(m_size); + SetMaxSize(m_size); + SetSize(m_size); + Bind(wxEVT_PAINT, &AceBadge::paintEvent, this); +} + +void AceBadge::SetInk(const wxColour &ink) +{ + m_ink = ink; + Refresh(); +} + +void AceBadge::SetSlots(const std::vector &colors_rrggbb) +{ + m_unknown = false; + for (size_t i = 0; i < m_bays.size(); ++i) + m_bays[i] = i < colors_rrggbb.size() ? parse_hash_rgb(colors_rrggbb[i]) : std::nullopt; + Refresh(); +} + +void AceBadge::SetUnit(const AceMmu::AceUnit &unit) +{ + m_unknown = false; + m_bays.fill(std::nullopt); + for (const AceMmu::AceSlot &s : unit.slots) { + if (s.idx < 0 || size_t(s.idx) >= m_bays.size()) + continue; + // An unoccupied slot is empty whatever colour it last carried. + m_bays[s.idx] = s.occupied ? parse_hash_rgb(s.color_rrggbb) : std::nullopt; + } + Refresh(); +} + +void AceBadge::SetUnknown() +{ + m_unknown = true; + m_bays.fill(std::nullopt); + Refresh(); +} + +void AceBadge::render_cabinet(wxDC &dc) +{ + const double z = m_scale; + const auto S = [z](double v) { return int(std::lround(v * z)); }; + + dc.SetPen(*wxTRANSPARENT_PEN); + + // The hood: x 2..40, top corners rounded at 7. Its bottom corners are rounded too and then + // covered by the base, which is what gives the cabinet its stepped silhouette. + dc.SetBrush(wxBrush(m_unknown ? ACE_BLOCK : ACE_HOOD)); + dc.DrawRoundedRectangle(S(2), S(2), S(38), S(24), S(7)); + + // Four bays, 5x14 capsules at x 6/15/24/33 - padding 4 equals gap 4. + for (int i = 0; i < AceMmu::SLOT_COUNT; ++i) { + const wxColour fill = m_unknown ? ACE_DISABLE : (m_bays[i] ? *m_bays[i] : ACE_EMPTY); + dc.SetBrush(wxBrush(fill)); + dc.DrawRoundedRectangle(S(6 + i * 9), S(4.5), S(5), S(14), S(2.5)); + } + + // The base, drawn over the bays and slightly wider than the hood: that width difference is + // what makes the shape read as a cabinet rather than a bar chart. + dc.SetBrush(wxBrush(m_unknown ? ACE_DISABLE : ACE_BASE)); + dc.DrawRoundedRectangle(0, S(16), S(44), S(10), S(1.5)); +} + +// S4 - the front face alone: one outlined body and four solid bays, no hood and no base step. +// At 16-24px the hood is what disappears first, so dropping it is what keeps this legible where +// the wide drawing would not be. +void AceBadge::render_square(wxDC &dc) +{ + const double z = m_scale; + const auto S = [z](double v) { return int(std::lround(v * z)); }; + const wxColour ink = m_unknown ? ACE_DISABLE : m_ink; + + // The chassis is outlined; the stroke is 1.6 in the drawing's own units. + const int stroke = std::max(1, int(std::lround(1.6 * z))); + dc.SetPen(wxPen(ink, stroke)); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.DrawRoundedRectangle(S(1.8), S(4.4), S(20.4), S(15.2), S(3.2)); + + // Bays solid, in the same ink: the family is carried by the bay treatment, not the outline. + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(ink)); + for (int i = 0; i < AceMmu::SLOT_COUNT; ++i) + dc.DrawRoundedRectangle(S(4.2 + i * 4.2), S(7.4), S(3.2), S(9.2), S(1.6)); +} + +void AceBadge::paintEvent(wxPaintEvent &) +{ + wxAutoBufferedPaintDC dc(this); + dc.SetBackground(wxBrush(GetParent() ? GetParent()->GetBackgroundColour() : *wxWHITE)); + dc.Clear(); +#ifdef __WXMSW__ + // GTK's wxDC is already Cairo-backed; MSW's is not, and a 2.5px capsule radius without + // antialiasing reads as a rectangle. + wxGCDC gdc(dc); + m_form == Form::SquareFace ? render_square(gdc) : render_cabinet(gdc); +#else + m_form == Form::SquareFace ? render_square(dc) : render_cabinet(dc); +#endif +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/AceBadge.hpp b/src/slic3r/GUI/AceBadge.hpp new file mode 100644 index 00000000000..4b81bfcad8d --- /dev/null +++ b/src/slic3r/GUI/AceBadge.hpp @@ -0,0 +1,69 @@ +#ifndef slic3r_GUI_AceBadge_hpp_ +#define slic3r_GUI_AceBadge_hpp_ + +// The ACE unit, drawn. docs/ace-mmu/16-ace-visuals.md settles one treatment - outlined chassis, +// solid bays - in three proportions; this is the filled badge, form A, at 44x26. +// +// Drawn rather than shipped as an icon. It has to carry four live slot colours, so an asset would +// need one file per colour combination; and icon SVGs go through nanosvg (BitmapCache.cpp), which +// has no patterns and could not carry them anyway. +// +// Four bays is not a variable: /api/state returns exactly four slots and SLOT_COUNT is a constant. +// There is no single-bay form - an AMS Lite has one spool, an ACE never does. + +#include "libslic3r/AceMmuState.hpp" + +#include + +#include +#include +#include + +namespace Slic3r { namespace GUI { + +class AceBadge : public wxWindow +{ +public: + // The three forms doc 16 settles, under one rule: outlined chassis, solid bays. + enum class Form { + Cabinet, // A - 44x26 filled. A head box's ACE row; carries the slot colours. + SquareFace, // S4 - 24x24 line. A menu row, a tab, a ScalableButton. Deliberately NOT the + // same silhouette: it has a third of the width to say the same thing in, so + // the family is carried by the bay treatment and the stroke, not the hood. + }; + + // The nominal drawing is 44x26 for Cabinet and 24x24 for SquareFace; `height_dip` scales + // both axes of whichever form together. + explicit AceBadge(wxWindow *parent, int height_dip = 26, Form form = Form::Cabinet); + + // SquareFace draws in one ink rather than in slot colours; this is that ink. + void SetInk(const wxColour &ink); + + // Bay colours as "#rrggbb", empty for an empty bay. Fewer than four leaves the rest empty. + void SetSlots(const std::vector &colors_rrggbb); + + // Every bay from a unit the panel has read. Convenience over SetSlots. + void SetUnit(const AceMmu::AceUnit &unit); + + // A unit the preset names but the panel has not read, or one configured and not answering: + // drawn in the disabled greys rather than as four empty bays, because empty is a claim. + void SetUnknown(); + + wxSize DoGetBestSize() const override { return m_size; } + +private: + void render_cabinet(wxDC &dc); + void render_square(wxDC &dc); + void paintEvent(wxPaintEvent &evt); + + std::array, AceMmu::SLOT_COUNT> m_bays; + bool m_unknown = true; + Form m_form = Form::Cabinet; + wxColour m_ink; + wxSize m_size; + double m_scale = 1.0; +}; + +}} // namespace Slic3r::GUI + +#endif // slic3r_GUI_AceBadge_hpp_ diff --git a/src/slic3r/GUI/AceMmuProvider.cpp b/src/slic3r/GUI/AceMmuProvider.cpp new file mode 100644 index 00000000000..e6d20683224 --- /dev/null +++ b/src/slic3r/GUI/AceMmuProvider.cpp @@ -0,0 +1,173 @@ +#include "AceMmuProvider.hpp" + +#include "slic3r/Utils/Http.hpp" +#include "slic3r/Utils/PrintHost.hpp" +#include "GUI_App.hpp" +#include "DeviceManager.hpp" +#include "libslic3r/PresetBundle.hpp" + +#include "nlohmann/json.hpp" +#include +#include + +#include +#include +#include + +namespace Slic3r { namespace GUI { + +// "http://192.168.2.242:7125/" / "192.168.2.242:7125" -> "192.168.2.242". +static std::string host_to_ip(std::string h) +{ + const auto scheme = h.find("://"); + if (scheme != std::string::npos) + h = h.substr(scheme + 3); + h = h.substr(0, h.find('/')); // strip any path + h = h.substr(0, h.find(':')); // strip any port + while (!h.empty() && (h.back() == ' ' || h.back() == '\t')) + h.pop_back(); + return h; +} + +std::string AceMmuProvider::resolve_connected_host() +{ + // 1) Selected MachineObject's IP (Bambu-style path). + if (auto* dev = wxGetApp().getDeviceManager()) { + if (MachineObject* obj = dev->get_selected_machine()) + if (!obj->dev_ip.empty()) { + BOOST_LOG_TRIVIAL(info) << "AceMmuProvider::resolve_connected_host: dev_ip=" << obj->dev_ip; + return obj->dev_ip; + } + } + + // 2) The connected PrintHost (set by SSWCP when the U1 connects). + std::shared_ptr host; + wxGetApp().get_connect_host(host); + if (host) { + const std::string ip = host_to_ip(host->get_host()); + if (!ip.empty()) { + BOOST_LOG_TRIVIAL(info) << "AceMmuProvider::resolve_connected_host: connect_host=" << ip; + return ip; + } + } + + // 3) The host config SSWCP stores on connect (this is where print_host actually + // lands for the U1 — it is set on a copy, not the edited preset). + if (DynamicPrintConfig* hc = wxGetApp().get_host_config()) { + if (hc->has("print_host")) { + const std::string ip = host_to_ip(hc->opt_string("print_host")); + if (!ip.empty()) { + BOOST_LOG_TRIVIAL(info) << "AceMmuProvider::resolve_connected_host: host_config print_host=" << ip; + return ip; + } + } + } + + // 4) Last resort: the edited printer preset's print_host, if any. + if (wxGetApp().preset_bundle) { + const auto& cfg = wxGetApp().preset_bundle->printers.get_edited_preset().config; + if (cfg.has("print_host")) { + const std::string ip = host_to_ip(cfg.opt_string("print_host")); + if (!ip.empty()) { + BOOST_LOG_TRIVIAL(info) << "AceMmuProvider::resolve_connected_host: preset print_host=" << ip; + return ip; + } + } + } + + BOOST_LOG_TRIVIAL(warning) << "AceMmuProvider::resolve_connected_host: no host found (dev_ip/connect_host/print_host all empty)"; + return {}; +} + +std::string AceMmuProvider::resolve_generic_filament_id(const std::string& material) +{ + if (material.empty() || !wxGetApp().preset_bundle) + return {}; + // Mirror PresetBundle::sync_ams_list's own fallback: a compatible, system + // "Generic " preset. Returning its filament_id lets the direct match + // in sync_ams_list succeed, so the spool isn't counted as "unknown". + const std::string want = "Generic " + material; + const auto& filaments = wxGetApp().preset_bundle->filaments; + for (auto it = filaments.begin(); it != filaments.end(); ++it) { + if (it->is_compatible && it->is_system && boost::algorithm::starts_with(it->name, want)) + return it->filament_id; + } + return {}; +} + +AceMmuProvider::AceMmuProvider(std::string host, int poll_interval_s) + : m_host(std::move(host)), m_base_url("http://" + m_host + "/multiace"), m_poll_interval_s(poll_interval_s < 1 ? 1 : poll_interval_s) +{} + +AceMmuProvider::~AceMmuProvider() { stop(); } + +void AceMmuProvider::start() +{ + if (m_running.exchange(true)) + return; // already running + m_worker = std::thread(&AceMmuProvider::run, this); +} + +void AceMmuProvider::stop() +{ + if (!m_running.exchange(false)) + return; // already stopped + m_wait_cv.notify_all(); + if (m_worker.joinable()) + m_worker.join(); +} + +AceMmu::AceSnapshot AceMmuProvider::snapshot() const +{ + std::lock_guard lock(m_mutex); + return m_snapshot; +} + +bool AceMmuProvider::fetch_once(int timeout_connect_s, int timeout_max_s) +{ + const std::string url = m_base_url + "/api/state"; + + bool well_formed = false; + AceMmu::AceSnapshot parsed; + + Http::get(url) + .timeout_connect(timeout_connect_s) + .timeout_max(timeout_max_s) + .on_error([&](std::string /*body*/, std::string error, unsigned status) { + BOOST_LOG_TRIVIAL(warning) << "AceMmuProvider: GET " << url << " failed: " << error << " (status " << status << ")"; + }) + .on_complete([&](std::string body, unsigned /*status*/) { + const nlohmann::json doc = nlohmann::json::parse(body, nullptr, /*allow_exceptions=*/false); + if (!doc.is_object()) { + BOOST_LOG_TRIVIAL(warning) << "AceMmuProvider: " << url << " returned a non-object body; keeping last good snapshot"; + return; + } + parsed = AceMmu::parse_ace_state(doc); + well_formed = true; + }) + .perform_sync(); + + if (!well_formed) + return false; // transient/failed/garbage: keep last good + + const int device_count = parsed.device_count; + { + std::lock_guard lock(m_mutex); + m_snapshot = std::move(parsed); + } + const uint64_t rev = m_revision.fetch_add(1) + 1; + BOOST_LOG_TRIVIAL(debug) << "AceMmuProvider: refreshed; device_count=" << device_count << " rev=" << rev; + return true; +} + +void AceMmuProvider::run() +{ + while (m_running.load()) { + fetch_once(); + + std::unique_lock lock(m_wait_mutex); + m_wait_cv.wait_for(lock, std::chrono::seconds(m_poll_interval_s), [this] { return !m_running.load(); }); + } +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/AceMmuProvider.hpp b/src/slic3r/GUI/AceMmuProvider.hpp new file mode 100644 index 00000000000..3d7d1ad215a --- /dev/null +++ b/src/slic3r/GUI/AceMmuProvider.hpp @@ -0,0 +1,88 @@ +#ifndef slic3r_AceMmuProvider_hpp_ +#define slic3r_AceMmuProvider_hpp_ + +// Slicer-side provider that polls a Snapmaker U1's printer-side multiACE service +// (REST `/multiace/api/state`) and keeps a fresh AceSnapshot. +// +// Phase 1 (this file): connect, poll on a worker thread, and cache the latest +// good snapshot. Projecting the snapshot onto MachineObject::amsList and the +// GUI-thread refresh signalling come in Phase 2 (see docs/ace-mmu/). + +#include "libslic3r/AceMmuState.hpp" + +#include +#include +#include +#include +#include +#include + +namespace Slic3r { namespace GUI { + +class AceMmuProvider +{ +public: + // `host` is the printer IP/hostname, e.g. "192.168.2.242"; the base URL is + // built as http:///multiace. Plain HTTP mirrors multiACE's own + // post-processor and works unauthenticated on stock installs + // (see docs/ace-mmu/02-multiace-printer-api.md §2.1). + explicit AceMmuProvider(std::string host, int poll_interval_s = 3); + ~AceMmuProvider(); + + AceMmuProvider(const AceMmuProvider&) = delete; + AceMmuProvider& operator=(const AceMmuProvider&) = delete; + + // Start/stop the background poll worker. Both are idempotent; stop() joins. + void start(); + void stop(); + bool is_running() const { return m_running.load(); } + + // Thread-safe copy of the most recent good snapshot. + AceMmu::AceSnapshot snapshot() const; + + // Monotonic counter, bumped on every successful fetch; lets callers detect a + // refresh without comparing whole snapshots. + uint64_t revision() const { return m_revision.load(); } + + // Fetch and parse `/api/state` once, synchronously (this is what the worker + // calls each tick). Returns true and replaces the cached snapshot only on a + // well-formed response; a transient/failed/garbage read keeps the last good + // snapshot (docs 04 §4.7). + // The timeouts are the background poller's. Callers blocking the GUI on this - + // AcePlanDialog reads the ACE as it opens - should pass shorter ones: a printer + // that is configured but switched off would otherwise freeze the dialog. + bool fetch_once(int timeout_connect_s = 4, int timeout_max_s = 8); + + const std::string& base_url() const { return m_base_url; } + + // Best-effort IP/host of the currently-connected printer: the selected + // MachineObject's dev_ip if set, else parsed from the connected PrintHost + // (the U1 connects as a PrintHost via the webview, not a MachineObject). + // Empty if nothing is connected. + static std::string resolve_connected_host(); + + // filament_id of a compatible system "Generic " preset (e.g. PETG -> + // the Generic PETG preset's id), or empty if none. Used so ACE spools resolve to + // a real preset and PresetBundle::sync_ams_list doesn't flag them as "unknown". + static std::string resolve_generic_filament_id(const std::string& material); + +private: + void run(); + + std::string m_host; + std::string m_base_url; + int m_poll_interval_s; + + mutable std::mutex m_mutex; // guards m_snapshot + AceMmu::AceSnapshot m_snapshot; // last good + std::atomic m_revision{0}; + + std::thread m_worker; + std::atomic m_running{false}; + std::mutex m_wait_mutex; // pairs with m_wait_cv for the poll sleep + std::condition_variable m_wait_cv; +}; + +}} // namespace Slic3r::GUI + +#endif // slic3r_AceMmuProvider_hpp_ diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 030eaadfe01..7ec7774e87b 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -9,6 +9,7 @@ #include "format.hpp" #include "common_func/common_func.hpp" #include "Downloader.hpp" +#include "SMAccountPersist.hpp" #include "slic3r/GUI/WebUrlDialog.hpp" #include "slic3r/GUI/WebPresetDialog.hpp" @@ -1003,6 +1004,11 @@ void GUI_App::post_init() m_open_method = "double_click"; bool switch_to_3d = false; + // The account was restored in init_app_config(), before the webview existed - so the notify() + // that went with it reached no subscribers. Say it again now the page is listening, or it + // keeps offering Login/Register over a live session. + sm_announce_login(); + if (!this->init_params->input_files.empty()) { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(", init with input files, size %1%, input_gcode %2%") @@ -2234,6 +2240,12 @@ void GUI_App::init_app_config() } // Save orig_version here, so its empty if no app_config existed before this run. m_last_config_version = app_config->orig_version();//parse_semver_from_ini(app_config->config_path()); + + // Restore the saved Snapmaker account here rather than in post_init(): the webview asks + // for the login state once, early, and post_init() runs after that. Restoring later left + // the account live in C++ while the home page still offered Login/Register - seen on a + // real restore, and the placement PR #715 arrived at independently. + sm_restore_login(); } else { #ifdef _WIN32 @@ -4299,6 +4311,9 @@ void GUI_App::sm_request_user_logout() } catch (std::exception&) { ; } + // Forget the persisted session, after the revoke above has used the token. + m_login_userinfo.clear(); + sm_persist_login(); } //BBS diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 63d1f5f92b1..b64b8950e76 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -191,6 +191,10 @@ #include "StepMeshDialog.hpp" #include "CloneDialog.hpp" #include "WebPreprintDialog.hpp" +#include "AceMmuProvider.hpp" +#include "AceAssignPopup.hpp" +#include "AceBadge.hpp" +#include "libslic3r/AceMmuTopology.hpp" #include "filamentsync/SyncConfirmDialog.hpp" #include "filamentsync/SyncFilamentColorDialog.hpp" @@ -573,11 +577,17 @@ wxDEFINE_EVENT(EVT_DEL_FILAMENT, SimpleEvent); wxDEFINE_EVENT(EVT_ADD_CUSTOM_FILAMENT, ColorEvent); -#define PRINTER_THUMBNAIL_SIZE (wxSize(FromDIP(48), FromDIP(48))) +#define PRINTER_THUMBNAIL_SIZE (wxSize(FromDIP(68), FromDIP(68))) #define PRINTER_THUMBNAIL_SIZE_SMALL (wxSize(FromDIP(32), FromDIP(32))) #define PRINTER_PANEL_SIZE_SMALL (wxSize(FromDIP(98), FromDIP(68))) #define PRINTER_PANEL_SIZE_WIDEN (wxSize(FromDIP(136), FromDIP(68))) #define PRINTER_PANEL_SIZE (wxSize(FromDIP(98), FromDIP(98))) +#define SYNC_PANEL_SIZE (wxSize(FromDIP(106), FromDIP(106))) +#define PLATE_PANEL_SIZE (wxSize(FromDIP(106), FromDIP(106))) +// Doc 17 measured the plate against a 44x40 slot; the card has room for more than that, and the +// notches are what the silhouette exists to carry, so they get the pixels. The drawing still +// letterboxes - the plate is taller than wide, and squashing it would change those proportions. +#define PLATE_SWATCH_SIZE (wxSize(FromDIP(80), FromDIP(72))) // Nozzle diameter selection when multiple diameters are reported (e.g. U1 sync). // diameters_raw: list from device (may have duplicates or fewer than 4). Dedup and full-list logic inside. @@ -765,6 +775,110 @@ void build_machine_filament_list(PresetBundle* preset_bundle, std::vector& out_list, + const AceMmu::AceSnapshot* snap_in) +{ + AceMmu::AceSnapshot fetched; + if (!snap_in) { + const std::string host = AceMmuProvider::resolve_connected_host(); + if (host.empty()) + return; + AceMmuProvider prov(host); + if (!prov.fetch_once(2, 4)) + return; + fetched = prov.snapshot(); + snap_in = &fetched; + } + const AceMmu::AceSnapshot& snap = *snap_in; + + unsigned next_index = 0; + for (const auto& fd : out_list) + next_index = std::max(next_index, fd.m_index + 1); + + // Built as UTF-8 rather than typed: a narrow literal goes through the current locale on the + // way into wxString, and this string reaches the picker as a std::string. + const std::string kSep = " \xC2\xB7 "; + + // Relabel the toolhead rows with where their filament comes from, matching by head index. + for (auto& fd : out_list) { + const AceMmu::AceToolhead* th = nullptr; + for (const auto& t : snap.toolheads) + if (static_cast(t.idx) == fd.m_index) { + th = &t; + break; + } + + std::string label = "T" + std::to_string(static_cast(fd.m_index) + 1); + if (!th) { + if (!fd.m_type.empty()) + label += kSep + fd.m_type; + } else if (!th->filament_detected) { + label += kSep + "empty"; + fd.m_disabled = true; // nothing loaded: not a source, the way an empty slot is not + } else if (th->manual) { + label += kSep + th->material + kSep + "manual"; + } else if (!th->feeder) { + // Fed by an ACE: shown for context but not selectable. Mapping the head would + // address whichever slot happens to be loaded now; the slot rows below are the + // stable thing to map to, and they are all listed. + label += kSep + "from " + (th->ace.has_value() ? "A" + std::to_string(*th->ace + 1) : std::string("ACE")); + fd.m_disabled = true; + } else { + label += kSep + th->material; + } + fd.m_label = label; + } + + // Every slot of every unit, occupied or not - the same way an empty head still gets a row. + // "A-S", one-based, because every other surface numbers units from 1. + for (const auto& unit : snap.units) { + for (const auto& slot : unit.slots) { + const std::string tag = "A" + std::to_string(unit.idx + 1) + "-S" + std::to_string(slot.idx + 1); + FilamentData fd; + fd.m_index = next_index++; + fd.m_name = tag; + if (!slot.occupied) { + fd.m_type = ""; // NONE -> greyed and not selectable, like an empty head + fd.m_label = tag + kSep + "empty"; + fd.m_color = FilamentColor::FromColors({"#CCCCCC"}, FilamentColorMode::Segment); + out_list.push_back(std::move(fd)); + continue; + } + fd.m_type = slot.material; + fd.m_label = tag + kSep + slot.material; + std::vector colors; + if (!slot.color_rrggbb.empty()) + colors.push_back(slot.color_rrggbb); + fd.m_color = FilamentColor::FromColors(colors, FilamentColorMode::Segment); + out_list.push_back(std::move(fd)); + } + } + + // An explicit way back to unmapped. Empty-slot rows are NONE-typed and unclickable by + // design, so without this there is no way to say "this project filament has no source". + { + FilamentData none; + none.m_index = next_index++; + none.m_name = "NONE"; + none.m_type = "NONE"; + none.m_label = "None"; + none.m_assign_none = true; + none.m_color = FilamentColor::FromColors({"#CCCCCC"}, FilamentColorMode::Segment); + out_list.push_back(std::move(none)); + } +} + } // namespace bool Plater::has_illegal_filename_characters(const wxString& wxs_name) @@ -887,275 +1001,201 @@ int SidebarProps::TitlebarMargin() { return 8; } // Use as side margins on titl int SidebarProps::ContentMargin() { return 12; } // Use as side margins contents of title int SidebarProps::IconSpacing() { return 10; } // Use on main elements int SidebarProps::ElementSpacing() { return 5; } // Use if elements has relation between them like edit button for combo box etc. -// CustomNotebook.h -#pragma once - -#include -#include - -class CustomNotebook : public wxControl -{ -public: - CustomNotebook(wxWindow* parent, wxWindowID id = wxID_ANY, const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize) - : wxControl(parent, id, pos, size, wxBORDER_NONE), m_selectedIndex(-1), m_tabHeight(24), m_tabPadding(10), m_roundRadius(5) - { - SetBackgroundStyle(wxBG_STYLE_PAINT); - UpdateColors(); - Bind(wxEVT_PAINT, &CustomNotebook::OnPaint, this); - Bind(wxEVT_ERASE_BACKGROUND, &CustomNotebook::OnEraseBackground, this); - Bind(wxEVT_LEFT_DOWN, &CustomNotebook::OnLeftDown, this); - Bind(wxEVT_SIZE, &CustomNotebook::OnSize, this); +// One asset per bed type. Snapmaker's three U1 plates are photographed from its product shots; +// Cool Steel and the advanced-mode plates are drawn, because Snapmaker does not sell Cool Steel +// and the nearest real product is somebody else's - using it would brand a *bed type*, which is +// an abstract setting, with a vendor the app never otherwise mentions. All seven carry the same +// measured silhouette in their alpha, so photographed and drawn plates sit in one shape. +// +// Keyed off the enum value and never off the label: the same BedType is named differently +// depending on the printer, and s_keys_map_BedType crosses the identifiers against their strings +// ("Textured PEI Plate" is btPTE, "High Temp Plate" is btPEI). +static const char *plate_icon_name(BedType bt) +{ + switch (bt) { + case btPTE: return "plate_textured_pei"; // Textured PEI - photographed + case btPEI: return "plate_smooth_pei"; // Smooth PEI / high temp - photographed + case btGESP: return "plate_graphic_effect"; // Graphic Effect - photographed + case btSuperTack: return "plate_cool_steel"; // Cool Steel - drawn + case btPC: return "plate_cool"; // drawn + case btEP: return "plate_engineering"; // drawn + case btPCT: return "plate_textured_cool"; // drawn + default: return nullptr; + } +} + +// Null bitmap when the bed type has no art, or the asset is missing: callers draw nothing +// rather than paint a placeholder that claims to be a plate. +static wxBitmap plate_bitmap(wxWindow *win, BedType bt, int height_dip) +{ + const char *name = plate_icon_name(bt); + if (!name) + return wxNullBitmap; + try { + return create_scaled_bitmap(name, win, height_dip); + } catch (...) { + return wxNullBitmap; } +} - void AddPage(wxWindow* page, const wxString& text) - { - m_tabs.push_back({text, page}); - if (page) { - page->Reparent(this); - page->Hide(); - page->SetBackgroundColour(m_selectedTabColor); - } - - if (m_selectedIndex == -1) { - SetSelection(0); - } - - UpdateLayout(); - Refresh(); - } +// A card that can carry the green corner tick - the mark put on a card that agrees with +// the connected machine. StaticBox paints its own rounded border, so the tick goes on after it +// and follows the same corner arc: a plain right triangle would overhang a radius the border +// curves away from, which is what the mockup's `overflow:hidden` prevents. +// The sync mark: a green corner triangle with a white check, in the top-right of whatever it +// marks. Drawn into `win`'s own DC at its own top-right corner, so a card and a combo carry the +// identical mark rather than two that merely resemble each other. +// +// `radius` is the corner radius the thing being marked is drawn with; the mark follows that arc +// instead of overhanging it. `size_dip` lets a combo take a smaller triangle than a 106px card. +// One size wherever the mark appears. A combo is a third the height of a card, so this is a much +// larger share of a filament row than of the printer card - but the mark means the same thing in +// both places, and reading as the same mark matters more than sitting at the same proportion. +static constexpr int SYNC_MARK_DIP = 22; + +static void draw_sync_mark(wxWindow *win, wxDC &dc, double radius, int size_dip = SYNC_MARK_DIP) +{ + const wxSize sz = win->GetSize(); + const int s = win->FromDIP(size_dip); + if (sz.x < s || sz.y < s) + return; - void DeleteAllPages() - { - for (auto& tab : m_tabs) { - if (tab.page) { - tab.page->Destroy(); - } - } - m_tabs.clear(); - m_selectedIndex = -1; - UpdateLayout(); - Refresh(); + // Top edge, round the corner, down the right edge, back to the start. + std::vector pts; + pts.emplace_back(sz.x - s, 0); + for (int i = 0; i <= 6; ++i) { + const double a = M_PI / 2.0 * (double(i) / 6.0); + pts.emplace_back(int(std::lround(sz.x - radius + radius * std::sin(a))), + int(std::lround(radius - radius * std::cos(a)))); } + pts.emplace_back(sz.x, s); - size_t GetPageCount() const { return m_tabs.size(); } - - wxWindow* GetPage(size_t index) const { return (index < m_tabs.size()) ? m_tabs[index].page : nullptr; } + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(wxColour(0x00, 0xAE, 0x42))); + dc.DrawPolygon(int(pts.size()), pts.data()); - int GetSelection() const { return m_selectedIndex; } + // The check, drawn rather than set as text: a glyph this small lands differently on every + // platform's font, and it has to sit inside the triangle on all of them. + // + // Clipped to the mark before it is drawn. The hypotenuse runs (0,0) to (s,s), so the interior + // is x >= y; a round cap that crosses that line puts white on the outside of the triangle, + // which does not read as a check hanging over an edge - it reads as a notch cut out of the + // triangle, because what is behind it is usually white too. The clip makes that impossible at + // any DPI rather than relying on the placement below to have enough margin. + wxRegion clip(int(pts.size()), pts.data()); + dc.SetDeviceClippingRegion(clip); + + // Placed in the corner's thick end, clearing the diagonal by ~0.2s at every point. + const int x0 = sz.x - s; + const wxPoint tick[3] = {{x0 + s * 50 / 100, s * 30 / 100}, + {x0 + s * 62 / 100, s * 42 / 100}, + {x0 + s * 86 / 100, s * 18 / 100}}; + wxPen pen(*wxWHITE, std::max(1, win->FromDIP(2))); + pen.SetCap(wxCAP_ROUND); + pen.SetJoin(wxJOIN_ROUND); + dc.SetPen(pen); + dc.DrawLines(3, tick); + dc.DestroyClippingRegion(); +} + +// A card that can carry the mark. +class SyncMarkBox : public StaticBox +{ +public: + explicit SyncMarkBox(wxWindow *parent) : StaticBox(parent) {} - void SetSelection(size_t index) + void SetSynced(bool synced) { - if (index >= m_tabs.size() || static_cast(index) == m_selectedIndex) + if (m_synced == synced) return; - - if (m_selectedIndex != -1 && m_tabs[m_selectedIndex].page) { - m_tabs[m_selectedIndex].page->Hide(); - } - - m_selectedIndex = index; - - if (m_selectedIndex != -1 && m_tabs[m_selectedIndex].page) { - m_tabs[m_selectedIndex].page->Show(); - } - - UpdateLayout(); + m_synced = synced; Refresh(); } protected: - void OnPaint(wxPaintEvent& event) + void doRender(wxDC &dc) override { - UpdateColors(); - - wxPaintDC dc(this); - - // 1. 绘制背景 - dc.SetPen(*wxTRANSPARENT_PEN); - dc.SetBrush(wxBrush(m_bgColor)); - dc.DrawRectangle(GetClientRect()); - - // 2. 绘制标签背景区域 - dc.SetPen(wxPen(m_dividerColor, 1)); - dc.SetBrush(wxBrush(m_dividerColor)); - wxRect labelRect(0, 0, GetSize().x, m_tabHeight); - dc.DrawRoundedRectangle(labelRect, m_roundRadius); - dc.DrawRectangle(0, m_tabHeight - 2, GetSize().x, 4); - - // 3. 绘制所有标签 - wxFont font = wxSystemSettings::GetFont(wxSYS_DEFAULT_GUI_FONT); - font.SetPointSize(m_textSize); - dc.SetFont(font); - - auto height = dc.GetCharHeight(); - if (height > m_tabHeight - 2) { - m_tabHeight = height + 2; - Layout(); - } - - int xPos = 0; - for (size_t i = 0; i < m_tabs.size(); ++i) { - bool isSelected = static_cast(i) == m_selectedIndex; - - int textWidth, textHeight; - dc.GetTextExtent(m_tabs[i].text, &textWidth, &textHeight); - int tabWidth = textWidth + 2 * m_tabPadding; - - if (isSelected) { - dc.SetPen(wxPen(m_dividerColor, 1)); - dc.SetBrush(wxBrush(m_bgColor)); - wxRect selectedRect(xPos, 0, tabWidth, m_tabHeight + 2); - dc.DrawRectangle(selectedRect); - dc.DrawRoundedRectangle(selectedRect, m_roundRadius); - - dc.SetPen(wxPen(m_bgColor, 1)); - dc.SetBrush(wxBrush(m_bgColor)); - dc.DrawRectangle(xPos, m_tabHeight, tabWidth, 4); - } - - dc.SetTextForeground(isSelected ? m_selectedTextColor : m_textColor); - dc.DrawText(m_tabs[i].text, xPos + m_tabPadding, (m_tabHeight - textHeight) / 2); - - xPos += tabWidth; - } - - // 4. 绘制外边框 - dc.SetPen(wxPen(m_borderColor, 1)); - dc.SetBrush(*wxTRANSPARENT_BRUSH); - dc.DrawRoundedRectangle(GetClientRect(), m_roundRadius); - } - - void OnLeftDown(wxMouseEvent& event) - { - wxPoint pos = event.GetPosition(); - if (pos.y > m_tabHeight) { - event.Skip(); - return; - } - - int tabIndex = HitTest(pos); - if (tabIndex != -1 && tabIndex != m_selectedIndex) { - SetSelection(tabIndex); - Refresh(); - } - } - - void OnSize(wxSizeEvent& event) - { - UpdateLayout(); - Refresh(); - event.Skip(); + StaticBox::doRender(dc); + if (m_synced) + draw_sync_mark(this, dc, radius); } - void OnEraseBackground(wxEraseEvent& event) {} - private: - struct TabInfo - { - wxString text; - wxWindow* page; - }; - - wxRect GetTabRect(size_t index) const - { - if (index >= m_tabs.size()) - return wxRect(); - - wxClientDC dc(const_cast(this)); - wxFont font = wxSystemSettings::GetFont(wxSYS_DEFAULT_GUI_FONT); - font.SetPointSize(m_textSize); - dc.SetFont(font); - - int textWidth, textHeight; - dc.GetTextExtent(m_tabs[index].text, &textWidth, &textHeight); - int tabWidth = textWidth + 2 * m_tabPadding; - - int x = 0; - for (size_t i = 0; i < index; ++i) { - dc.GetTextExtent(m_tabs[i].text, &textWidth, &textHeight); - x += textWidth + 2 * m_tabPadding; - } - - return wxRect(x, 0, tabWidth, m_tabHeight); - } + bool m_synced = false; +}; - int HitTest(const wxPoint& pt) const +// The mark on a filament row, laid over the combo's own top-right corner rather than set beside +// it, so a filament is marked the way a card is - the mark covers the thing it is about. +// +// An overlay window rather than a paint hook: the combo is a shared control (TextInput, and every +// preset combo in the app is one), and this mark belongs to six rows in one panel, not to the +// class. It forwards its clicks, because a corner that swallowed them would be a dead spot on a +// dropdown, and it is raised so the combo's own children cannot cover it. +class SyncMarkOverlay : public wxWindow +{ +public: + explicit SyncMarkOverlay(wxWindow *combo, double radius) + : wxWindow(combo, wxID_ANY), m_radius(radius) { - if (pt.y > m_tabHeight) - return -1; - - wxClientDC dc(const_cast(this)); - wxFont font = wxSystemSettings::GetFont(wxSYS_DEFAULT_GUI_FONT); - font.SetPointSize(m_textSize); - dc.SetFont(font); + const int d = FromDIP(SYNC_MARK_DIP); + SetSize(wxSize(d, d)); + SetBackgroundStyle(wxBG_STYLE_PAINT); + Hide(); - int xPos = 0; - for (size_t i = 0; i < m_tabs.size(); ++i) { - int textWidth, textHeight; - dc.GetTextExtent(m_tabs[i].text, &textWidth, &textHeight); - int tabWidth = textWidth + 2 * m_tabPadding; + Bind(wxEVT_PAINT, [this](wxPaintEvent &) { + wxAutoBufferedPaintDC dc(this); + dc.SetBackground(wxBrush(GetParent()->GetBackgroundColour())); + dc.Clear(); + draw_sync_mark(this, dc, m_radius); + }); - if (pt.x >= xPos && pt.x <= xPos + tabWidth) { - return i; - } + for (auto evt : {wxEVT_LEFT_DOWN, wxEVT_LEFT_UP, wxEVT_LEFT_DCLICK}) + Bind(evt, [this](wxMouseEvent &e) { GetParent()->GetEventHandler()->ProcessEvent(e); }); - xPos += tabWidth; - } - - return -1; + // The combo's width is not known until it has been laid out, and changes with the panel. + combo->Bind(wxEVT_SIZE, [this](wxSizeEvent &e) { + e.Skip(); + reposition(); + }); + reposition(); } - void UpdateColors() + void SetSynced(bool synced) { - bool is_dark = wxGetApp().app_config->get("dark_color_mode") == "1"; - - if (!is_dark) { - m_bgColor = wxColour(255, 255, 255); - m_borderColor = wxColour(240, 240, 240); - m_selectedTabColor = wxColour(255, 255, 255); - m_textColor = wxColour(194, 194, 193); - m_dividerColor = wxColour(240, 240, 240); - m_selectedTextColor = wxColour(0, 0, 0); - } else { - m_bgColor = wxColour(45, 45, 49); - m_borderColor = wxColour(76, 76, 85); - m_selectedTabColor = wxColour(45, 45, 49); - m_textColor = wxColour(104, 105, 107); - m_dividerColor = wxColour(51, 51, 55); - m_selectedTextColor = wxColour(255, 255, 255); + if (IsShown() == synced) + return; + Show(synced); + if (synced) { + Raise(); + reposition(); } + GetParent()->Refresh(); } - void UpdateLayout() +private: + void reposition() { - if (m_selectedIndex != -1 && m_tabs[m_selectedIndex].page) { - wxSize size = GetSize(); - m_tabs[m_selectedIndex].page->SetSize(2, m_tabHeight + 1, size.x - 4, size.y - m_tabHeight - 4); - m_tabs[m_selectedIndex].page->Layout(); - } + const wxSize parent_sz = GetParent()->GetSize(); + const wxSize sz = GetSize(); + // Inset by the border the combo draws, so the mark sits inside its outline rather than + // on top of it. + Move(parent_sz.x - sz.x - 1, 1); } -private: - std::vector m_tabs; - int m_selectedIndex; - - wxColour m_bgColor; - wxColour m_borderColor; - wxColour m_selectedTabColor; - wxColour m_textColor; - wxColour m_selectedTextColor; - wxColour m_dividerColor; - - int m_tabHeight; - int m_tabPadding; - int m_roundRadius; -#ifdef _WIN32 - int m_textSize = 10; -#else - int m_textSize = 13; -#endif + double m_radius; }; +// The middle dot, from its own bytes. +static wxString ace_sep() { return wxString::FromUTF8("\xc2\xb7"); } + +// "stock feeder" / "ACE 1, 4 slots" - the same words the head box uses, one-based as every other +// surface names units. +static wxString ace_wiring_label(int unit, int cap) +{ + return cap <= 1 ? _L("stock feeder") : wxString::Format(_L("ACE %d, %d slots"), unit + 1, cap); +} + struct Sidebar::priv { Plater *plater; @@ -1163,6 +1203,8 @@ struct Sidebar::priv wxPanel *scrolled; PlaterPresetComboBox *combo_print; std::vector combos_filament; + // One per filament combo, shown when that filament is one the machine actually has loaded. + std::vector filament_sync_marks; int editing_filament = -1; wxBoxSizer *sizer_filaments; PlaterPresetComboBox *combo_sla_print; @@ -1172,7 +1214,7 @@ struct Sidebar::priv // test wxStaticBitmap * image_printer = nullptr; - StaticBox* panel_printer_preset = nullptr; + SyncMarkBox* panel_printer_preset = nullptr; //BBS Sidebar widgets @@ -1238,16 +1280,35 @@ struct Sidebar::priv // BBS printer config StaticBox* m_panel_printer_title = nullptr; ScalableButton* m_printer_icon = nullptr; + StaticBox* m_panel_sync_info = nullptr; ScalableButton* m_printerinfo_syncbtn = nullptr; ScalableButton* m_printer_setting = nullptr; wxStaticText* m_text_printer_settings = nullptr; wxPanel* m_panel_printer_content = nullptr; - // nozzle notebook and related controls - CustomNotebook* m_nozzle_notebook{nullptr}; + // The plate card, and the ACE mode row above the head boxes + StaticBox* panel_plate_preset = nullptr; + wxPanel* m_plate_swatch = nullptr; + wxPanel* m_panel_ace_mode = nullptr; + ComboBox* m_ace_mode_list = nullptr; + Label* m_ace_mode_hint = nullptr; + + // One bordered box per toolhead, wrapped two across; rebuilt by update_nozzle_settings() + wxPanel* m_panel_heads = nullptr; + wxGridSizer* m_heads_sizer = nullptr; std::vector m_nozzle_diameter_lists; std::vector m_nozzle_edit_btns; + // What the machine said when Sync info was last pressed, and whether it answered at all. + // The corner ticks are a diff against this rather than against a live poll: the U1 connects + // as a PrintHost through the webview, so there is nothing pushing its state at us, and a + // panel that has not asked has no business claiming the machine agrees with it. + bool m_machine_read = false; // model and nozzles were read + bool m_ace_read = false; // the multiACE service answered too + std::string m_machine_model; + std::vector m_machine_nozzles; + AceMmu::AceSnapshot m_ace_snapshot; + ObjectList *m_object_list{ nullptr }; ObjectSettings *object_settings{ nullptr }; ObjectLayers *object_layers{ nullptr }; @@ -2113,148 +2174,6 @@ Sidebar::Sidebar(Plater *parent) p->m_printer_icon = new ScalableButton(p->m_panel_printer_title, wxID_ANY, "printer"); p->m_text_printer_settings = new Label(p->m_panel_printer_title, _L("Printer"), LB_PROPAGATE_MOUSE_EVENT); - // Use ams_fila_sync icon (sync_nozzle_info.svg does not exist in resources) - p->m_printerinfo_syncbtn = new ScalableButton(p->m_panel_printer_title, wxID_ANY, "nozzle_sync"); - p->m_printerinfo_syncbtn->SetCursor(wxCURSOR_HAND); - p->m_printerinfo_syncbtn->SetToolTip(_L("Synchronize nozzle information")); - p->m_printerinfo_syncbtn->Bind(wxEVT_BUTTON, [this](wxCommandEvent &e) { - bool hasConnectDevice = false; - auto devices = wxGetApp().app_config->get_devices(); - for (const auto& device : devices) { - if (device.connected) - hasConnectDevice = true; - } - - if (!hasConnectDevice) - { - // showdialog tips no connect device - wxTheApp->CallAfter([this]() { - MessageDialog dlg(wxGetApp().mainframe, - _L("Printer not connected. Please go to the home page or the device page to connect the printer."), - _L("Note"), wxOK); - dlg.ShowModal(); - }); - return; - } - - std::string machine_type = ""; - std::vector nozzle_diameters; - std::string device_name = ""; - std::shared_ptr host = nullptr; - wxGetApp().get_connect_host(host); - const bool got_machine_info = SSWCP::query_machine_info(host, machine_type, nozzle_diameters, device_name); - - const auto& sync_nozzle_slots = wxGetApp().preset_bundle->m_connect_machine_info_list; - if (!sync_nozzle_slots.empty()) { - nozzle_diameters.clear(); - for (const auto& slot : sync_nozzle_slots) { - std::string nd = slot.nozzle_info; - boost::algorithm::trim(nd); - if (nd.size() > 2 && boost::iends_with(nd, "mm")) { - nd.resize(nd.size() - 2); - boost::algorithm::trim(nd); - } - if (!nd.empty()) - nozzle_diameters.push_back(nd); - } - } - if (got_machine_info && machine_type == "Snapmaker U1") - { - if (nozzle_diameters.size() <= 0) - { - wxTheApp->CallAfter([this]() { - MessageDialog dlgEx(wxGetApp().mainframe, - _L("No nozzle information detected. Please go to the printer settings to configure the nozzle."), - _L("Note"), wxOK); - dlgEx.ShowModal(); - }); - - return; - } - - bool res = false; - std::string headNozzleSize = nozzle_diameters[0]; - for (int i = 1; i < nozzle_diameters.size(); i++) - { - if (headNozzleSize != nozzle_diameters[i]) - { - res = true; - break; - } - } - - if (res) - { - std::vector diameters_raw = nozzle_diameters; - //std::vector diameters_raw = {"0.2", "0.8"}; - wxTheApp->CallAfter([this, diameters_raw]() { - NozzleDiameterSelectDialog dlg( - wxGetApp().mainframe, - _L("Note: Inconsistent nozzle diameters. Current version does not support mixed diameter printing. Please select one nozzle for this print."), - _L("Set Nozzle Diameter"), - diameters_raw); - if (dlg.ShowModal() == wxID_OK) { - std::string sel = dlg.GetSelectedDiameter(); - if (!sel.empty()) { - auto preset = wxGetApp().preset_bundle->get_similar_printer_preset({}, sel); - if (preset) { - preset->is_visible = true; - - auto diameter = sel; - auto preset = wxGetApp().preset_bundle->get_similar_printer_preset({}, diameter); - if (preset == nullptr) { - BOOST_LOG_TRIVIAL(error) << "get the similar printer preset fail"; - return; - } - preset->is_visible = true; // force visible - - for (size_t i = 0; i < p->m_nozzle_diameter_lists.size(); ++i) { - p->m_nozzle_diameter_lists[i]->SetValue(diameter + "mm"); - } - - wxGetApp().get_tab(Preset::TYPE_PRINTER)->select_preset(preset->name); - wxGetApp().plater()->sidebar().update_all_preset_comboboxes(true); - wxGetApp().plater()->sidebar().update_nozzle_settings(true); - } - } - } - }); - return; - } - else { - // All tool heads report the same diameter: apply it without opening the picker. - std::string diameter = headNozzleSize; - boost::algorithm::trim(diameter); - if (diameter.size() > 2 && boost::iends_with(diameter, "mm")) { - diameter.resize(diameter.size() - 2); - boost::algorithm::trim(diameter); - } - wxTheApp->CallAfter([this, diameter]() { - auto preset = wxGetApp().preset_bundle->get_similar_printer_preset({}, diameter); - if (preset == nullptr) { - BOOST_LOG_TRIVIAL(error) << "get the similar printer preset fail (uniform nozzle sync)"; - return; - } - preset->is_visible = true; - - for (size_t i = 0; i < p->m_nozzle_diameter_lists.size(); ++i) - p->m_nozzle_diameter_lists[i]->SetValue(diameter + "mm"); - - wxGetApp().get_tab(Preset::TYPE_PRINTER)->select_preset(preset->name); - wxGetApp().plater()->sidebar().update_all_preset_comboboxes(true); - wxGetApp().plater()->sidebar().update_nozzle_settings(true); - - wxTheApp->CallAfter([this]() { - MessageDialog dlg_Ex(wxGetApp().mainframe, _L("Nozzle settings synchronized successfully"), - _L("Note"), wxOK); - dlg_Ex.ShowModal(); - }); - }); - } - } - - }); - p->m_printer_setting = new ScalableButton(p->m_panel_printer_title, wxID_ANY, "settings"); p->m_printer_setting->SetToolTip(_L("settings")); p->m_printer_setting->Bind(wxEVT_BUTTON, [this](wxCommandEvent &e) { @@ -2266,8 +2185,6 @@ Sidebar::Sidebar(Plater *parent) h_sizer_title->AddSpacer(FromDIP(SidebarProps::ElementSpacing())); h_sizer_title->Add(p->m_text_printer_settings, 0, wxALIGN_CENTER); h_sizer_title->AddStretchSpacer(); - h_sizer_title->Add(p->m_printerinfo_syncbtn, 0, wxALIGN_CENTER); - h_sizer_title->wxSizer::AddSpacer(FromDIP(10)); h_sizer_title->Add(p->m_printer_setting, 0, wxALIGN_CENTER); h_sizer_title->AddSpacer(FromDIP(SidebarProps::TitlebarMargin())); h_sizer_title->SetMinSize(-1, 3 * em); @@ -2299,21 +2216,23 @@ Sidebar::Sidebar(Plater *parent) std::pair(wxColour(0x00AE42), StateColor::Hovered), std::pair(wxColour(0xEEEEEE), StateColor::Normal)); - p->panel_printer_preset = new StaticBox(p->m_panel_printer_content); + // Three cards across the top - printer, plate, sync - at equal height. The plate card absorbs + // what used to be a full-width "Bed type" row, which is where the room for the head boxes comes from. + p->panel_printer_preset = new SyncMarkBox(p->m_panel_printer_content); p->panel_printer_preset->SetCornerRadius(8); p->panel_printer_preset->SetBorderColor(panel_bd_col); - p->panel_printer_preset->SetMinSize(PRINTER_PANEL_SIZE_SMALL); + p->panel_printer_preset->SetMinSize(PRINTER_PANEL_SIZE); p->panel_printer_preset->Bind(wxEVT_LEFT_DOWN, [this](auto& evt) { p->combo_printer->wxEvtHandler::ProcessEvent(evt); }); PlaterPresetComboBox* combo_printer = new PlaterPresetComboBox(p->panel_printer_preset, Preset::TYPE_PRINTER); combo_printer->SetWindowStyle(combo_printer->GetWindowStyle() & ~wxALIGN_MASK | wxALIGN_LEFT); combo_printer->SetBorderWidth(0); - - ScalableBitmap bitmap_printer(p->panel_printer_preset, "printer_placeholder", 48); + + ScalableBitmap bitmap_printer(p->panel_printer_preset, "printer_placeholder", 68); p->image_printer = new wxStaticBitmap(p->panel_printer_preset, wxID_ANY, bitmap_printer.bmp(), wxDefaultPosition, PRINTER_THUMBNAIL_SIZE, 0); p->image_printer->Bind(wxEVT_LEFT_DOWN, [this](auto& evt) { p->combo_printer->wxEvtHandler::ProcessEvent(evt); }); - + p->combo_printer = combo_printer; // 绑定 combo 内部按钮的事件处理(按钮现在在 combo 内部) @@ -2322,7 +2241,7 @@ Sidebar::Sidebar(Plater *parent) if (combo_printer->switch_to_tab()) p->editing_filament = 0; }); - + combo_printer->bind_connection_button_handler([this]() { wxGetApp().sm_disconnect_current_machine(); PhysicalPrinterDialog dlg(this->GetParent()); @@ -2332,10 +2251,10 @@ Sidebar::Sidebar(Plater *parent) combo_printer->bind_machine_connecting_button_handler([this]() { // machine_connecting_btn 的处理逻辑(如果有的话) }); - + // 显示编辑按钮(鼠标悬停时原来会显示,现在直接显示) combo_printer->set_show_edit_button(true); - + // 设置按钮tooltip combo_printer->set_connection_tooltip(_L("Connect to printer")); combo_printer->set_machine_connecting_tooltip(_L("The machine has been connected and is currently in working mode")); @@ -2348,50 +2267,89 @@ Sidebar::Sidebar(Plater *parent) wxBoxSizer* vsizer_printer = new wxBoxSizer(wxVERTICAL); wxBoxSizer* hsizer_printer = new wxBoxSizer(wxHORIZONTAL); - wxBoxSizer* vsizer = new wxBoxSizer(wxVERTICAL); - wxBoxSizer* hsizer = new wxBoxSizer(wxHORIZONTAL); + // Thumbnail over the preset name, not beside it: the card is a picture of the machine + // with its name under it, and the row only has to be one card tall. + wxBoxSizer* vsizer_preset = new wxBoxSizer(wxVERTICAL); + // The thumbnail takes the air rather than the gap under it: a card that is a picture of + // the machine should be mostly machine. Stretch spacers above and below centre what is + // left over instead of pooling it all beneath the image. + vsizer_preset->AddSpacer(FromDIP(2)); + vsizer_preset->AddStretchSpacer(); + vsizer_preset->Add(p->image_printer, 0, wxALIGN_CENTER_HORIZONTAL); + vsizer_preset->AddStretchSpacer(); + vsizer_preset->Add(combo_printer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, FromDIP(4)); + p->panel_printer_preset->SetSizer(vsizer_preset); - // 简化后的布局:打印机图片 + combo_printer(内含所有按钮) - combo_printer->SetWindowStyle(combo_printer->GetWindowStyle() & ~wxALIGN_MASK | wxALIGN_LEFT); + hsizer_printer->Add(p->panel_printer_preset, 1, wxEXPAND, 0); - hsizer->Add(p->image_printer, 0, wxLEFT | wxALIGN_CENTER, FromDIP(4)); - hsizer->Add(combo_printer, 1, wxALIGN_CENTRE | wxLEFT | wxRIGHT, FromDIP(6)); - hsizer->AddSpacer(FromDIP(10)); - p->panel_printer_preset->SetSizer(hsizer); + /* ---------------------------- the plate card ---------------------------- */ + // Bed type used to own a whole row of the sidebar for one combo. As a card it costs + // nothing extra: it sits in the height the printer card already takes. + p->panel_plate_preset = new StaticBox(p->m_panel_printer_content); + p->panel_plate_preset->SetCornerRadius(8); + p->panel_plate_preset->SetBorderColor(panel_bd_col); + p->panel_plate_preset->SetMinSize(PLATE_PANEL_SIZE); + p->panel_plate_preset->SetMaxSize(PLATE_PANEL_SIZE); + p->panel_plate_preset->SetCursor(wxCURSOR_HAND); + // The picture is the target, not just the narrow combo under it - the same forwarding the + // printer card uses for its preset combo. The info glyph is a button and keeps its own + // clicks, so it still reaches the wiki rather than opening the list. + p->panel_plate_preset->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& evt) { + m_bed_type_list->wxEvtHandler::ProcessEvent(evt); + }); - hsizer_printer->Add(p->panel_printer_preset, 1, wxEXPAND, 0); - vsizer_printer->AddSpacer(FromDIP(4)); - vsizer_printer->Add(hsizer_printer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(4)); - vsizer_printer->AddSpacer(FromDIP(10)); - - /*vsizer_printer->AddSpacer(FromDIP(16)); - hsizer_printer->Add(p->image_printer, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(3)); - hsizer_printer->Add(combo_printer, 1, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(3)); - hsizer_printer->Add(edit_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(3)); - hsizer_printer->Add(FromDIP(8), 0, 0, 0, 0); - hsizer_printer->Add(connection_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(3)); - hsizer_printer->Add(machine_connecting_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(3)); - hsizer_printer->Add(FromDIP(8), 0, 0, 0, 0); - - vsizer_printer->Add(hsizer_printer, 0, wxEXPAND, 0);*/ - - // Bed type selection - // 创建一个像打印机选择那样的容器 - p->panel_printer_preset = new StaticBox(p->m_panel_printer_content, wxID_ANY, wxDefaultPosition, wxDefaultSize, - wxTAB_TRAVERSAL | wxBORDER_NONE); - p->panel_printer_preset->SetCornerRadius(8); - StateColor panel_bd_col1(std::pair(wxColour(0x00AE42), StateColor::Pressed), - std::pair(wxColour(0x00AE42), StateColor::Hovered), - std::pair(wxColour(0xEEEEEE), StateColor::Normal)); - // p->panel_printer_preset->SetBorderColor(panel_bd_col1); - // p->panel_printer_preset->SetMinSize(PRINTER_PANEL_SIZE_SMALL); - - // 创建Bed type选择控件 - wxBoxSizer* bed_type_sizer = new wxBoxSizer(wxHORIZONTAL); - wxStaticText* bed_type_title = new wxStaticText(p->panel_printer_preset, wxID_ANY, _L("Bed type")); - bed_type_title->Wrap(-1); - bed_type_title->SetFont(Label::Body_14); - m_bed_type_list = new ComboBox(p->panel_printer_preset, wxID_ANY, wxString(""), wxDefaultPosition, {-1, FromDIP(30)}, 0, nullptr, wxCB_READONLY); + // The swatch is drawn, not photographed: a plate picture would be either a vendor's + // product shot (which this repository cannot redistribute) or a new asset per bed type. + // A texture that says smooth-vs-grained is all the card needs to say at this size. + p->m_plate_swatch = new wxPanel(p->panel_plate_preset, wxID_ANY, wxDefaultPosition, PLATE_SWATCH_SIZE); + wxPanel* plate_swatch = p->m_plate_swatch; + plate_swatch->SetMinSize(wxSize(-1, FromDIP(40))); + plate_swatch->SetBackgroundStyle(wxBG_STYLE_PAINT); + plate_swatch->SetCursor(wxCURSOR_HAND); + plate_swatch->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& evt) { + m_bed_type_list->wxEvtHandler::ProcessEvent(evt); + }); + plate_swatch->Bind(wxEVT_PAINT, [plate_swatch](wxPaintEvent&) { + wxAutoBufferedPaintDC dc(plate_swatch); + dc.SetBackground(wxBrush(plate_swatch->GetParent()->GetBackgroundColour())); + dc.Clear(); + // Read the bed type itself, never the combo's index: a U1 is offered a different and + // shorter list (enum_values_u1) than every other printer, so position means nothing. + const BedType bt = wxGetApp().preset_bundle->project_config.opt_enum("curr_bed_type"); + wxBitmap bmp = plate_bitmap(plate_swatch, bt, 76); + if (!bmp.IsOk()) + return; + const wxRect r = plate_swatch->GetClientRect(); + if (r.width <= 0 || r.height <= 0) + return; + // Fitted to the panel rather than drawn at the size that was asked for. The height the + // swatch actually gets is whatever the card has left after the combo and the margins, + // and hard-coding a number larger than that is what clipped the plate top and bottom - + // DrawBitmap centres, so an oversized drawing loses both ends at once. + if (bmp.GetWidth() > r.width || bmp.GetHeight() > r.height) { + const double k = std::min(double(r.width) / bmp.GetWidth(), double(r.height) / bmp.GetHeight()); + wxImage img = bmp.ConvertToImage(); + img.Rescale(std::max(1, int(bmp.GetWidth() * k)), std::max(1, int(bmp.GetHeight() * k)), + wxIMAGE_QUALITY_HIGH); + bmp = wxBitmap(img); + } + dc.DrawBitmap(bmp, r.x + (r.width - bmp.GetWidth()) / 2, + r.y + (r.height - bmp.GetHeight()) / 2, true); + }); + + + // The wiki link keeps its home: the label was the link, and the card's info glyph is it now. + ScalableButton* plate_info_btn = new ScalableButton(p->panel_plate_preset, wxID_ANY, "info", wxEmptyString, + wxDefaultSize, wxDefaultPosition, + wxBU_EXACTFIT | wxNO_BORDER, false, 14); + plate_info_btn->SetCursor(wxCURSOR_HAND); + plate_info_btn->SetToolTip(_L("Bed types and what they are for")); + plate_info_btn->Bind(wxEVT_BUTTON, [](wxCommandEvent&) { + wxLaunchDefaultBrowser("https://github.com/SoftFever/OrcaSlicer/wiki/bed-types"); + }); + + m_bed_type_list = new ComboBox(p->panel_plate_preset, wxID_ANY, wxString(""), wxDefaultPosition, {-1, FromDIP(24)}, 0, nullptr, wxCB_READONLY); + m_bed_type_list->SetBorderWidth(0); const ConfigOptionDef* bed_type_def = print_config_def.get("curr_bed_type"); if (bed_type_def && bed_type_def->enum_keys_map) { for (const auto& item : bed_type_def->enum_labels) @@ -2399,25 +2357,8 @@ Sidebar::Sidebar(Plater *parent) for (const auto& v : bed_type_def->enum_values) m_bed_type_combo_enum_values.push_back(v); } - - // 添加链接事件等 - bed_type_title->Bind(wxEVT_ENTER_WINDOW, [bed_type_title, this](wxMouseEvent &e) { - e.Skip(); - auto font = bed_type_title->GetFont(); - font.SetUnderlined(true); - bed_type_title->SetFont(font); - SetCursor(wxCURSOR_HAND); - }); - bed_type_title->Bind(wxEVT_LEAVE_WINDOW, [bed_type_title, this](wxMouseEvent &e) { - e.Skip(); - auto font = bed_type_title->GetFont(); - font.SetUnderlined(false); - bed_type_title->SetFont(font); - SetCursor(wxCURSOR_ARROW); - }); - bed_type_title->Bind(wxEVT_LEFT_UP, [bed_type_title, this](wxMouseEvent &e) { - wxLaunchDefaultBrowser("https://github.com/SoftFever/OrcaSlicer/wiki/bed-types"); - }); + // Repaint the swatch whenever the choice changes; on_bed_type_change does the rest. + m_bed_type_list->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent& e) { refresh_plate_card(); e.Skip(); }); AppConfig *app_config = wxGetApp().app_config; std::string str_bed_type = app_config->get("curr_bed_type"); @@ -2430,41 +2371,146 @@ Sidebar::Sidebar(Plater *parent) int bed_type_idx = bed_type_value - 1; m_bed_type_list->Select(bed_type_idx); + refresh_plate_card(); + + // The glyph is positioned, not laid out. As a sizer row it took about a fifth of a 106px + // card to show a 14px button, and that height is the plate's - the card exists to be a + // picture of a plate. Overlaid it keeps the same corner and costs nothing. Repositioned on + // size because the card's width is not known until it has been laid out, and raised so the + // larger swatch cannot come out on top of it. + plate_info_btn->Raise(); + p->panel_plate_preset->Bind(wxEVT_SIZE, [plate_info_btn](wxSizeEvent& evt) { + evt.Skip(); + const wxSize card = evt.GetSize(); + const wxSize btn = plate_info_btn->GetSize(); + plate_info_btn->Move(card.x - btn.x - plate_info_btn->FromDIP(4), plate_info_btn->FromDIP(4)); + }); - // 布局Bed type控件 - bed_type_sizer->Add(bed_type_title, 0, wxLEFT | wxRIGHT | wxALIGN_CENTER_VERTICAL, FromDIP(10)); - bed_type_sizer->Add(m_bed_type_list, 1, wxLEFT | wxRIGHT | wxEXPAND, FromDIP(0)); - p->panel_printer_preset->SetSizer(bed_type_sizer); + // No stretch spacers around the swatch: they carry proportion 1 as well, so the leftover + // height was being split three ways and the drawing got a third of what the card had. + // The swatch takes all of it and the drawing centres itself inside. + wxBoxSizer* vsizer_plate = new wxBoxSizer(wxVERTICAL); + vsizer_plate->AddSpacer(FromDIP(5)); + vsizer_plate->Add(plate_swatch, 1, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(4)); + vsizer_plate->AddSpacer(FromDIP(2)); + vsizer_plate->Add(m_bed_type_list, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, FromDIP(4)); + p->panel_plate_preset->SetSizer(vsizer_plate); + + hsizer_printer->Add(p->panel_plate_preset, 0, wxEXPAND | wxLEFT, FromDIP(4)); + + /* ---------------------------- the sync card ---------------------------- */ + // Sync info, as a card beside the printer rather than an unlabelled glyph in the title + // bar. The glyph said nothing about what it syncs and was a 16px hit box; a card carries + // the word, matches the printer card's height, and takes the whole click. + // Bordered like the cards beside it - StaticBox zeroes its border width under + // wxBORDER_NONE, so passing that style here would draw the border colour set below nowhere. + p->m_panel_sync_info = new StaticBox(p->m_panel_printer_content); + p->m_panel_sync_info->SetCornerRadius(8); + p->m_panel_sync_info->SetBorderColor(panel_bd_col); + p->m_panel_sync_info->SetMinSize(SYNC_PANEL_SIZE); + p->m_panel_sync_info->SetMaxSize(SYNC_PANEL_SIZE); + p->m_panel_sync_info->SetCursor(wxCURSOR_HAND); + p->m_panel_sync_info->SetToolTip(_L("Synchronize printer information")); + + p->m_printerinfo_syncbtn = new ScalableButton(p->m_panel_sync_info, wxID_ANY, "nozzle_sync", wxEmptyString, + wxDefaultSize, wxDefaultPosition, + wxBU_EXACTFIT | wxNO_BORDER, false, 24); + p->m_printerinfo_syncbtn->SetCursor(wxCURSOR_HAND); + p->m_printerinfo_syncbtn->SetToolTip(_L("Synchronize printer information")); + p->m_printerinfo_syncbtn->Bind(wxEVT_BUTTON, [this](wxCommandEvent &) { sync_printer_info(); }); + + // LB_PROPAGATE_MOUSE_EVENT hands the label's clicks to the card, so the card's own + // handler is the only one - clicking the word cannot fire the sync twice. + auto sync_info_label = new Label(p->m_panel_sync_info, Label::Body_12, _L("Sync info"), LB_PROPAGATE_MOUSE_EVENT); + + wxBoxSizer* sync_info_sizer = new wxBoxSizer(wxVERTICAL); + sync_info_sizer->AddStretchSpacer(); + sync_info_sizer->Add(p->m_printerinfo_syncbtn, 0, wxALIGN_CENTER_HORIZONTAL); + sync_info_sizer->AddSpacer(FromDIP(4)); + sync_info_sizer->Add(sync_info_label, 0, wxALIGN_CENTER_HORIZONTAL); + sync_info_sizer->AddStretchSpacer(); + p->m_panel_sync_info->SetSizer(sync_info_sizer); + // Skip() matters: StaticBox's own state handler clears its pressed state on LEFT_UP, and + // this handler is bound later, so it runs first and would otherwise swallow that. The + // leave is synthesised because every path out of sync_printer_info() opens a modal: the + // pointer walks off to that dialog, the card never sees the crossing behind it, and it + // would sit drawn as hovered until the pointer happened to cross it again. + p->m_panel_sync_info->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent &e) { + e.Skip(); + wxMouseEvent leave(wxEVT_LEAVE_WINDOW); + leave.SetEventObject(p->m_panel_sync_info); + p->m_panel_sync_info->GetEventHandler()->ProcessEvent(leave); + sync_printer_info(); + }); - // 添加到垂直布局 - vsizer_printer->Add(p->panel_printer_preset, 0, wxEXPAND | wxALL, FromDIP(4)); + // The gap belongs to the card, not to the row: a plain spacer would survive the card + // being hidden on a non-U1 printer and leave 4px of nothing beside the plate card. + hsizer_printer->Add(p->m_panel_sync_info, 0, wxEXPAND | wxLEFT, FromDIP(4)); + + vsizer_printer->AddSpacer(FromDIP(4)); + vsizer_printer->Add(hsizer_printer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(4)); vsizer_printer->AddSpacer(FromDIP(8)); auto& project_config = wxGetApp().preset_bundle->project_config; BedType bed_type = (BedType)bed_type_value; project_config.set_key_value("curr_bed_type", new ConfigOptionEnum(bed_type)); - p->m_panel_printer_content->SetSizer(vsizer_printer); - p->m_panel_printer_content->Layout(); - scrolled_sizer->Add(p->m_panel_printer_content, 0, wxEXPAND, 0); + /* ---------------------------- the ACE mode row ---------------------------- */ + // The printer's own three-way switch, mirrored. It decides whether per-head wiring means + // anything at all, so it sits above the head boxes and greys their ACE rows in Normal. + p->m_panel_ace_mode = new wxPanel(p->m_panel_printer_content, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL); + p->m_panel_ace_mode->SetBackgroundColour(p->m_panel_printer_content->GetBackgroundColour()); + + auto ace_mode_label = new Label(p->m_panel_ace_mode, Label::Body_14, _L("ACE mode")); + p->m_ace_mode_list = new ComboBox(p->m_panel_ace_mode, wxID_ANY, wxString(""), wxDefaultPosition, {-1, FromDIP(30)}, 0, nullptr, wxCB_READONLY); + p->m_ace_mode_hint = new Label(p->m_panel_ace_mode, Label::Body_10, wxEmptyString); + p->m_ace_mode_hint->SetForegroundColour(wxColour(0x6B, 0x72, 0x76)); + + const ConfigOptionDef *ace_mode_def = print_config_def.get("ace_mode"); + if (ace_mode_def) + for (const auto &item : ace_mode_def->enum_labels) + p->m_ace_mode_list->AppendString(_L(item)); + p->m_ace_mode_list->SetToolTip(_L("SET_ACE_MODE MODE=normal|multi|head, the printer's own switch. " + "Setting it here records the wiring in the printer preset; it is not " + "sent to the machine yet.")); + p->m_ace_mode_list->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent &e) { + e.Skip(); + const int sel = p->m_ace_mode_list->GetSelection(); + if (sel < 0) + return; + auto &printers = wxGetApp().preset_bundle->printers; + printers.get_edited_preset().config.set_key_value("ace_mode", new ConfigOptionEnum(AceMode(sel))); + wxGetApp().get_tab(Preset::TYPE_PRINTER)->update_dirty(); + update_nozzle_settings(); + }); - // 创建Nozzle notebook的容器 - StaticBox* nozzle_container = new StaticBox(p->m_panel_printer_content, wxID_ANY, wxDefaultPosition, wxDefaultSize, - wxTAB_TRAVERSAL | wxBORDER_NONE); - nozzle_container->SetCornerRadius(8); - // nozzle_container->SetBorderColor(panel_bd_col); + wxBoxSizer* ace_mode_sizer = new wxBoxSizer(wxVERTICAL); + ace_mode_sizer->Add(ace_mode_label, 0, wxLEFT | wxBOTTOM, FromDIP(2)); + ace_mode_sizer->Add(p->m_ace_mode_list, 0, wxEXPAND); + ace_mode_sizer->Add(p->m_ace_mode_hint, 0, wxTOP | wxLEFT, FromDIP(3)); + p->m_panel_ace_mode->SetSizer(ace_mode_sizer); + vsizer_printer->Add(p->m_panel_ace_mode, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(8)); + vsizer_printer->AddSpacer(FromDIP(8)); - // 创建notebook - p->m_nozzle_notebook = new CustomNotebook(nozzle_container, wxID_ANY); + /* ---------------------------- the head boxes ---------------------------- */ + // One bordered box per toolhead, wrapped two across. update_nozzle_settings() fills it, + // because the number of heads follows the preset's nozzle_diameter. + p->m_panel_heads = new wxPanel(p->m_panel_printer_content, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL); + p->m_panel_heads->SetBackgroundColour(p->m_panel_printer_content->GetBackgroundColour()); + p->m_heads_sizer = new wxGridSizer(0, 2, FromDIP(4), FromDIP(4)); + p->m_panel_heads->SetSizer(p->m_heads_sizer); + vsizer_printer->Add(p->m_panel_heads, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, FromDIP(8)); - // 创建nozzle_sizer并添加notebook - wxBoxSizer* nozzle_sizer = new wxBoxSizer(wxVERTICAL); - nozzle_sizer->Add(p->m_nozzle_notebook, 1, wxEXPAND | wxALL, FromDIP(0)); - nozzle_container->SetSizer(nozzle_sizer); - nozzle_container->SetMinSize(wxSize(-1, FromDIP(80))); + p->m_panel_printer_content->SetSizer(vsizer_printer); + p->m_panel_printer_content->Layout(); + scrolled_sizer->Add(p->m_panel_printer_content, 0, wxEXPAND, 0); - // 添加到主布局 - vsizer_printer->Add(nozzle_container, 0, wxEXPAND | wxALL, FromDIP(4)); + // update_presets() settles this on every preset change; do it once here too, or the card + // is briefly on screen for a printer that has nothing to sync. + const auto *sync_printer_model = wxGetApp().preset_bundle->printers.get_edited_preset() + .config.option("printer_model"); + p->m_panel_sync_info->Show(sync_printer_model && boost::icontains(sync_printer_model->value, "Snapmaker") && + boost::icontains(sync_printer_model->value, "U1")); // Initialize nozzle settings update_nozzle_settings(); @@ -3045,6 +3091,14 @@ Sidebar::Sidebar(Plater *parent) }); combobox->edit_btn = edit_btn; + // The mark rides on the combo's own corner, so it joins no sizer. + { + auto* sync_mark = new SyncMarkOverlay(combobox, 4.0); + sync_mark->SetToolTip(_L("This filament is loaded on the printer")); + if (int(p->filament_sync_marks.size()) <= 0) + p->filament_sync_marks.resize(1, nullptr); + p->filament_sync_marks[0] = sync_mark; + } combo_and_btn_sizer->Add(edit_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(SidebarProps::ElementSpacing()) - FromDIP(2)); // ElementSpacing - 2 (from combo box)) combo_and_btn_sizer->AddSpacer(FromDIP(SidebarProps::ContentMargin())); @@ -3473,6 +3527,14 @@ void Sidebar::init_filament_combo(PlaterPresetComboBox **combo, const int filame combobox->edit_btn = edit_btn; + // The mark rides on the combo's own corner, so it joins no sizer. + { + auto* sync_mark = new SyncMarkOverlay(combobox, 4.0); + sync_mark->SetToolTip(_L("This filament is loaded on the printer")); + if (int(p->filament_sync_marks.size()) <= filament_idx) + p->filament_sync_marks.resize(filament_idx + 1, nullptr); + p->filament_sync_marks[filament_idx] = sync_mark; + } combo_and_btn_sizer->Add(edit_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(SidebarProps::ElementSpacing()) - FromDIP(2)); // ElementSpacing - 2 (from combo box)) combo_and_btn_sizer->AddSpacer(FromDIP(SidebarProps::ContentMargin())); @@ -3717,6 +3779,7 @@ void Sidebar::update_all_preset_comboboxes(bool reload_printer_view) m_bed_type_list->SetSelection(get_selection_index()); m_bed_type_list->Disable(); } + refresh_plate_card(); if (print_tech == ptFFF) { for (PlaterPresetComboBox* cb : p->combos_filament) @@ -3730,6 +3793,10 @@ void Sidebar::update_all_preset_comboboxes(bool reload_printer_view) p_mainframe->show_device(preset_bundle.use_bbl_device_tab() && !use_new_connection); p_mainframe->m_tabpanel->SetSelection(p_mainframe->m_tabpanel->GetSelection()); + + // A filament that has just been switched or recoloured may no longer be one the printer has; + // the mark is a diff, so it is recomputed wherever the filaments can have moved. + refresh_filament_sync_marks(); } void Sidebar::update_presets(Preset::Type preset_type) @@ -3796,18 +3863,16 @@ void Sidebar::update_presets(Preset::Type preset_type) auto printer_config = wxGetApp().preset_bundle->printers.get_edited_preset().config; auto printer_model_opt = printer_config.option("printer_model"); - if (printer_model_opt) + if (printer_model_opt && p->m_panel_sync_info) { std::string printer_model = printer_model_opt->value; bool is_snapmaker_u1 = boost::icontains(printer_model, "Snapmaker") && boost::icontains(printer_model, "U1"); - if (is_snapmaker_u1) - { - p->m_printerinfo_syncbtn->Show(); - } - else - { - p->m_printerinfo_syncbtn->Hide(); + // The card sits in the printer row and takes width from it, so hiding it has to + // re-lay the row out - the title-bar glyph it replaces never did. + if (p->m_panel_sync_info->IsShown() != is_snapmaker_u1) { + p->m_panel_sync_info->Show(is_snapmaker_u1); + p->m_panel_printer_content->Layout(); } } @@ -8953,6 +9018,7 @@ void Sidebar::on_bed_type_change(BedType bed_type) int sel_idx = (int)bed_type - 1; if (m_bed_type_list != nullptr) m_bed_type_list->SetSelection(sel_idx); + refresh_plate_card(); } std::map Sidebar::build_filament_ams_list(MachineObject* obj) @@ -9138,7 +9204,22 @@ void Sidebar::show_sync_filament_dialog() } } - if (!host && !device_machine) { + // The ACE half of the inventory does not come through either of those: it is read from the + // machine's own service over plain HTTP. Sync info usually leaves a snapshot behind; without + // one, ask now, because whether that answers decides whether "not connected" is even true. + AceMmu::AceSnapshot ace_snap; + bool have_ace = p->m_ace_read; + if (have_ace) { + ace_snap = p->m_ace_snapshot; + } else if (const std::string ace_host = AceMmuProvider::resolve_connected_host(); !ace_host.empty()) { + AceMmuProvider prov(ace_host); + if (prov.fetch_once(2, 4)) { + ace_snap = prov.snapshot(); + have_ace = true; + } + } + + if (!host && !device_machine && !have_ace) { SyncRichConfirmDialog dlg(this, _L("No printer is connected. Please connect your U1 from the Device page before syncing."), wxYES_NO); @@ -9191,6 +9272,11 @@ void Sidebar::show_sync_filament_dialog() std::vector machineFilamentList; build_machine_filament_list(preset_bundle, machineFilamentList); + // Both sources, in one list: the toolheads the bridge reported, relabelled with where each + // one is fed from, then every ACE slot. Reuses the snapshot Sync info took when this dialog + // is the second step of that press, so the two halves cannot disagree about the machine. + if (have_ace) + append_ace_filament_list(machineFilamentList, &ace_snap); auto nonEmptyFilaments = [](const std::vector& filamentDatas) { for (const auto& filament : filamentDatas) { if (!is_none_filament(filament)) @@ -9233,7 +9319,10 @@ void Sidebar::show_sync_filament_dialog() dlg.setMixedFilamentInfos(mixedInfos); { if (wxGetApp().plater()->model().objects.empty()) { - dlg.setOverwriteMode(); + // Nothing on the plate: no object references a filament index, so the project can take + // the machine's whole inventory rather than the count it happened to be carrying. With + // objects present the mapping dialog opens instead, unchanged. + dlg.setOverwriteMode(/*whole_machine=*/true); } else { dlg.CentreOnScreen(); if (dlg.ShowModal() != wxID_OK) @@ -9313,6 +9402,8 @@ void Sidebar::show_sync_filament_dialog() NotificationType::CustomNotification, NotificationManager::NotificationLevel::RegularNotificationLevel, _u8L("Filament types and colors have been successfully synced from the printer.")); + + refresh_filament_sync_marks(); } } @@ -9327,52 +9418,533 @@ void Sidebar::show_SEMM_buttons(bool bshow) Layout(); } +// The card paints from curr_bed_type, and nothing else tells it that has moved. +void Sidebar::refresh_plate_card() +{ + if (p->m_plate_swatch) + p->m_plate_swatch->Refresh(); +} + void Sidebar::update_dynamic_filament_list() { dynamic_filament_list.update(); dynamic_filament_list_1_based.update(); } +// Read the connected machine's nozzle information into the preset. Lifted verbatim out of the +// title-bar button's lambda when that button became the Sync info card, so the card and the icon +// on it reach one place instead of two copies of it. +void Sidebar::sync_printer_info() +{ + bool hasConnectDevice = false; + auto devices = wxGetApp().app_config->get_devices(); + for (const auto& device : devices) { + if (device.connected) + hasConnectDevice = true; + } + + // No early return on a missing bridge. The nozzle half of this press goes through the webview + // connection; the ACE half only needs the machine to be on the network, and refusing to ask a + // printer that is answering on its own address would be a gate on the wrong thing. Not being + // connected is one of the outcomes this press reports, not a reason to skip it. + std::string machine_type = ""; + std::vector nozzle_diameters; + std::string device_name = ""; + std::shared_ptr host = nullptr; + bool got_machine_info = false; + if (hasConnectDevice) { + wxGetApp().get_connect_host(host); + got_machine_info = SSWCP::query_machine_info(host, machine_type, nozzle_diameters, device_name); + } + + const auto& sync_nozzle_slots = wxGetApp().preset_bundle->m_connect_machine_info_list; + if (!sync_nozzle_slots.empty()) { + nozzle_diameters.clear(); + for (const auto& slot : sync_nozzle_slots) { + std::string nd = slot.nozzle_info; + boost::algorithm::trim(nd); + if (nd.size() > 2 && boost::iends_with(nd, "mm")) { + nd.resize(nd.size() - 2); + boost::algorithm::trim(nd); + } + if (!nd.empty()) + nozzle_diameters.push_back(nd); + } + } + // One press, two reads, and they are independent. The nozzles came back over the webview + // bridge above; the ACE mode and wiring live in the printer's own multiACE service, which + // answers plain HTTP and is the only thing that knows which unit feeds which head. Reading it + // outside the branch below means a machine on the LAN can still describe its wiring when the + // bridge has not connected - which is exactly when the panel has least to go on. + p->m_machine_model = machine_type; + p->m_machine_nozzles = nozzle_diameters; + p->m_machine_read = got_machine_info && !nozzle_diameters.empty(); + p->m_ace_read = false; + const std::string ace_host = AceMmuProvider::resolve_connected_host(); + if (ace_host.empty()) { + BOOST_LOG_TRIVIAL(info) << "sync_printer_info: no host to read the multiACE state from"; + } else { + // Short timeouts, because this blocks the GUI thread: a machine that answered the webview + // may still have its multiACE service off, and the press must not hang on it. + AceMmuProvider prov(ace_host); + if (prov.fetch_once(2, 4)) { + p->m_ace_snapshot = prov.snapshot(); + p->m_ace_read = true; + BOOST_LOG_TRIVIAL(info) << "sync_printer_info: multiACE state read from " << ace_host + << "; mode=" << p->m_ace_snapshot.mode + << " device_count=" << p->m_ace_snapshot.device_count; + } + } + + if (got_machine_info && machine_type == "Snapmaker U1") + { + if (nozzle_diameters.size() <= 0) + { + wxTheApp->CallAfter([this]() { + MessageDialog dlgEx(wxGetApp().mainframe, + _L("No nozzle information detected. Please go to the printer settings to configure the nozzle."), + _L("Note"), wxOK); + dlgEx.ShowModal(); + }); + + return; + } + + bool res = false; + std::string headNozzleSize = nozzle_diameters[0]; + for (int i = 1; i < nozzle_diameters.size(); i++) + { + if (headNozzleSize != nozzle_diameters[i]) + { + res = true; + break; + } + } + + if (res) + { + std::vector diameters_raw = nozzle_diameters; + //std::vector diameters_raw = {"0.2", "0.8"}; + wxTheApp->CallAfter([this, diameters_raw]() { + NozzleDiameterSelectDialog dlg( + wxGetApp().mainframe, + _L("Note: Inconsistent nozzle diameters. Current version does not support mixed diameter printing. Please select one nozzle for this print."), + _L("Set Nozzle Diameter"), + diameters_raw); + if (dlg.ShowModal() == wxID_OK) { + std::string sel = dlg.GetSelectedDiameter(); + if (!sel.empty()) { + auto preset = wxGetApp().preset_bundle->get_similar_printer_preset({}, sel); + if (preset) { + preset->is_visible = true; + + auto diameter = sel; + auto preset = wxGetApp().preset_bundle->get_similar_printer_preset({}, diameter); + if (preset == nullptr) { + BOOST_LOG_TRIVIAL(error) << "get the similar printer preset fail"; + return; + } + preset->is_visible = true; // force visible + + for (size_t i = 0; i < p->m_nozzle_diameter_lists.size(); ++i) { + p->m_nozzle_diameter_lists[i]->SetValue(diameter + "mm"); + } + + wxGetApp().get_tab(Preset::TYPE_PRINTER)->select_preset(preset->name); + wxGetApp().plater()->sidebar().update_all_preset_comboboxes(true); + wxGetApp().plater()->sidebar().update_nozzle_settings(true); + + // After the preset switch, never before: the ACE keys belong to the + // preset being switched to, and writing them first would follow the + // old one out. + wxTheApp->CallAfter([this]() { finish_printer_sync(); }); + } + } + } + }); + return; + } + else { + // All tool heads report the same diameter: apply it without opening the picker. + std::string diameter = headNozzleSize; + boost::algorithm::trim(diameter); + if (diameter.size() > 2 && boost::iends_with(diameter, "mm")) { + diameter.resize(diameter.size() - 2); + boost::algorithm::trim(diameter); + } + wxTheApp->CallAfter([this, diameter]() { + auto preset = wxGetApp().preset_bundle->get_similar_printer_preset({}, diameter); + if (preset == nullptr) { + BOOST_LOG_TRIVIAL(error) << "get the similar printer preset fail (uniform nozzle sync)"; + return; + } + preset->is_visible = true; + + for (size_t i = 0; i < p->m_nozzle_diameter_lists.size(); ++i) + p->m_nozzle_diameter_lists[i]->SetValue(diameter + "mm"); + + wxGetApp().get_tab(Preset::TYPE_PRINTER)->select_preset(preset->name); + wxGetApp().plater()->sidebar().update_all_preset_comboboxes(true); + wxGetApp().plater()->sidebar().update_nozzle_settings(true); + + wxTheApp->CallAfter([this]() { finish_printer_sync(); }); + }); + } + } + else if (p->m_ace_read) { + // The bridge did not name a U1 - it may not have connected at all - but the machine's own + // multiACE service answered, so the half of the press that does not need the bridge still + // runs rather than the card doing nothing. + wxTheApp->CallAfter([this]() { finish_printer_sync(); }); + } + else { + // Neither read worked. Pressing a card and getting nothing back is the one outcome that + // reads as a broken button, so say which half failed. + wxTheApp->CallAfter([this]() { + MessageDialog dlg(wxGetApp().mainframe, + _L("Nothing could be read from the printer.\n\n" + "Check that it is switched on and on the same network. Nozzle sizes and " + "filaments also need it connected from the home page or the Device page."), + _L("Sync info"), wxOK); + dlg.ShowModal(); + }); + } +} + +// The second half of one press. The nozzle half has finished - possibly by switching the printer +// preset - so the ACE mode and wiring read from the machine go into whichever preset is now +// selected, and the press ends by offering the filament sync rather than stopping at an OK. +// +// The machine is authoritative: SET_ACE_MODE and ACE_SET_HEAD_ACE persist the wiring there, and +// the preset is a cache that lets slicing work with the printer switched off. So divergence is +// resolved towards the machine, and named line by line rather than corrected quietly - a wrong +// unit here becomes a wrong ACE= argument in the gcode. +void Sidebar::finish_printer_sync() +{ + wxString changes; + + if (p->m_ace_read) { + auto& printers = wxGetApp().preset_bundle->printers; + const DynamicConfig& cfg = printers.get_edited_preset().config; + + const auto* nozzle_diameter = cfg.option("nozzle_diameter"); + const size_t head_count = nozzle_diameter ? nozzle_diameter->values.size() : 0; + const AceMmu::AceTopology topo = AceMmu::ace_topology_of(p->m_ace_snapshot, head_count); + + const auto* ace_mode_opt = cfg.option>("ace_mode"); + const AceMode was_mode = ace_mode_opt ? AceMode(ace_mode_opt->value) : amNormal; + if (was_mode != topo.mode) { + const ConfigOptionDef* def = print_config_def.get("ace_mode"); + const auto label = [def](AceMode m) { + return def && size_t(m) < def->enum_labels.size() ? _L(def->enum_labels[m]) : wxString(); + }; + changes += wxString::Format(_L(" ACE mode: %s -> %s\n"), label(was_mode), label(topo.mode)); + } + + const auto* head_unit = cfg.option("ace_head_unit"); + const auto* head_cap = cfg.option("ace_head_capacity"); + for (size_t h = 0; h < head_count; ++h) { + const int was_cap = (head_cap && h < head_cap->values.size()) ? head_cap->values[h] : 1; + const int was_unit = (head_unit && h < head_unit->values.size()) ? head_unit->values[h] : -1; + if (was_cap != topo.cap[h] || (topo.cap[h] > 1 && was_unit != topo.unit[h])) + changes += wxString::Format(_L(" Toolhead %d: %s -> %s\n"), int(h) + 1, + ace_wiring_label(was_unit, was_cap), + ace_wiring_label(topo.unit[h], topo.cap[h])); + } + + if (!changes.IsEmpty()) { + // Written through the edited preset the ACE mode combo already writes to - that is the + // same object the printer Tab holds as m_config - then the Tab is told to reread it, so + // the Multimaterial page and the dirty markers do not keep the old values on screen. + DynamicConfig& edited = printers.get_edited_preset().config; + edited.set_key_value("ace_mode", new ConfigOptionEnum(topo.mode)); + edited.set_key_value("ace_head_unit", new ConfigOptionInts(topo.unit)); + edited.set_key_value("ace_head_capacity", new ConfigOptionInts(topo.cap)); + if (Tab* tab = wxGetApp().get_tab(Preset::TYPE_PRINTER)) { + tab->reload_config(); + tab->update_dirty(); + } + } + } + + // Repaint with what was just read: the ACE rows, the corner ticks that are now a diff against + // a machine this panel has actually spoken to, and the filament rows' own marks. + update_nozzle_settings(); + refresh_filament_sync_marks(); + + // Say which halves ran. Both can fail independently - the nozzles come over the webview + // bridge, the ACE state over the machine's own HTTP service - and a message claiming the one + // that did not is what makes an absent corner tick look like a bug rather than an answer. + wxString msg; + if (p->m_ace_read && p->m_machine_read) + msg = _L("Successfully synchronized nozzle, ACE mode and ACE unit information."); + else if (p->m_ace_read) + msg = _L("Synchronized the ACE mode and ACE unit information.") + "\n\n" + + _L("The nozzle sizes could not be read: the printer is not connected on the Device page."); + else + msg = _L("Nozzle settings synchronized successfully.") + "\n\n" + + _L("The printer's multiACE service did not answer, so the ACE mode and wiring were " + "left as they are."); + + if (p->m_ace_read) + msg += "\n\n" + (changes.IsEmpty() ? _L("The preset already matched the printer's ACE wiring.") + : _L("Read from the printer:") + "\n" + changes); + + // RichMessageDialog, not MessageDialog: only the rich one carries SetYesNoLabels, and the + // step has to name what it continues to rather than offering a bare Yes. + RichMessageDialog dlg(wxGetApp().mainframe, msg, _L("Sync info"), wxYES_NO); + dlg.SetYesNoLabels(_L("Continue to sync filaments"), _L("Cancel")); + if (dlg.ShowModal() == wxID_YES) + show_sync_filament_dialog(); +} + +// The assign popover. Its two kinds of row write exactly `ace_head_unit` and `ace_head_capacity`, +// which is all "which ACE feeds this toolhead" is in the preset - and the printer is not told: +// the machine is authoritative for its own wiring, so this records what the user believes it to be +// and Sync info is what reconciles the two. +// Which project filaments the machine actually has loaded. +// +// The same claim the corner ticks make, one row lower: this is a diff against what was read, not +// a record that a sync once happened. Edit a filament's colour or preset afterwards and its mark +// goes, because it no longer names anything on the printer. +// +// Matched on type and colour, which is all the sync itself writes - it says so on its own dialog: +// "Only filament types and colors are synchronized". Nothing here fetches; a machine that has not +// been read marks nothing. +void Sidebar::refresh_filament_sync_marks() +{ + if (p->filament_sync_marks.empty()) + return; + + std::vector machine; + if (p->m_ace_read || !wxGetApp().preset_bundle->m_connect_machine_info_list.empty()) { + build_machine_filament_list(wxGetApp().preset_bundle, machine); + if (p->m_ace_read) + append_ace_filament_list(machine, &p->m_ace_snapshot); + } + + std::vector project; + build_design_filament_list(wxGetApp().preset_bundle, project); + + for (size_t i = 0; i < p->filament_sync_marks.size(); ++i) { + SyncMarkOverlay* mark = p->filament_sync_marks[i]; + if (!mark) + continue; + + bool synced = false; + if (i < project.size()) { + const wxColour pc = getMainColor(project[i].m_color); + for (const FilamentData& m : machine) { + // A row the user could not have picked is not something to claim agreement with: + // an empty slot, an ACE-fed head, or the Assign None action. + if (is_none_filament(m) || m.m_disabled) + continue; + if (m.m_type != project[i].m_type) + continue; + const wxColour mc = getMainColor(m.m_color); + if (mc.Red() == pc.Red() && mc.Green() == pc.Green() && mc.Blue() == pc.Blue()) { + synced = true; + break; + } + } + } + mark->SetSynced(synced); + } +} + +void Sidebar::show_ace_assign_popup(size_t head_idx, wxWindow* anchor) +{ + auto& printers = wxGetApp().preset_bundle->printers; + const DynamicConfig& cfg = printers.get_edited_preset().config; + + const auto* nozzle_diameter = cfg.option("nozzle_diameter"); + const size_t head_count = nozzle_diameter ? nozzle_diameter->values.size() : 0; + if (head_idx >= head_count) + return; + + // Read into full-length vectors first: a preset that has never been told about ACE carries the + // one-element default, and writing a short vector back would leave three heads unaddressable. + std::vector units(head_count, -1), caps(head_count, 1); + if (const auto* opt = cfg.option("ace_head_unit")) + for (size_t h = 0; h < head_count && h < opt->values.size(); ++h) + units[h] = opt->values[h]; + if (const auto* opt = cfg.option("ace_head_capacity")) + for (size_t h = 0; h < head_count && h < opt->values.size(); ++h) + caps[h] = opt->values[h]; + + // Parented to the sidebar, not to the head box: choosing rebuilds the head boxes, and a popup + // whose parent is destroyed under it takes the app with it. + auto* popup = new AceAssignPopup(this, head_idx, units, caps, p->m_ace_snapshot, p->m_ace_read); + popup->on_choice([this, head_idx, units, caps](int unit, int cap) mutable { + if (units[head_idx] == unit && caps[head_idx] == cap) + return; // nothing moved; leave the preset clean rather than marking it dirty + units[head_idx] = unit; + caps[head_idx] = cap; + + DynamicConfig& edited = wxGetApp().preset_bundle->printers.get_edited_preset().config; + edited.set_key_value("ace_head_unit", new ConfigOptionInts(units)); + edited.set_key_value("ace_head_capacity", new ConfigOptionInts(caps)); + if (Tab* tab = wxGetApp().get_tab(Preset::TYPE_PRINTER)) { + tab->reload_config(); + tab->update_dirty(); + } + // After the popup has finished dismissing itself: this rebuilds the box the click came from. + CallAfter([this]() { update_nozzle_settings(); }); + }); + popup->popup_at(anchor); +} + void Sidebar::update_nozzle_settings(bool switch_machine) { - if (!p->m_nozzle_notebook) + if (!p->m_panel_heads || !p->m_heads_sizer) return; - // Get new nozzle count - auto* nozzle_diameter = dynamic_cast( - wxGetApp().preset_bundle->printers.get_edited_preset().config.option("nozzle_diameter")); - size_t new_nozzle_count = nozzle_diameter ? nozzle_diameter->values.size() : 1; + const Preset& printer_preset = wxGetApp().preset_bundle->printers.get_edited_preset(); + const DynamicConfig& cfg = printer_preset.config; + + auto* nozzle_diameter = dynamic_cast(cfg.option("nozzle_diameter")); + size_t head_count = nozzle_diameter ? nozzle_diameter->values.size() : 1; + + // The machine's own interface calls these Toolhead N - see the strings in + // resources/web/flutter_web/assets/assets/i10n/en.json. A two-headed IDEX machine keeps the + // Left/Right names it has always had, because there the label names a position. + const auto *printer_model = cfg.option("printer_model"); + const bool is_snapmaker_u1 = printer_model && boost::icontains(printer_model->value, "Snapmaker") && + boost::icontains(printer_model->value, "U1"); + + const auto *ace_mode_opt = cfg.option>("ace_mode"); + const AceMode ace_mode = ace_mode_opt ? AceMode(ace_mode_opt->value) : amNormal; + const auto *head_unit = cfg.option("ace_head_unit"); + const auto *head_cap = cfg.option("ace_head_capacity"); + + // The mode row belongs to a machine that has ACE units at all. Everything else keeps a + // sidebar with no multiACE vocabulary in it. + if (p->m_panel_ace_mode) { + if (p->m_panel_ace_mode->IsShown() != is_snapmaker_u1) + p->m_panel_ace_mode->Show(is_snapmaker_u1); + if (is_snapmaker_u1 && p->m_ace_mode_list) { + p->m_ace_mode_list->SetSelection(int(ace_mode)); + switch (ace_mode) { + case amHead: p->m_ace_mode_hint->SetLabel(_L("Each head is a feeder, or wired to one ACE")); break; + case amMulti: p->m_ace_mode_hint->SetLabel(_L("Units pooled onto a single ACE head")); break; + default: p->m_ace_mode_hint->SetLabel(_L("Stock feeders only - no ACE")); break; + } + } + } + + // The corner ticks, computed only from what Sync info actually read. A machine nobody has + // spoken to gets no marks at all - the absence is the honest answer, not a defect. + if (p->panel_printer_preset) { + // The card agrees when the selected preset is the machine's own model at the nozzle size + // every head reports. A mixed set never agrees: the preset cannot hold one. + bool printer_agrees = p->m_machine_read && printer_model && + boost::iequals(printer_model->value, p->m_machine_model) && + !p->m_machine_nozzles.empty(); + if (printer_agrees) { + const auto* pv = cfg.option("printer_variant"); + for (const std::string& nd : p->m_machine_nozzles) + if (!pv || nd != pv->value) { + printer_agrees = false; + break; + } + } + p->panel_printer_preset->SetSynced(printer_agrees); + } + const AceMmu::AceTopology machine_topo = p->m_ace_read ? AceMmu::ace_topology_of(p->m_ace_snapshot, head_count) : AceMmu::AceTopology(); - // Clear existing pages and controls - p->m_nozzle_notebook->DeleteAllPages(); + p->m_heads_sizer->Clear(true); p->m_nozzle_diameter_lists.clear(); p->m_nozzle_edit_btns.clear(); - // Recreate pages for new nozzle count - // Create tabs for each nozzle - for (size_t i = 0; i < new_nozzle_count; i++) { - wxPanel* nozzle_panel = new wxPanel(p->m_nozzle_notebook, wxID_ANY, wxDefaultPosition, wxDefaultSize, - wxTAB_TRAVERSAL | wxBORDER_NONE); - // nozzle_panel->SetBackgroundColour(wxColour(255, 255, 255)); - - wxBoxSizer* tab_sizer = new wxBoxSizer(wxHORIZONTAL); + const bool is_dark = wxGetApp().app_config->get("dark_color_mode") == "1"; + const wxColour row_label_colour = is_dark ? wxColour(194, 194, 194) : wxColour(0, 0, 0); + + StateColor head_bd_col(std::pair(wxColour(0x00AE42), StateColor::Hovered), + std::pair(wxColour(0xEEEEEE), StateColor::Normal)); + + for (size_t i = 0; i < head_count; i++) { + SyncMarkBox* head_box = new SyncMarkBox(p->m_panel_heads); + head_box->SetCornerRadius(8); + head_box->SetBorderColor(head_bd_col); + // Marked when this head's wiring is what the printer last reported. Only a U1 has wiring + // to be right about; every other machine's boxes hold a diameter and nothing to check. + head_box->SetSynced(is_snapmaker_u1 && p->m_ace_read && AceMmu::ace_head_agrees(cfg, machine_topo, i)); + + wxString head_name; + if (is_snapmaker_u1) + head_name = wxString::Format(_L("Toolhead %d"), int(i) + 1); + else if (head_count == 2) + head_name = (i == 0) ? _L("Left Nozzle") : _L("Right Nozzle"); + else if (head_count > 2) + head_name = wxString(_L("Nozzle")) + wxString::Format(" %d", i + 1); + else + head_name = _L("Nozzle"); + + auto* head_label = new Label(head_box, Label::Head_12, head_name); + head_label->SetForegroundColour(wxColour(0x4A, 0x52, 0x58)); + + wxBoxSizer* box_sizer = new wxBoxSizer(wxVERTICAL); + box_sizer->AddSpacer(FromDIP(4)); + box_sizer->Add(head_label, 0, wxLEFT | wxRIGHT, FromDIP(8)); + + // The ACE row - what feeds this head. It reads the preset only: choosing a unit is the + // assign popover, which arrives with the ACE row's live contents. In Normal mode no head + // is wired to anything, so the row says so rather than showing a stale unit. + if (is_snapmaker_u1) { + const int unit = (head_unit && i < head_unit->values.size()) ? head_unit->values[i] : -1; + const int cap = (head_cap && i < head_cap->values.size()) ? head_cap->values[i] : 1; + const bool wired = ace_mode != amNormal && unit >= 0 && cap > 1; + + auto* ace_key = new Label(head_box, Label::Body_12, _L("ACE")); + ace_key->SetForegroundColour(row_label_colour); + ace_key->SetMinSize(wxSize(FromDIP(34), -1)); + + // One adjust button per head, in the same place whether a badge or the words Stock + // feeder follow it, so it can be found without reading the row first. Disabled in + // Normal mode, where no head is wired to anything and the choice would mean nothing. + auto* ace_edit = new ScalableButton(head_box, wxID_ANY, "edit", wxEmptyString, wxDefaultSize, + wxDefaultPosition, wxBU_EXACTFIT | wxNO_BORDER, false, 14); + ace_edit->SetToolTip(_L("Which ACE feeds this toolhead")); + ace_edit->SetCursor(wxCURSOR_HAND); + ace_edit->Enable(ace_mode != amNormal); + ace_edit->Bind(wxEVT_BUTTON, [this, i, ace_edit](wxCommandEvent&) { show_ace_assign_popup(i, ace_edit); }); + + // The badge carries the unit's own slot colours when the panel has read the machine, + // and the disabled greys when it has not - four empty bays would be a claim. + wxWindow* ace_val = nullptr; + if (wired) { + auto* badge = new AceBadge(head_box, 22); + const AceMmu::AceUnit* live = p->m_ace_read ? p->m_ace_snapshot.find_unit(unit) : nullptr; + if (live) + badge->SetUnit(*live); + else + badge->SetUnknown(); + badge->SetToolTip(wxString::Format(_L("ACE %d"), unit + 1) + " " + ace_sep() + " " + + wxString::Format("%d ", cap) + _L("slots")); + badge->SetCursor(wxCURSOR_HAND); + badge->Bind(wxEVT_LEFT_UP, [this, i, ace_edit](wxMouseEvent&) { show_ace_assign_popup(i, ace_edit); }); + ace_val = badge; + } else { + auto* txt = new Label(head_box, Label::Body_12, _L("Stock feeder")); + txt->SetForegroundColour(ace_mode == amNormal ? wxColour(0x9A, 0x9A, 0x9A) : wxColour(0x4A, 0x52, 0x58)); + ace_val = txt; + } - // Add diameter label and combobox - wxBoxSizer* diameter_sizer = new wxBoxSizer(wxHORIZONTAL); - wxStaticText* diameter_label = new wxStaticText(nozzle_panel, wxID_ANY, _L("Diameter")); - bool is_dark = wxGetApp().app_config->get("dark_color_mode") == "1"; - if (!is_dark) { - diameter_label->SetForegroundColour(wxColor(0, 0, 0)); - } - else { - diameter_label->SetForegroundColour(wxColor(194, 194, 194)); + wxBoxSizer* ace_row = new wxBoxSizer(wxHORIZONTAL); + ace_row->Add(ace_key, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); + ace_row->Add(ace_edit, 0, wxALIGN_CENTER_VERTICAL); + ace_row->AddStretchSpacer(); + ace_row->Add(ace_val, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(8)); + box_sizer->Add(ace_row, 0, wxEXPAND | wxTOP, FromDIP(4)); } - - diameter_label->SetFont(Label::Body_14); - ComboBox* diameter_combo = new ComboBox(nozzle_panel, wxID_ANY, wxEmptyString, wxDefaultPosition, {-1, FromDIP(32)}, 0, + // The Diameter row. Every head shows one, and every one of them writes all of them: the + // U1 refuses a mixed set, so changing any diameter switches the whole preset. + auto* diameter_label = new Label(head_box, Label::Body_12, _L("Diameter")); + diameter_label->SetForegroundColour(row_label_colour); + + ComboBox* diameter_combo = new ComboBox(head_box, wxID_ANY, wxEmptyString, wxDefaultPosition, {-1, FromDIP(26)}, 0, nullptr, wxCB_READONLY); - // Visible presets for this printer_model (system + user). Imported multi-nozzle variants are // usually non-system; diameters_for_same_printer_model() only counted system and kept the combo disabled. @@ -9381,7 +9953,7 @@ void Sidebar::update_nozzle_settings(bool switch_machine) diameter_combo->AppendString(wxString(diameter) + "mm"); } if (diameter_combo->GetCount() == 0) { - const auto *pv = wxGetApp().preset_bundle->printers.get_edited_preset().config.option("printer_variant"); + const auto *pv = cfg.option("printer_variant"); if (pv) diameter_combo->AppendString(wxString(pv->value) + "mm"); } @@ -9389,16 +9961,7 @@ void Sidebar::update_nozzle_settings(bool switch_machine) diameter_combo->Enable(false); } - diameter_combo->Bind(wxEVT_COMBOBOX, [this, diameter_combo, i](wxCommandEvent& event) { - - //auto* pNotice = p->plater->get_notification_manager(); - //if (pNotice) - //{ - // pNotice->close_notification_of_type(NotificationType::CustomNotification); - // pNotice->push_notification(_u8L("Note: Printing PLA Silk on the hot end of 0.6mm hardened steel is not recommended. 0.4mm or smaller specifications are suggested."), 0); - // pNotice->set_slicing_progress_hidden(); - //} - + diameter_combo->Bind(wxEVT_COMBOBOX, [this, diameter_combo](wxCommandEvent& event) { auto printer_config = wxGetApp().preset_bundle->printers.get_edited_preset().config; auto printer_model_opt = printer_config.option("printer_model"); if (printer_model_opt) { @@ -9413,14 +9976,14 @@ void Sidebar::update_nozzle_settings(bool switch_machine) { RichMessageDialog dlg(static_cast(wxGetApp().mainframe), _L("Note: Changing this will sync all other nozzles to the same diameter."), - _L("Set Nozzle Diameter"), + _L("Set Nozzle Diameter"), wxOK); dlg.ShowCheckBox(_L("Don't show this again"), false); auto res = dlg.ShowModal(); bool isCheckBox = dlg.IsCheckBoxChecked(); if (wxID_OK == res) - wxGetApp().app_config->set("app", "sync_diameter_flags", isCheckBox); + wxGetApp().app_config->set("app", "sync_diameter_flags", isCheckBox); } } } @@ -9432,7 +9995,7 @@ void Sidebar::update_nozzle_settings(bool switch_machine) return; } preset->is_visible = true; // force visible - + for (size_t i = 0; i < p->m_nozzle_diameter_lists.size(); ++i) { //set all nozzle use the diameter p->m_nozzle_diameter_lists[i]->SetValue(diameter + "mm"); @@ -9441,52 +10004,30 @@ void Sidebar::update_nozzle_settings(bool switch_machine) wxGetApp().get_tab(Preset::TYPE_PRINTER)->select_preset(preset->name); // Do not event.Skip(): select_preset rebuilds nozzle UI and can destroy this combo; skipping would let sidebar treat this as bed-type combo and use-after-free. }); - - auto diam_str = wxGetApp().preset_bundle->printers.get_edited_preset().config.option("printer_variant")->value; - - diameter_combo->SetValue(diam_str + "mm"); - - p->m_nozzle_diameter_lists.push_back(diameter_combo); - - diameter_sizer->AddSpacer(15); - diameter_sizer->Add(diameter_label, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(5)); - diameter_sizer->AddSpacer(10); - diameter_sizer->Add(diameter_combo, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(15)); - - // 删除Flow相关控件 - tab_sizer->Add(diameter_sizer, 1, wxEXPAND | wxALIGN_CENTER_VERTICAL); + const auto *variant = cfg.option("printer_variant"); + diameter_combo->SetValue((variant ? wxString(variant->value) : wxString("0.4")) + "mm"); - nozzle_panel->SetSizer(tab_sizer); + p->m_nozzle_diameter_lists.push_back(diameter_combo); - // Add tab - wxString tab_name = ""; - switch (new_nozzle_count) - { - case 1: - { - tab_name = _L("Nozzle"); - break; - } - case 2: - { - if (i == 0) - tab_name = _L("Left Nozzle"); - else - tab_name = _L("Right Nozzle"); + wxBoxSizer* diameter_row = new wxBoxSizer(wxHORIZONTAL); + diameter_row->Add(diameter_label, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); + diameter_row->Add(diameter_combo, 1, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, FromDIP(8)); + box_sizer->Add(diameter_row, 0, wxEXPAND | wxTOP, FromDIP(4)); + box_sizer->AddSpacer(FromDIP(6)); - break; - } - default: - { - tab_name = wxString(_L("Nozzle")) + wxString::Format(" %d", i + 1); - } - - } - p->m_nozzle_notebook->AddPage(nozzle_panel, tab_name); + head_box->SetSizer(box_sizer); + p->m_heads_sizer->Add(head_box, 1, wxEXPAND); } - p->m_nozzle_notebook->Layout(); + // An odd head count would leave the grid's last row half empty and stretched; a filler keeps + // every box the same width as the ones above it. + if (head_count % 2) + p->m_heads_sizer->AddStretchSpacer(); + + p->m_panel_heads->Layout(); + p->m_panel_printer_content->Layout(); + m_scrolled_sizer->Layout(); if (switch_machine) { p->combo_printer->SetFocus(); @@ -9681,17 +10222,17 @@ void Sidebar::update_printer_thumbnail() std::string printer_type = selected_preset.get_current_printer_type(preset_bundle); try { - p->image_printer->SetBitmap(create_scaled_bitmap(png_name, this, 48)); + p->image_printer->SetBitmap(create_scaled_bitmap(png_name, this, 68)); } catch (std::exception& e) { - p->image_printer->SetBitmap(create_scaled_bitmap("printer_placeholder", this, 48)); + p->image_printer->SetBitmap(create_scaled_bitmap("printer_placeholder", this, 68)); } /*if (printer_thumbnails.find(printer_type) != printer_thumbnails.end()) p->image_printer->SetBitmap(create_scaled_bitmap(, this, 48)); else - p->image_printer->SetBitmap(create_scaled_bitmap("printer_placeholder", this, 48));*/ + p->image_printer->SetBitmap(create_scaled_bitmap("printer_placeholder", this, 68));*/ } void Sidebar::auto_calc_flushing_volumes(const int modify_id) diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index 4c79e678bcc..d5dfb08ab19 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -187,6 +187,17 @@ class Sidebar : public wxPanel void update_dynamic_filament_list(); void update_nozzle_settings(bool switch_machine = false); + void sync_printer_info(); + // Second half of one press: write the ACE mode and wiring just read into the printer preset, + // then offer the filament sync. Split out because the nozzle half may have to open a picker + // first, and both of its paths end here. + void finish_printer_sync(); + // The assign popover behind a head box's ACE row: which unit feeds this toolhead. + void show_ace_assign_popup(size_t head_idx, wxWindow* anchor); + // Mark the filaments the machine has loaded. A diff, like the corner ticks - not a record + // that a sync once ran. + void refresh_filament_sync_marks(); + void refresh_plate_card(); ObjectList* obj_list(); ObjectSettings* obj_settings(); diff --git a/src/slic3r/GUI/SMAccountPersist.cpp b/src/slic3r/GUI/SMAccountPersist.cpp new file mode 100644 index 00000000000..84299f8dbdf --- /dev/null +++ b/src/slic3r/GUI/SMAccountPersist.cpp @@ -0,0 +1,59 @@ +#include "SMAccountPersist.hpp" +#include "GUI_App.hpp" +#include "libslic3r/AppConfig.hpp" + +#include + +namespace Slic3r { namespace GUI { + +static const char* SM_SECTION = "sm_account"; + +void sm_persist_login() +{ + auto* ui = wxGetApp().sm_get_userinfo(); + AppConfig* cfg = wxGetApp().app_config; + if (!ui || !cfg) + return; + + cfg->set(SM_SECTION, "token", ui->get_user_token()); + cfg->set(SM_SECTION, "id", ui->get_user_id()); + cfg->set(SM_SECTION, "account", ui->get_user_account()); + cfg->set(SM_SECTION, "name", ui->get_user_name()); + cfg->set(SM_SECTION, "icon", ui->get_user_icon_url()); + cfg->set(SM_SECTION, "login", ui->is_user_login() ? std::string("1") : std::string("0")); + cfg->save(); + BOOST_LOG_TRIVIAL(info) << "sm_persist_login: saved account (login=" << ui->is_user_login() << ", has_token=" << !ui->get_user_token().empty() << ")"; +} + +void sm_announce_login() +{ + auto* ui = wxGetApp().sm_get_userinfo(); + if (!ui || !ui->is_user_login()) + return; + ui->notify(); + BOOST_LOG_TRIVIAL(info) << "sm_announce_login: re-announced " << ui->get_user_account(); +} + +void sm_restore_login() +{ + auto* ui = wxGetApp().sm_get_userinfo(); + AppConfig* cfg = wxGetApp().app_config; + if (!ui || !cfg) + return; + + const std::string token = cfg->get(SM_SECTION, "token"); + if (token.empty()) { + BOOST_LOG_TRIVIAL(info) << "sm_restore_login: no saved token"; + return; + } + + ui->set_user_id(cfg->get(SM_SECTION, "id")); + ui->set_user_account(cfg->get(SM_SECTION, "account")); + ui->set_user_name(cfg->get(SM_SECTION, "name")); + ui->set_user_icon_url(cfg->get(SM_SECTION, "icon")); + ui->set_user_token(token); + ui->set_user_login(true); // also notify()s any login-state subscribers + BOOST_LOG_TRIVIAL(info) << "sm_restore_login: restored account " << ui->get_user_account(); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/SMAccountPersist.hpp b/src/slic3r/GUI/SMAccountPersist.hpp new file mode 100644 index 00000000000..70a30d3e837 --- /dev/null +++ b/src/slic3r/GUI/SMAccountPersist.hpp @@ -0,0 +1,33 @@ +#ifndef slic3r_GUI_SMAccountPersist_hpp_ +#define slic3r_GUI_SMAccountPersist_hpp_ + +// Persist the Snapmaker account across restarts. The account (token/id/account/ +// name/icon) is otherwise kept only in memory (GUI_App::m_login_userinfo) and is +// never written to disk, so every launch starts logged out. These helpers save it +// to app_config on login, restore it at startup, and clear it on logout. +// +// Deliberately a tiny standalone header (not GUI_App.hpp, which ~hundreds of TUs +// include) so touching login persistence doesn't trigger a full rebuild. +// +// NOTE: stores the bearer token in plaintext app_config (matches how this fork +// stores other settings). An OS keychain would be more secure — future work. + +namespace Slic3r { namespace GUI { + +// Write the current in-memory Snapmaker account to app_config (and save()). +void sm_persist_login(); + +// Restore the Snapmaker account from app_config into memory at startup. No-op if +// no token was saved. Does not re-validate the token against the server; a stale +// token simply fails on the next API call and the user re-logs in. +void sm_restore_login(); + +// Re-announce the current account to whatever has subscribed since. sm_restore_login() +// runs before the webview exists, so the notify() inside it reaches nobody; this is +// called once the webview is up so the page learns the session it was too early to hear +// about. No-op when logged out. +void sm_announce_login(); + +}} // namespace Slic3r::GUI + +#endif // slic3r_GUI_SMAccountPersist_hpp_ diff --git a/src/slic3r/GUI/WebSMUserLoginDialog.cpp b/src/slic3r/GUI/WebSMUserLoginDialog.cpp index 9b23e17535b..dc3e76bb99c 100644 --- a/src/slic3r/GUI/WebSMUserLoginDialog.cpp +++ b/src/slic3r/GUI/WebSMUserLoginDialog.cpp @@ -26,6 +26,7 @@ #include #include #include "sentry_wrapper/SentryWrapper.hpp" +#include "SMAccountPersist.hpp" using namespace std; using namespace nlohmann; @@ -221,6 +222,7 @@ void SMUserLogin::OnNavigationRequest(wxWebViewEvent &evt) sentryReportLog(SENTRY_LOG_TRACE, userInfo, BP_LOGIN); wxGetApp().sm_get_userinfo()->set_user_token(token); wxGetApp().sm_get_userinfo()->set_user_login(true); + Slic3r::GUI::sm_persist_login(); // remember the session across restarts } }) .on_error([&](std::string body, std::string error, unsigned status) { diff --git a/src/slic3r/GUI/filamentsync/FilamentColorMapBox.cpp b/src/slic3r/GUI/filamentsync/FilamentColorMapBox.cpp index 9cc80205592..8a7e2970922 100644 --- a/src/slic3r/GUI/filamentsync/FilamentColorMapBox.cpp +++ b/src/slic3r/GUI/filamentsync/FilamentColorMapBox.cpp @@ -249,9 +249,11 @@ void FilamentColorMapBox::onPaint(wxPaintEvent&) { gdc.SetTextForeground(g_bodyTextColor); gdc.SetFont(Label::Body_10); - const wxString type = m_belowFilament.m_type.empty() - ? wxString("NONE") - : wxString(m_belowFilament.m_type); + const wxString type = !m_belowFilament.m_label.empty() + ? wxString::FromUTF8(m_belowFilament.m_label) + : (m_belowFilament.m_type.empty() + ? wxString("NONE") + : wxString(m_belowFilament.m_type)); const wxSize te = gdc.GetTextExtent(type); gdc.DrawText(type, (w - te.x) / 2, splitY + FromDIP(g_nameTextY)); diff --git a/src/slic3r/GUI/filamentsync/FilamentColorMapBoxGroup.cpp b/src/slic3r/GUI/filamentsync/FilamentColorMapBoxGroup.cpp index a7813f2e470..1ea8b79e460 100644 --- a/src/slic3r/GUI/filamentsync/FilamentColorMapBoxGroup.cpp +++ b/src/slic3r/GUI/filamentsync/FilamentColorMapBoxGroup.cpp @@ -25,9 +25,6 @@ constexpr int g_containerBorderW = 1; // border width constexpr int g_labelGap = 20; // gap between labels and cards constexpr int g_cardGap = 20; // gap between cards (Figma: gap-[20px]) -// Label vertical positioning: align with card top-bar text (y=7) -constexpr int g_labelDesignTopMargin = 6; // align "Source Filament" with top bar text -constexpr int g_labelVerticalGap = 24; // gap between "Source Filament" and "Printer Filament" // ============================================================ // Colours @@ -61,10 +58,9 @@ namespace GUI int FilamentColorMapBoxGroup::GetGridCols() { - const wxString lang = wxGetApp().app_config->get_language_code(); - if (lang.StartsWith("zh") || lang.StartsWith("ja") || lang.StartsWith("ko")) - return 5; - return 4; + // 6 columns wide: with the left label column gone the dialog width fits 6 cards, + // so the conceivable maximum of 16 spools (4 ACE units x 4 slots) lands in 3 rows. + return 6; } FilamentColorMapBoxGroup::FilamentColorMapBoxGroup(wxWindow* parent, @@ -79,42 +75,20 @@ FilamentColorMapBoxGroup::FilamentColorMapBoxGroup(wxWindow* parent, SetBackgroundColour(g_containerBg); Bind(wxEVT_PAINT, &FilamentColorMapBoxGroup::onPaint, this); - // ---- Outer horizontal sizer (after padding) ---- - auto* rowSizer = new wxBoxSizer(wxHORIZONTAL); - - // ---- Left label column ---- - auto* labelSizer = new wxBoxSizer(wxVERTICAL); - - m_pLabelDesign = new Label(this, _L("Source Filament")); - m_pLabelDesign->SetFont(Label::Body_14); - m_pLabelDesign->SetForegroundColour(g_labelTextColor); - m_pLabelDesign->SetBackgroundStyle(wxBG_STYLE_TRANSPARENT); - m_pLabelDesign->SetBackgroundColour(g_containerBg); - labelSizer->AddSpacer(FromDIP(g_labelDesignTopMargin)); - labelSizer->Add(m_pLabelDesign, 0, wxEXPAND); - labelSizer->AddSpacer(FromDIP(g_labelVerticalGap)); - - m_pLabelMachine = new Label(this, _L("Printer Filament")); - m_pLabelMachine->SetFont(Label::Body_14); - m_pLabelMachine->SetForegroundColour(g_labelTextColor); - m_pLabelMachine->SetBackgroundStyle(wxBG_STYLE_TRANSPARENT); - m_pLabelMachine->SetBackgroundColour(g_containerBg); - labelSizer->Add(m_pLabelMachine, 0, wxEXPAND); - - // Constrain both labels to the same width so the label column has a - // predictable size regardless of which translation ends up longer. - { - int w1 = m_pLabelDesign->GetTextExtent(m_pLabelDesign->GetLabel()).GetWidth(); - int w2 = m_pLabelMachine->GetTextExtent(m_pLabelMachine->GetLabel()).GetWidth(); - int maxW = std::max(w1, w2); - m_pLabelDesign->SetMinSize(wxSize(maxW, -1)); - m_pLabelMachine->SetMinSize(wxSize(maxW, -1)); - } - labelSizer->AddStretchSpacer(1); - - rowSizer->Add(labelSizer, 0, wxEXPAND | wxRIGHT, FromDIP(g_labelGap)); - - // ---- Right card grid — 5 columns, auto rows ---- + // ---- Caption ---- + // Replaces the old left "Source Filament" / "Printer Filament" label column, which only lined + // up with the first card row and read as broken once the cards wrapped to a second row (more + // than four filaments - which a U1 reaches as soon as the ACE is in the list). A single caption + // above the grid scales to any number of filaments; each card already shows the project + // filament on top and the mapped printer filament below. + m_pCaption = new Label( + this, _L("Map each project filament (top) to a printer filament (below)")); + m_pCaption->SetFont(Label::Body_12); + m_pCaption->SetForegroundColour(g_labelTextColor); + m_pCaption->SetBackgroundStyle(wxBG_STYLE_TRANSPARENT); + m_pCaption->SetBackgroundColour(g_containerBg); + + // ---- Card grid - N columns, auto rows ---- auto* cardGridSizer = new wxFlexGridSizer(0, GetGridCols(), FromDIP(g_cardGap), FromDIP(g_cardGap)); int boxIndex = 0; @@ -131,11 +105,12 @@ FilamentColorMapBoxGroup::FilamentColorMapBoxGroup(wxWindow* parent, ++boxIndex; } - rowSizer->Add(cardGridSizer, 0, wxALIGN_TOP); - - // ---- Padding around the row ---- + // ---- Caption above the grid, padded ---- auto* outerSizer = new wxBoxSizer(wxVERTICAL); - outerSizer->Add(rowSizer, 1, wxEXPAND | wxALL, FromDIP(g_containerPadding)); + outerSizer->Add(m_pCaption, 0, wxLEFT | wxTOP | wxRIGHT, FromDIP(g_containerPadding)); + outerSizer->AddSpacer(FromDIP(g_labelGap)); + outerSizer->Add(cardGridSizer, 0, + wxLEFT | wxRIGHT | wxBOTTOM | wxALIGN_TOP, FromDIP(g_containerPadding)); SetSizer(outerSizer); Layout(); } @@ -255,7 +230,10 @@ int FilamentColorMapBoxGroup::getHeightForRowCount(int rows) const int vGap = FromDIP(g_cardGap); int gridH = rows * cardH + std::max(0, rows - 1) * vGap; int pad = FromDIP(g_containerPadding); - return gridH + 2 * pad; + // Account for the caption above the grid (and the gap below it), or the container is sized + // too short and clips the last card row. + int header = m_pCaption ? m_pCaption->GetBestSize().y + FromDIP(g_labelGap) : 0; + return header + gridH + 2 * pad; } void FilamentColorMapBoxGroup::bindMappingChangedCallback(std::function cb) diff --git a/src/slic3r/GUI/filamentsync/FilamentColorMapBoxGroup.hpp b/src/slic3r/GUI/filamentsync/FilamentColorMapBoxGroup.hpp index 6cf1ccb73dc..306fe238693 100644 --- a/src/slic3r/GUI/filamentsync/FilamentColorMapBoxGroup.hpp +++ b/src/slic3r/GUI/filamentsync/FilamentColorMapBoxGroup.hpp @@ -61,8 +61,7 @@ class FilamentColorMapBoxGroup : public wxPanel MachineFilamentPicker* m_pPicker = nullptr; - Label* m_pLabelDesign = nullptr; - Label* m_pLabelMachine = nullptr; + Label* m_pCaption = nullptr; std::function m_mappingChangedCallback = nullptr; }; diff --git a/src/slic3r/GUI/filamentsync/FilamentData.hpp b/src/slic3r/GUI/filamentsync/FilamentData.hpp index 41733da77a9..f50b8ecf89f 100644 --- a/src/slic3r/GUI/filamentsync/FilamentData.hpp +++ b/src/slic3r/GUI/filamentsync/FilamentData.hpp @@ -27,6 +27,18 @@ struct FilamentData std::string m_name; std::string m_type; FilamentColor m_color; + // Optional display label (e.g. "T1 · PLA", "A1-S2 · PETG"); when set, the picker + // shows this instead of the bare type. Does not affect matching (which uses + // m_type/m_name). Used to surface the U1-toolhead / ACE-slot source. + std::string m_label; + // When true, this is an explicit, selectable "Assign None" action (unmap the + // project filament). The picker normally ignores clicks on NONE-typed rows + // (they represent empty machine slots); this flag opts a row back into being + // clickable without changing that behaviour for other printers. + bool m_assign_none = false; + // When true, the row is shown for context but greyed and not selectable (e.g. a + // U1 toolhead that is fed by the ACE — map the ACE slot instead of the head). + bool m_disabled = false; }; struct MixedFilamentPreviewInfo diff --git a/src/slic3r/GUI/filamentsync/FilamentSyncAlgorithm.cpp b/src/slic3r/GUI/filamentsync/FilamentSyncAlgorithm.cpp index 03fa374cac5..2acfca5683c 100644 --- a/src/slic3r/GUI/filamentsync/FilamentSyncAlgorithm.cpp +++ b/src/slic3r/GUI/filamentsync/FilamentSyncAlgorithm.cpp @@ -90,6 +90,17 @@ float delta_e_ciede2000(uint8_t r1, uint8_t g1, uint8_t b1, return DeltaE00(L1, a1, b1v, L2, a2, b2v); } +// A row the user cannot pick is not a candidate for the matcher either. Two kinds qualify: +// NONE-typed rows (an empty slot or head, and the explicit "Assign None" action) and disabled +// ones - a U1 toolhead fed by an ACE, which carries whatever slot is loaded at this moment. The +// picker greys both; auto-matching to one anyway is how a mapping ends up on a row the user is +// then not allowed to change, and on the U1 it duplicates a spool that is already in the list +// under its own slot. +static bool is_selectable_source(const GUI::FilamentData& d) +{ + return !is_none_filament(d) && !d.m_disabled; +} + std::vector compute_color_match( const std::vector& design_data, const std::vector& machine_data) @@ -125,7 +136,7 @@ std::vector compute_color_match( // Pass 1: same filament type for (size_t j = 0; j < machineCount; ++j) { - if (is_none_filament(machine_data[j])) + if (!is_selectable_source(machine_data[j])) continue; if (machine_data[j].m_type != designType) continue; @@ -140,7 +151,7 @@ std::vector compute_color_match( // Pass 2 (fallback): any non-NONE machine filament if (bestIdx < 0) { for (size_t j = 0; j < machineCount; ++j) { - if (is_none_filament(machine_data[j])) + if (!is_selectable_source(machine_data[j])) continue; float dist = DeltaE00(designL, designA, designB, machineLab[j].L, machineLab[j].a, machineLab[j].b); @@ -165,7 +176,7 @@ std::vector compute_direct_override( std::vector validPos; for (size_t j = 0; j < machine_data.size(); ++j) { - if (!is_none_filament(machine_data[j])) + if (is_selectable_source(machine_data[j])) validPos.push_back(j); } diff --git a/src/slic3r/GUI/filamentsync/MachineFilamentPicker.cpp b/src/slic3r/GUI/filamentsync/MachineFilamentPicker.cpp index d2d8cd6061e..2e98505622b 100644 --- a/src/slic3r/GUI/filamentsync/MachineFilamentPicker.cpp +++ b/src/slic3r/GUI/filamentsync/MachineFilamentPicker.cpp @@ -104,7 +104,7 @@ class PickerContentPanel : public wxPanel wxClientDC dc(this); dc.SetFont(Label::Body_10); for (const auto& data : m_dataList) { - wxString typeStr = wxString::FromUTF8(data.m_type); + wxString typeStr = wxString::FromUTF8(data.m_label.empty() ? data.m_type : data.m_label); int tw = dc.GetTextExtent(typeStr).x; if (tw > maxTextWidthPx) { maxTextWidthPx = tw; @@ -115,7 +115,18 @@ class PickerContentPanel : public wxPanel int contentWidthPx = FromDIP(g_textX + g_contentOffsetX) + maxTextWidthPx + FromDIP(g_textRightPadding); int actualWidthPx = std::max(minWidthPx, contentWidthPx); - wxSize sz(actualWidthPx, FromDIP(g_popupHeight)); + // Height grows with the row count so every entry is visible: the popup has no + // scrolling, and a U1 lists four toolheads plus every ACE slot, which is well past + // the fixed height this was written for. Matches the row layout used in + // render()/hitTestRow - symmetric g_firstRowY padding top and bottom - and keeps + // the old fixed height for an empty list. Capped as a sanity bound. + const int rowCount = std::max(1, static_cast(m_dataList.size())); + const int cappedRows = std::min(rowCount, 16); + int heightDip = g_firstRowY * 2 + (cappedRows - 1) * g_itemStepY + g_itemRowH; + if (m_dataList.empty()) + heightDip = g_popupHeight; + + wxSize sz(actualWidthPx, FromDIP(heightDip)); SetSize(sz); SetMinSize(sz); SetMaxSize(sz); @@ -199,7 +210,8 @@ class PickerContentPanel : public wxPanel drawCheckmark(dc, cx, cy, cw, ch); } - bool isNone = isNoneEntry(data); + // Disabled rows (e.g. an ACE-fed toolhead) are greyed like NONE entries. + bool isNone = isNoneEntry(data) || data.m_disabled; // ---- Colour circle (bitmap) ---- int circleCxPx = FromDIP(g_circleCx + g_contentOffsetX); @@ -224,7 +236,8 @@ class PickerContentPanel : public wxPanel // ---- Filament type text ---- dc.SetFont(labelFont); dc.SetTextForeground(isNone ? wxColour(0xBB, 0xBB, 0xBB) : g_textColor); - wxString typeStr = wxString::FromUTF8(data.m_type.empty() ? "NONE" : data.m_type); + wxString typeStr = !data.m_label.empty() ? wxString::FromUTF8(data.m_label) + : wxString::FromUTF8(data.m_type.empty() ? "NONE" : data.m_type); int textX = FromDIP(g_textX + g_contentOffsetX); int textH = dc.GetTextExtent(typeStr).y; int textY = FromDIP(y) + static_cast(std::round((FromDIP(g_itemRowH) - textH) / 2.0)); @@ -264,8 +277,9 @@ class PickerContentPanel : public wxPanel return; } - // Ignore clicks on NONE (empty slot) entries - if (isNoneEntry(m_dataList[row])) { + // Ignore clicks on NONE (empty slot) and disabled (e.g. ACE-fed toolhead) + // entries, unless the row is an explicit "Assign None" action. + if ((isNoneEntry(m_dataList[row]) || m_dataList[row].m_disabled) && !m_dataList[row].m_assign_none) { return; } diff --git a/src/slic3r/GUI/filamentsync/SyncFilamentColorDialog.cpp b/src/slic3r/GUI/filamentsync/SyncFilamentColorDialog.cpp index a4d10684c28..fafa684313f 100644 --- a/src/slic3r/GUI/filamentsync/SyncFilamentColorDialog.cpp +++ b/src/slic3r/GUI/filamentsync/SyncFilamentColorDialog.cpp @@ -94,16 +94,30 @@ constexpr const char* g_block3BorderColor = "#F0F0F0"; constexpr const char* g_block3SeparatorColor = "#F3F4F6"; constexpr const char* g_secondaryHoverBg = "#F3F4F6"; +// Overwrite mode's result: the machine's own filaments, in machine order. +// +// `wholeMachine` decides how many. Normally it is the count the project already had, because +// overwriting a project that has objects in it must not renumber the filaments those objects +// reference. With nothing on the plate there is no such constraint and no design to preserve, so +// the project takes the machine's whole inventory - which is the only way a four-slot ACE ever +// shows four filaments. +// +// Skipped either way: NONE-typed rows (an empty slot, or the explicit "Assign None" action) and +// disabled rows. A disabled row is a toolhead fed by an ACE; it carries whatever slot is loaded +// at this moment, so taking it as well as that slot would list the same spool twice. std::vector collectVisibleOverwriteMachineFilaments( const std::vector& machineDataList, - size_t designCount) + size_t designCount, + bool wholeMachine) { std::vector visibleMachine; - size_t visibleCount = std::min(designCount, machineDataList.size()); - visibleMachine.reserve(visibleCount); - for (size_t i = 0; i < visibleCount; ++i) { - if (!Slic3r::GUI::is_none_filament(machineDataList[i])) - visibleMachine.push_back(machineDataList[i]); + visibleMachine.reserve(machineDataList.size()); + for (const auto& data : machineDataList) { + if (Slic3r::GUI::is_none_filament(data) || data.m_disabled) + continue; + if (!wholeMachine && visibleMachine.size() >= designCount) + break; + visibleMachine.push_back(data); } return visibleMachine; } @@ -170,7 +184,7 @@ SyncFilamentColorDialog::SyncFilamentColorDialog(wxWindow* parent, loadCoverPreview(); }); - m_bNeedScroll = m_pFilamentColorMapBoxGroup->exceedsRowCount(2); + m_bNeedScroll = m_pFilamentColorMapBoxGroup->exceedsRowCount(3); // --- Preview wrapper (was Block 3) --- auto* previewWrapper = new wxPanel(block, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE); @@ -232,7 +246,7 @@ SyncFilamentColorDialog::SyncFilamentColorDialog(wxWindow* parent, // Assemble: scroll widgets are always created so the scrollbar // can be shown / hidden when the mode changes. // ============================================================ - m_maxViewportHeight = m_pFilamentColorMapBoxGroup->getHeightForRowCount(2); + m_maxViewportHeight = m_pFilamentColorMapBoxGroup->getHeightForRowCount(3); { int boxCount = m_pFilamentColorMapBoxGroup->getVisibleBoxCount(); int gridCols = FilamentColorMapBoxGroup::GetGridCols(); @@ -397,7 +411,8 @@ std::vector SyncFilamentColorDialog::getSyncDataList() const return dataList; if (!m_bMappingMode) { - return collectVisibleOverwriteMachineFilaments(m_machineDataList, m_designDataList.size()); + return collectVisibleOverwriteMachineFilaments(m_machineDataList, m_designDataList.size(), + m_bOverwriteWholeMachine); } dataList = m_pFilamentColorMapBoxGroup->getCurFilamentList(); @@ -418,8 +433,9 @@ std::vector SyncFilamentColorDialog::getSyncDataList() const return dataList; } -void SyncFilamentColorDialog::setOverwriteMode() +void SyncFilamentColorDialog::setOverwriteMode(bool whole_machine) { + m_bOverwriteWholeMachine = whole_machine; if (m_pModeToggle) m_pModeToggle->setSelected(g_modeOverwrite); onModeChanged(g_modeOverwrite); @@ -625,7 +641,9 @@ void SyncFilamentColorDialog::loadCoverPreview() std::vector filamentMapping; if (!m_bMappingMode) { - filamentMapping = collectVisibleOverwriteMachineFilaments(m_machineDataList, m_designDataList.size()); + // Same list the sync will apply, so the preview cannot promise something else. + filamentMapping = collectVisibleOverwriteMachineFilaments(m_machineDataList, m_designDataList.size(), + m_bOverwriteWholeMachine); } else if (m_pFilamentColorMapBoxGroup) { filamentMapping = m_pFilamentColorMapBoxGroup->getCurFilamentList(); } @@ -816,11 +834,11 @@ void SyncFilamentColorDialog::updateScrollState() if (!m_pFilamentColorMapBoxGroup || !m_pScrollBar || !m_pScrollGap || !m_pScrollViewport) return; - bool needScroll = m_pFilamentColorMapBoxGroup->exceedsRowCount(2); + bool needScroll = m_pFilamentColorMapBoxGroup->exceedsRowCount(3); m_bNeedScroll = needScroll; // Recalculate heights (visible count may have changed) - m_maxViewportHeight = m_pFilamentColorMapBoxGroup->getHeightForRowCount(2); + m_maxViewportHeight = m_pFilamentColorMapBoxGroup->getHeightForRowCount(3); { int boxCount = m_pFilamentColorMapBoxGroup->getVisibleBoxCount(); int gridCols = FilamentColorMapBoxGroup::GetGridCols(); diff --git a/src/slic3r/GUI/filamentsync/SyncFilamentColorDialog.hpp b/src/slic3r/GUI/filamentsync/SyncFilamentColorDialog.hpp index 18e47d2f093..ac5de6925e2 100644 --- a/src/slic3r/GUI/filamentsync/SyncFilamentColorDialog.hpp +++ b/src/slic3r/GUI/filamentsync/SyncFilamentColorDialog.hpp @@ -44,7 +44,10 @@ class SyncFilamentColorDialog : public wxDialog void setMixedFilamentInfos(const std::vector& infos); bool shouldDeleteMixedFilaments() const; - void setOverwriteMode(); + // `whole_machine` is for the case where the plate is empty: the project then takes every + // filament the machine reports rather than the count it happened to have. With objects on the + // plate it must stay false - they reference filaments by index. + void setOverwriteMode(bool whole_machine = false); bool Layout() override; @@ -102,6 +105,7 @@ class SyncFilamentColorDialog : public wxDialog std::vector m_filamentIdRemap; std::vector m_mixedFilamentInfos; bool m_bMappingMode = true; + bool m_bOverwriteWholeMachine = false; bool m_hasMixedFilaments = false; bool m_shouldDeleteMixedFilaments = false; }; diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 9c958028511..b278ff90725 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -3,6 +3,7 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME) add_executable(${_TEST_NAME}_tests ${_TEST_NAME}_tests.cpp test_3mf.cpp + test_ace_mmu_topology.cpp test_aabbindirect.cpp test_clipper_offset.cpp test_clipper_utils.cpp diff --git a/tests/libslic3r/test_ace_mmu_topology.cpp b/tests/libslic3r/test_ace_mmu_topology.cpp new file mode 100644 index 00000000000..2f95dadfc42 --- /dev/null +++ b/tests/libslic3r/test_ace_mmu_topology.cpp @@ -0,0 +1,194 @@ +#include + +#include "libslic3r/AceMmuTopology.hpp" + +using namespace Slic3r; +using namespace Slic3r::AceMmu; + +// The live U1 at 192.168.2.242, read 22 Aug 2026: mode "head", one connected ACE 2 Pro at 38% RH +// feeding Toolhead 4 from slot 2, three heads on their own feeders, four PETG spools. +// +// head_ace is the trap this fixture exists for. The machine reports {"0":0,"1":1,"2":2,"3":0} with +// exactly ONE unit plugged in, so reading it as wiring invents units 1 and 2. Only head 3 is +// actually ACE-fed, and `feeder` is what says so. +static const char *LIVE_HEAD_MODE = R"({ + "device_count": 1, + "active_device": 0, + "ace_head": 3, + "mode": "head", + "printer_state": "idle", + "ace_status": "ready", + "ace_temp": 29, + "head_ace": { "0": 0, "1": 1, "2": 2, "3": 0 }, + "aces": [ + { + "idx": 0, "connected": true, "protocol": "v2", "status": "ready", + "temp": 29, "humidity": 38, + "dryer": { "status": "stop", "target_temp": 0, "duration": 0, "remain_time": 0 }, + "slots": [ + { "idx": 0, "state": "ready", "raw": 1, "rfid": 0, "material": "PETG", + "brand": "Kingroon", "sku": "", "subtype": "Basic", "color": "#83AFFF", "source": "override" }, + { "idx": 1, "state": "ready", "raw": 1, "rfid": 0, "material": "PETG", + "brand": "Kingroon", "sku": "", "subtype": "Basic", "color": "#8FA7C8", "source": "override" }, + { "idx": 2, "state": "ready", "raw": 1, "rfid": 0, "material": "PETG", + "brand": "Generic", "sku": "", "subtype": "Basic", "color": "#632c2c", "source": "override" }, + { "idx": 3, "state": "ready", "raw": 1, "rfid": 0, "material": "PETG", + "brand": "Kingroon", "sku": "", "subtype": "Basic", "color": "#C47053", "source": "override" } + ] + } + ], + "toolheads": [ + { "idx": 0, "ace": null, "slot": null, "material": "PLA", "color": "#f44336", + "filament_detected": true, "manual": false, "feeder": true, "source": null }, + { "idx": 1, "ace": null, "slot": null, "material": "PLA", "color": "#ffffdc", + "filament_detected": true, "manual": false, "feeder": true, "source": null }, + { "idx": 2, "ace": null, "slot": null, "material": "", "color": "#ffffff", + "filament_detected": false, "manual": false, "feeder": true, "source": null }, + { "idx": 3, "ace": 0, "slot": 2, "material": "PETG", "color": "#632c2c", + "filament_detected": true, "manual": false, "feeder": false, "source": null } + ] +})"; + +// A printer preset carries these three keys and nothing else this code reads. +static DynamicConfig preset_with(AceMode mode, std::vector units, std::vector caps) +{ + DynamicConfig cfg; + cfg.set_key_value("ace_mode", new ConfigOptionEnum(mode)); + cfg.set_key_value("ace_head_unit", new ConfigOptionInts(std::move(units))); + cfg.set_key_value("ace_head_capacity", new ConfigOptionInts(std::move(caps))); + return cfg; +} + +TEST_CASE("ace_mode_from_string takes the firmware's own words", "[AceMmuTopology]") +{ + REQUIRE(ace_mode_from_string("normal") == amNormal); + REQUIRE(ace_mode_from_string("head") == amHead); + REQUIRE(ace_mode_from_string("multi") == amMulti); + // A word we do not know claims no ACE rather than guessing one. + REQUIRE(ace_mode_from_string("") == amNormal); + REQUIRE(ace_mode_from_string("something-new") == amNormal); +} + +TEST_CASE("the live U1 maps to one ACE-fed head and three feeders", "[AceMmuTopology]") +{ + const AceSnapshot snap = parse_ace_state(std::string(LIVE_HEAD_MODE)); + REQUIRE(snap.mode == "head"); + REQUIRE(snap.device_count == 1); + REQUIRE(snap.toolheads.size() == 4); + + const AceTopology topo = ace_topology_of(snap, 4); + REQUIRE(topo.mode == amHead); + REQUIRE(topo.unit == std::vector{-1, -1, -1, 0}); + REQUIRE(topo.cap == std::vector{1, 1, 1, 4}); +} + +TEST_CASE("head_ace never turns a stock feeder into a unit", "[AceMmuTopology]") +{ + // The regression this file exists for: head_ace names a unit for all four heads, but three of + // them are feeders and only one ACE is plugged in. + const AceSnapshot snap = parse_ace_state(std::string(LIVE_HEAD_MODE)); + for (size_t h = 0; h < 3; ++h) + REQUIRE(snap.toolheads[h].ace.has_value()); // parse_ace_state did fill it in from head_ace + + const AceTopology topo = ace_topology_of(snap, 4); + for (size_t h = 0; h < 3; ++h) { + REQUIRE(topo.unit[h] == -1); + REQUIRE(topo.cap[h] == 1); + } +} + +TEST_CASE("capacity follows the unit's own slot list", "[AceMmuTopology]") +{ + const AceSnapshot snap = parse_ace_state(std::string(LIVE_HEAD_MODE)); + REQUIRE(ace_unit_capacity(snap, 0) == 4); + // A unit the machine never mentioned still offers the protocol's four. + REQUIRE(ace_unit_capacity(snap, 3) == SLOT_COUNT); +} + +TEST_CASE("a head with more heads than the machine reports falls back to its feeder", "[AceMmuTopology]") +{ + // A preset for a four-head machine read against a snapshot that only described two. + const AceSnapshot snap = parse_ace_state(std::string(R"({"mode":"head","toolheads":[ + {"idx":0,"ace":1,"feeder":false},{"idx":1,"feeder":true}]})")); + const AceTopology topo = ace_topology_of(snap, 4); + REQUIRE(topo.unit == std::vector{1, -1, -1, -1}); + REQUIRE(topo.cap == std::vector{4, 1, 1, 1}); +} + +TEST_CASE("normal mode wires nothing", "[AceMmuTopology]") +{ + const AceSnapshot snap = parse_ace_state(std::string(R"({"mode":"normal","device_count":1, + "toolheads":[{"idx":0,"feeder":true},{"idx":1,"feeder":true}]})")); + const AceTopology topo = ace_topology_of(snap, 2); + REQUIRE(topo.mode == amNormal); + REQUIRE(topo.unit == std::vector{-1, -1}); + REQUIRE(topo.cap == std::vector{1, 1}); +} + +TEST_CASE("one unit may feed two heads", "[AceMmuTopology]") +{ + // ACE_SET_HEAD_ACE binds a head to a unit and says nothing about the reverse, so this is legal + // and both heads read the same unit at its full capacity. + const AceSnapshot snap = parse_ace_state(std::string(R"({"mode":"head","device_count":1, + "aces":[{"idx":0,"connected":true,"protocol":"v2","slots":[ + {"idx":0,"state":"ready","raw":1},{"idx":1,"state":"ready","raw":1}, + {"idx":2,"state":"ready","raw":1},{"idx":3,"state":"ready","raw":1}]}], + "toolheads":[{"idx":0,"ace":0,"feeder":false},{"idx":1,"ace":0,"feeder":false}]})")); + const AceTopology topo = ace_topology_of(snap, 2); + REQUIRE(topo.unit == std::vector{0, 0}); + REQUIRE(topo.cap == std::vector{4, 4}); +} + +TEST_CASE("ace_head_agrees is the corner tick's whole claim", "[AceMmuTopology]") +{ + const AceSnapshot snap = parse_ace_state(std::string(LIVE_HEAD_MODE)); + const AceTopology topo = ace_topology_of(snap, 4); + + SECTION("a preset written by the sync agrees on every head") { + const DynamicConfig cfg = preset_with(amHead, topo.unit, topo.cap); + for (size_t h = 0; h < 4; ++h) + REQUIRE(ace_head_agrees(cfg, topo, h)); + } + + SECTION("the wrong mode disagrees on every head, whatever the wiring says") { + const DynamicConfig cfg = preset_with(amNormal, topo.unit, topo.cap); + for (size_t h = 0; h < 4; ++h) + REQUIRE_FALSE(ace_head_agrees(cfg, topo, h)); + } + + SECTION("only the head that moved loses its tick") { + const DynamicConfig cfg = preset_with(amHead, {-1, -1, -1, 1}, {1, 1, 1, 4}); + REQUIRE(ace_head_agrees(cfg, topo, 0)); + REQUIRE(ace_head_agrees(cfg, topo, 1)); + REQUIRE(ace_head_agrees(cfg, topo, 2)); + REQUIRE_FALSE(ace_head_agrees(cfg, topo, 3)); // preset says ACE 2, printer says ACE 1 + } + + SECTION("a stock feeder carries no unit, so a stale one does not break agreement") { + // ace_head_unit keeps whatever it last held when a head goes back to its feeder; the + // capacity is what says "feeder", and comparing the unit there would mark a false change. + const DynamicConfig cfg = preset_with(amHead, {2, 0, 3, 0}, {1, 1, 1, 4}); + for (size_t h = 0; h < 4; ++h) + REQUIRE(ace_head_agrees(cfg, topo, h)); + } + + SECTION("a preset shorter than the machine disagrees rather than reading past its end") { + const DynamicConfig cfg = preset_with(amHead, {-1, -1}, {1, 1}); + REQUIRE(ace_head_agrees(cfg, topo, 0)); + REQUIRE_FALSE(ace_head_agrees(cfg, topo, 3)); + } +} + +TEST_CASE("units are named as the printer names them", "[AceMmuTopology]") +{ + const AceSnapshot snap = parse_ace_state(std::string(LIVE_HEAD_MODE)); + REQUIRE(snap.units.size() == 1); + REQUIRE(ace_unit_model(snap.units[0]) == "ACE 2 Pro"); + + AceUnit v1; + v1.protocol = "v1"; + REQUIRE(ace_unit_model(v1) == "ACE Pro"); + + AceUnit unknown; + REQUIRE(ace_unit_model(unknown).empty()); +}