ソースを参照

fix(slicer): painted multi-colour models keep their colours (issue #3355)

- Before slicing, filaments the plate doesn't use are swapped for one it
  does, so the slicer's material checks pass. Which slots are used was
  read from paint_color one hex digit at a time, but the string is the
  slicer's nibble tree, last node first: filament 1 is "4", 2 is "8",
  3 is "0C". Painted filaments counted as unused and were overwritten
  with another slot's profile and colour, so a black/white/green/green
  model sliced all green and its AMS mapping followed.
- paint_color is now decoded as the slicer writes it. Every painted leaf
  counts; the 5% noise cutoff, which only hid the misread digits, is
  removed.
- A slot that really is unused still takes the anchor's profile but
  keeps its own filament_colour.
- Tests use real encodings, including a project painted in Bambu Studio
  02.08.02.61.
maziggy 5 時間 前
親
コミット
a3542b8fe8

+ 1 - 0
CHANGELOG.md

@@ -175,6 +175,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Slicing a painted multi-colour model keeps its colours (#3355, reported by @cartman75)** — On a model coloured with the paint tool, slicing in Bambuddy could turn several filaments into one colour, for example black, white, green and green all coming out green. Before slicing, Bambuddy works out which filaments the plate doesn't use and swaps them for one it does, so the slicer's material checks pass. It misread the painted areas, so painted filaments counted as unused and got another slot's profile and colour, and the print dialog then mapped them to that slot's spool. Painted areas are now read correctly, including small ones. A filament that really is unused keeps its own colour when it is swapped.
 - **An AMS Lite shows its serial number and firmware version (#3348, reported by @Spegeli)** — The printer reports both in its version answer, where the AMS Lite calls itself `ams_f1`. Bambuddy only knew the names of the other AMS models, so it skipped the AMS Lite's entry. The AMS popup showed "—" for both, the status API returned them empty, and the log warned on every connection that they were missing. They now come through on the A1, A1 Mini and A2L, including the A2L's AMS Lite, which the printer numbers differently. A name given to an AMS Lite before this, which was kept by its position because it had no serial number, still shows on the printer card and in the inventory's location column. Saving it again moves it to the serial number, so it follows the unit, and **Clear** now removes it; before, the old name came back after clearing.
 - **The queue's "View in File Manager" icon opens the file (#3336, reported by @Magical-Penguin)** — On a job added from the File Manager, the link icon next to the name went to `/library`, which is no page, so the screen stayed empty. It now opens the File Manager in the file's folder, with the file marked and scrolled into view, in the grid, list and column views. The folder is looked up when the link is opened, so a file moved since it was queued is still found. A deleted file, or one you can't see, shows a warning. The archive icon on the same cards opened the Archives page without pointing at the archive; it now marks the archive too, switches from the log or calendar view to the grid, and warns when the filters hide it. An archive tile on a project's page did the same and now opens its archive marked as well. Both look in a freshly loaded list, so an archive created since the page was last open is found. Wiki updated.
 - **An H2D Pro reports its chamber and can heat it, and so can an X1E (#3335, reported by @Spegeli)** — A printer stored as "H2D Pro", which is what discovery and the add-printer list store, was checked against the model lists as "H2D PRO", with the space kept, while the lists spell it "H2DPRO". So an H2D Pro got no chamber temperature on the printer card and in the API (Home Assistant included), setting its chamber temperature was refused, and queued prints got no chamber preheat or soak and never switched the airduct to heating, or back to cooling for a PLA print after ABS; the printer card did not offer its airduct toggle either. Every model check now compares the name without spaces, and the card lists the H2D Pro. Separately, the X1E was listed as having only a chamber sensor although it has a chamber heater (up to 60 °C): setting its chamber temperature was refused and the queue never preheated its chamber. It now does both; the X1E has no airduct flap, so none is switched.

+ 20 - 1
backend/app/services/slicer_3mf_convert.py

@@ -235,6 +235,25 @@ def merge_plate_3mfs(
     return out_buf.getvalue()
 
 
+def _with_own_colour(anchor_json: str, own_json: str) -> str:
+    """*anchor_json* carrying *own_json*'s ``filament_colour`` (#3355).
+
+    The substitution is there for the slicer's material and printer checks,
+    which never read the colour. Copying the anchor's colour too recoloured
+    the slot in the sliced file, and the print's AMS mapping then matched it
+    to the anchor's spool, so a slot wrongly taken for unused printed in the
+    wrong colour. A profile that isn't a JSON object is passed through as is.
+    """
+    try:
+        anchor = json.loads(anchor_json)
+        own = json.loads(own_json)
+    except json.JSONDecodeError:
+        return anchor_json
+    if not isinstance(anchor, dict) or not isinstance(own, dict) or "filament_colour" not in own:
+        return anchor_json
+    return json.dumps({**anchor, "filament_colour": own["filament_colour"]})
+
+
 def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | None, items: list[str]) -> list[str]:
     """Replace any filament-list entry whose 1-indexed slot isn't used by
     ``plate_id`` with the entry from the plate's lowest *used* slot.
@@ -326,7 +345,7 @@ def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | N
         slot = idx + 1
         if slot not in used:
             substituted.append(slot)
-            out[idx] = out[anchor_slot - 1]
+            out[idx] = _with_own_colour(out[anchor_slot - 1], items[idx])
     if substituted:
         logger.info(
             "Substituted slot-%s filament for unused slot(s) %s on plate %s "

+ 51 - 45
backend/app/utils/threemf_tools.py

@@ -1676,18 +1676,51 @@ def extract_support_filament_slots_from_3mf(zf: zipfile.ZipFile) -> set[int]:
 
 _PAINT_COLOR_ATTR_RE = re.compile(rb'paint_color="([0-9A-Fa-f]+)"')
 
-# Painted-face quadtree leaves include both real filament assignments and
-# tiny edit artifacts (single-leaf accidents from "tried a colour, undid,
-# repainted with a different one"). The threshold's only job is dropping
-# accidents — anything the user spent meaningful effort on must survive.
-# 5% of an object's painted triangles is well below any 60/40 / 70/30 /
-# 33/33/33 split a real two- or three-colour print would hit, so all
-# intentional colours are kept; one-off single-leaf paints (typically
-# 0.1-1.5% in observed projects) are filtered. Note that this fallback
-# path runs ONLY when the preview-slice path can't reach the sidecar; in
-# the normal flow the slicer's own pruning produces the canonical list and
-# this threshold isn't reached.
-_PAINT_NOISE_THRESHOLD = 0.05
+
+def _painted_states(paint_color: bytes) -> set[int]:
+    """Filament numbers a triangle's ``paint_color`` paints with (#3355).
+
+    The string is the slicer's TriangleSelector tree, one hex digit per
+    4-bit node, written last node first. Each node: bits 0-1 are the
+    number of split sides; a split node (1-3) keeps the special side in bits
+    2-3 and is followed by split + 1 children. A leaf keeps its state in bits
+    2-3 (0 = unpainted, 1 or 2 = that filament); ``0b11`` there means the next
+    node holds ``state - 3``, and a ``0xF`` in that one means one more holds
+    ``state - 18``. Unpainted leaves print with the object's or part's own
+    filament, which the caller already counts. Reading each digit as a
+    filament number instead (extruder 1 is written ``4``, 2 ``8``, 3 ``0C``)
+    left real colours out, and the slice then overwrote them.
+
+    A string that ends early is malformed: what was read is kept.
+    """
+    nibbles = [int(chr(c), 16) for c in reversed(paint_color)]
+    states: set[int] = set()
+    pos = 0
+    pending = 1  # nodes still to read
+    try:
+        while pending:
+            pending -= 1
+            code = nibbles[pos]
+            pos += 1
+            split_sides = code & 0b11
+            if split_sides:
+                pending += split_sides + 1
+                continue
+            if code & 0b1100 == 0b1100:
+                low = nibbles[pos]
+                pos += 1
+                if low == 0b1111:
+                    state = nibbles[pos] + 18
+                    pos += 1
+                else:
+                    state = low + 3
+            else:
+                state = code >> 2
+            if state:
+                states.add(state)
+    except IndexError:
+        pass
+    return states
 
 
 def extract_plate_extruder_set_from_3mf(zf: zipfile.ZipFile, plate_id: int) -> set[int]:
@@ -1703,12 +1736,9 @@ def extract_plate_extruder_set_from_3mf(zf: zipfile.ZipFile, plate_id: int) -> s
        with distinct filaments).
     3. ``3D/Objects/object_*.model`` — ``paint_color`` attributes on
        individual ``<triangle>`` elements (used when the user "painted" a
-       face with a different filament). The encoding is a hex string where
-       each nibble is a TriangleSelector tree node: ``0`` = unpainted leaf,
-       ``F`` = branch (4 children follow), ``1``..``E`` = leaf painted with
-       extruder N. We don't decode the tree — every leaf-paint nibble in
-       the string IS the extruder number, so a flat scan over hex chars
-       yields the correct set without recursive parsing.
+       face with a different filament), decoded by :func:`_painted_states`.
+       Every painted leaf counts, however small: the slicer prints it with
+       that filament, so the slot is used.
 
     Without (3) the painted-face data is invisible: model_settings says
     every object on a multi-color plate uses extruder 1 by default but the
@@ -1798,33 +1828,9 @@ def extract_plate_extruder_set_from_3mf(zf: zipfile.ZipFile, plate_id: int) -> s
         except OSError:
             paint_cache[path] = out
             return out
-        # Per-extruder triangle coverage. Each painted triangle may have
-        # multiple leaf nibbles (the quadtree subdivides the face into
-        # painted regions); we count one triangle per unique extruder per
-        # match so the resulting fraction is "what share of painted
-        # triangles include at least one leaf with extruder N". Noise from
-        # one-off edit artifacts is filtered out at the threshold below.
-        extruder_triangles: dict[int, int] = {}
-        total_painted = 0
-        for match in _PAINT_COLOR_ATTR_RE.finditer(data):
-            total_painted += 1
-            seen: set[int] = set()
-            for ch in match.group(1):
-                # Hex digit → 4-bit value. 0 = unpainted leaf, F = branch
-                # (decoded recursively but children are encoded inline, so
-                # we'll see them on later iterations). 1-E = leaf painted
-                # with extruder N.
-                if ch in b"123456789":
-                    seen.add(ch - 0x30)
-                elif ch in b"ABCDEabcde":
-                    seen.add((ch & 0x4F) - 0x37)
-            for e in seen:
-                extruder_triangles[e] = extruder_triangles.get(e, 0) + 1
-        if total_painted > 0:
-            cutoff = max(1, int(total_painted * _PAINT_NOISE_THRESHOLD))
-            for ext, count in extruder_triangles.items():
-                if count >= cutoff:
-                    out.add(ext)
+        # Whole-triangle paints repeat ("4", "8", ...): decode each string once.
+        for paint in {match.group(1) for match in _PAINT_COLOR_ATTR_RE.finditer(data)}:
+            out |= _painted_states(paint)
         paint_cache[path] = out
         return out
 

+ 189 - 0
backend/tests/_fixtures/painted_cube_3355.py

@@ -0,0 +1,189 @@
+"""Paint captured from a real Bambu Studio 02.08.02.61 project (#3355).
+
+A cube on filament 1 (its object default), painted with filaments 2, 3 and 4
+using the brush, so most triangles are split. One entry per triangle, as
+written to ``paint_color`` in ``3D/Objects/object_1.model``. Decoded, the
+leaves are unpainted (filament 1) or painted 2, 3 and 4.
+"""
+
+CUBE_PAINT_COLORS = [
+    (
+        "00000800300300388880003883838800888300803300008003030008003033888008"
+        "03883833088808308088338000300030800030003300033030008808380880833880"
+        "00300038880830808833300388800030080300803808883388088388330003888088"
+        "38388880883883830888000300803808833008030808883388088388333300000803"
+        "88083080033888000383008883003388088388308008038883388388808830003008"
+        "03808833000888083880833038800888300803388330080038883003880008038088"
+        "33000388083330300008883003880883000388083388838880883883008883008030"
+        "80888333000088083038808300888300330008088083303383000080038088083388"
+        "00030808833080003303003330000000800300300300388800030003000800303808"
+        "83008883008033300038880003838800030003880080383388808830088830080338"
+        "30880003000300080030380883008883008033333888088308088833883000088083"
+        "03800888380003080033300808883308000803808883333000388888008038838838"
+        "30008880830800330300088838000308003388830008003808000330338888000308"
+        "88380883388388333030000088838880883838880883830880003008038088333088"
+        "80003830033888088383880830800033880838000308000330003080883000300033"
+        "00080038800038808330030033888800803883000800303800803888338838300008"
+        "00300303008888083080883388083808808333000080030030330000080388083080"
+        "03380080388388388880883883830088830038880003008038088338880003833300"
+        "08003808880830800033303388880003000300803888380080388338838888000388"
+        "3883883833033"
+    ),
+    (
+        "00008003003888808838300803008883008033880883883308880830808833000330"
+        "03003000880003080033888388000308000330003008003808000330033888008030"
+        "80888330008003038088833838888088300038838338800803838000300030800030"
+        "00330003000300008003003888000383080003000330030033003888000300030003"
+        "00030008003880003880833038008038830800803088838088833300888388830008"
+        "80830380088838808308003333300008038088000308003338088880830800033080"
+        "03808000333308883888388838883808883888388838880888380888338388338880"
+        "88388308008030088838088833808883338833000008880003838808830003880080"
+        "38330030030300000088838000308003388838800803838808300030800030003308"
+        "80838800038808330033003033088888808838838838830008808380800033030088"
+        "83808883388838088808388083388833008030800888380800333000800303800888"
+        "38883808003888333383880883008883008033000080388083080033000800303388"
+        "08830088830080338838300000088083800030800033030330000080030038880883"
+        "83088083000338880883838880830808833080008030080333088000300803808833"
+        "00033030308880003000300080030380883008883008033300080030388008883880"
+        "83080033388088300888300803388330000888380888338088808388083338088800"
+        "03080003388083808000333380888008030088830080338308003808000338800030"
+        "00388808308088333338880883000300803008038088833800803883300803080008"
+        "03808003338880003883883300030008003888388838800803000803008033883830"
+        "08808300030003033880008038883808830080380888333883000800380800033003"
+        "88808838388808388800300803808833388800888380883383800030888083880833"
+        "00330000888300303008800030800338883000880830030333333"
+    ),
+    "8",
+    "8",
+    "0C",
+    "0C",
+    "1C",
+    "1C",
+    (
+        "00001C0031C1C1C31C1C1C31C1C1C1C00031C1C31C1C31C30001C1C01C30030031C1"
+        "C01C30003000301C000300030003331C1C01C1C300030003000300031C1C1C1C1C01"
+        "C1C31C1C31C1C31C31C1C1C001C0301C001C0331C30001C1C000301C0033031C1C00"
+        "01C03001C0331C1C3301C1C000300030001C031C01C1C1C01C301C003331C1C01C1C"
+        "31C1C301C001C031C1C1C33333030000001C0030031C1C01C301C0003000331C1C1C"
+        "01C1C31C31C1C01C301C0001C03001C033301C000300033003030001C1C1C31C1C1C"
+        "31C1C1C31C1C1C31C1C1C001C0301C001C0331C30001C1C00031C1C01C33031C1C00"
+        "01C03001C0331C1C3301C0031C01C1C01C331C1C01C1C300031C1C01C331C1C0001C"
+        "031C01C1C3300031C1C001C03001C1C1C3001C0331C3331C1C1C30000001C0031C01"
+        "C00033003003031C1C1C0001C03001C0331C31C1C01C1C3000300031C1C001C0301C"
+        "001C0331C331C1C1C1C01C1C3001C1C1C3001C0331C1C31C301C1C1C0003000301C0"
+        "1C1C30003300001C003003031C001C1C1C3001C0301C01C1C1C3333300001C1C0003"
+        "01C00330030030033300000001C003003003003031C1C1C1C1C0001C03001C033001"
+        "C03001C031C01C1C1C331C1C01C1C31C1C331C1C31C31C1C1C1C1C01C1C31C1C31C1"
+        "C31C1C30000001C003031C1C1C00031C3001C00300331C1C1C1C01C1C31C1C31C300"
+        "1C0031C1C1C31C1C01C30003001C00300333001C0301C01C1C1C3301C001C1C1C31C"
+        "01C1C1C333001C1C1C31C1C1C31C1C1C300001C031C01C00331C01C1C1C01C301C00"
+        "333331C1C1C1C1C01C1C301C01C1C1C331C1C31C1C31C33000001C1C1C31C1C01C30"
+        "1C00331C1C01C31C1C000300031C1C01C33001C00300331C1C000300030001C00303"
+        "1C01C1C3300030001C1C1C30031C1C01C1C300031C1C01C33003300333"
+    ),
+    (
+        "0000001C1C1C31C1C01C301C00331C1C01C31C1C01C301C00033001C00300331C1C0"
+        "1C31C1C01C301C000331C1C01C31C1C01C301C001C3301C0003000330001C0030030"
+        "0331C1C01C31C000301C000330003000300001C00300300300331C1C1C1C1C1C01C1"
+        "C3001C1C1C3001C0331C1C31C300001C003031C001C1C1C31C1C01C301C003330001"
+        "C0031C1C1C3031C0001C031C1C01C301C00331C1C1C331C1C1C000300031C1C01C1C"
+        "301C1C1C31C01C1C1C3331C1C331C1C1C1C1C01C1C31C1C30001C1C01C3030001C1C"
+        "1C3033001C0301C1C1C31C01C1C1C331C1C1C31C1C001C031C1C31C1C331C331C1C0"
+        "001C031C01C1C33001C031C1C01C1C31C1C330003000300030001C1C1C00031C3003"
+        "1C1C1C001C1C1C31C01C1C331C301C1C001C031C31C0003000301C00030003330030"
+        "0331C1C1C1C1C0003001C031C01C1C33001C03001C031C01C1C1C331C1C01C1C31C1"
+        "C331C1C300001C0031C01C1C000301C000333030001C1C1C000301C0003301C0031C"
+        "01C0003330330001C0031C01C1C01C33030001C00303001C031C01C1C1C331C001C1"
+        "C1C30031C1C1C33001C03001C031C01C1C1C330001C00303001C1C1C30001C003033"
+        "1C001C1C1C31C01C00331C1C1C331C01C1C1C31C1C1C31C1C1C331C1C1C1C01C1C31"
+        "C1C31C1C30001C1C1C303001C1C1C3001C1C01C30331C01C1C1C31C1C1C31C1C1C33"
+        "1C1C330003001C1C01C31C01C1C01C331C1C000300031C1C01C331C1C1C000301C1C"
+        "1C31C01C1C3300031C1C0001C1C1C3031C331C0001C1C1C3031C1C3001C1C1C31C1C"
+        "1C31C1C1C30001C00303001C031C01C1C1C01C31C1C01C33331C1C1C00031C1C31C1"
+        "C300001C1C01C30030300001C1C1C31C1C01C301C00330331C1C1C01C1C3001C1C1C"
+        "31C1C1C3001C0331C33030000001C1C1C01C1C31C3003031C1C1C1C01C1C31C301C1"
+        "C00031C1C331C1C01C31C1C01C31C000301C0003301C01C1C30003301C0001C031C0"
+        "1C1C33000330001C1C1C31C1C1C300300330000001C0031C01C1C01C33003031C1C1"
+        "C1C00030001C031C01C00331C01C1C331C30001C1C1C31C1C01C30003001C0030033"
+        "00330001C0031C01C1C1C01C301C01C1C33303001C1C1C31C1C1C31C1C1C300001C1"
+        "C01C3031C1C1C01C1C31C3001C1C1C300331C1C1C01C1C31C3001C1C1C31C1C1C31C"
+        "0003000301C1C01C3003333331C1C1C1C000300030001C0031C01C00033031C01C1C"
+        "3001C1C1C1C01C33001C0333001C0301C1C1C1C01C31C1C1C01C31C1C01C3331C01C"
+        "1C1C331C1C1C01C1C31C1C301C01C1C1C31C1C1C331C1C331C1C300001C1C1C31C1C"
+        "1C31C1C1C31C1C1C01C3001C031C01C1C33000301C1C01C30031C1C1C00031C301C1"
+        "C01C30003331C1C1C0003001C031C01C1C3301C1C1C31C1C1C31C1C01C1C31C1C330"
+        "00001C0030030300001C003003033000001C1C01C300303031C1C01C1C301C01C1C1"
+        "C330001C1C1C31C01C1C1C33001C0331C1C01C1C31C1C301C01C1C1C31C1C1C331C3"
+        "303000001C1C1C31C1C1C31C1C000301C01C1C330003001C0030031C1C01C31C01C1"
+        "C01C3301C00030003330030300001C0030030300001C0031C1C1C31C1C1C01C301C0"
+        "003301C00331C1C1C31C1C1C1C01C1C31C1C31C31C1C1C01C1C3001C003031C1C01C"
+        "1C1C31C3301C1C00030003001C1C01C3031C01C1C3001C1C1C3001C033330001C003"
+        "0030031C1C1C1C000301C1C1C31C01C1C331C301C00030003000333000001C1C1C30"
+        "031C01C1C000301C00033300303333"
+    ),
+    (
+        "0000000C0030C0C00030C0C00C330030030C0C00030003000300030C0C0C00C0C30C"
+        "30C0C000C030C300C0C0003000C030C00C0C3330C0C00C30C0C00C300C0003300C00"
+        "03000333030C0C0C0C0C0C00C0C30C0C30000C0C00C3030000C0C0C3033000C030C0"
+        "C0C30C0C0C30C0C0C00C0C30C0C30C0C330C0C30C3000000C0C0C30C0C00C300C003"
+        "30C00030003000C00300330030C0C0C0C0C00C0C300C000C0330C0C30C30C0C0C0C0"
+        "003000C0300C0C0C30C00C0C0C330C0C00C0C30C0C330C330C0C0C000C03000C0C0C"
+        "3000C0330C300C0C00C0C30003000C030C0C0C30C0C00C0C30C0C3330C0C00C30C00"
+        "03000300C000300033000300C0000C030C00C0C33000300033000C0C0C00030C3003"
+        "0C00C0C0C00C0C30C30C0C0C00C300C00C0C3300C000333003330000000C0C0C00C3"
+        "0C0C00C330030C0C0C0C00C0C3000C030C00C0C0C330C30C0C0C0C00030C0C30C330"
+        "C0C000300C00C0C3300030003000C0C00C30C00C0C00C3300300330C0C0C0C0C0003"
+        "00030000C003030C00C0C3000C0C0C3000C0333000C030C0C00C0C3000C0C0C3000C"
+        "0330C0C330000C0030300C00000C003030C0C00C30C00C00033000C003003330C0C0"
+        "000C030C0C0C3000C03000C030C00C0C0C3330C0C330C0C0C00000C030C00C00330C"
+        "0C0C3000C03000C030C00C0C0C33000C030C000C030C0C0C3330C33030000000C003"
+        "0030030C00C0C00C30C0C00C300C000330C0C0C00C300C0003300C0C00C300033303"
+        "0C0C0C0C0C00C0C30C0C30C0C30C30C0C0C00C0C30C0C30000C030C0C0C3000C0300"
+        "0C030C00C0C0C33300000C0C00C3030C0C00C3000C00300330000C0030330C0C0C0C"
+        "0C00C0C30C0C30C0C30C33000C0C0C0C000300C0003300C000C0C0C30C00C0C3330C"
+        "0C00C30C00C0C00C330C00C0C0C00C300C00C0C333300330000C0C0C30C0C00C300C"
+        "00330C0C0C30C0C0C30C0C0C30C0C0C0C0C0C0C030C0C0C30C0C0C30C30C30C0C300"
+        "000C0030C00C0C00C330C0C00C0C300030C0C00C33030C0C0C00C0C3000C0C0C3000"
+        "C0330000C0C0C30C0C00C300C00330000C0030330C30000C0C0C30C000300C00330C"
+        "0C0C300330000C0030C00C0C00C330300000C003030C000C0C0C300330C00C0C0C33"
+        "0000C00303000C030C00C0C0C00C30C0C00C333000C030000C0C0C3030C00C0C0C33"
+        "0C00C0C0C30C0C0C330C000C0C0C30C0C00C300C00330C0C0C30C0C0C330C000C0C0"
+        "C30C0C00C30003000C00300330C0C0C30C0C0C3333"
+    ),
+    (
+        "0000C0C0C30C0C0C30C0003000300C0C00C300330C0C0C0C00C0C300C00C0C0C330C"
+        "30C0C0C00C0C30C330C0C0C30C0C0C300000C0030C0C00C300C000330030030C0C00"
+        "C30C00C0C00C330C0C0C00C0C30C300C000330C0C0C00C0C300030C300C0C000C030"
+        "C30003300C000300030003000330C0C0C00C0C300C00C0C0C330C30C0C0C00C30C0C"
+        "00C300C0003300C000C0C0C3000C033300C00000C003030000C0C0C303330C0C00C3"
+        "0C00C000330C0C00C300C000330C0C00C30C0C00C30C0C00C300C0003300C0003000"
+        "3300C00030003300C0003000300030003300C0C00030000C003030C000C0C0C3000C"
+        "033300030003330C0C0C0000C03000C03300030000C003030000C030C00C0C0C330C"
+        "00C0C0C330C0000C0C0C3030C0C3300030000C0030300000C0030030C0C00030C0C0"
+        "0C30C00C000333030C000C0C0C30C0C0C30C0C000C03000C0C0C3000C0330C300C00"
+        "30C0C000300030C0C0C00C300C00C0C333330C0000C0C0C303000C0C0C30000C0030"
+        "33000C0C0C0C00C0C30C300C0030C0C00030C0C00C3330000C0030330C0C33000300"
+        "000C0C000300C00330030C0C00C0C3000300030C0C0C00C0C30C30C0C00C300C0000"
+        "C030C00C0C33330C0C000300030003000C030C0C0C30C00C0C300C00C0C0C330C0C3"
+        "0C000C03000C030C00C0C0C330C0C3300C0C0C00030000C0C0C3030C00C0C3300000"
+        "C0C0C30C000300C0033030C0C00C0C30000C03000C0330C0C333030000000C0C0003"
+        "00C003300300303000000C0C00C30C0C00030C0C00C33003030C0C0C0C000C030C0C"
+        "30C300C0C0C000300C0C0C30C00C0C330C0C330000C0C030C0C0C30C0C0C00C300C0"
+        "0C0C3300C0030C00C0C00C3330033000000C0030030C0C00C300C000300033003033"
+        "0000000C0C0C30C0C0C30C0C0C00C300C00C0C3300C0030C00C000333003030C0C00"
+        "0C03000C0C0C3000C0330C0C300000C003003030C000C0C00C303000C0300C0C0C30"
+        "C00C0C0C330C00C0030C0C0C3330C0C000C0C0C3000C0330000C0C00C3030000C003"
+        "03300C0C0C30C0C0C30C0C0C30C0C0C00C0C30C0C30C0C330C3000C0C0C000C03000"
+        "C0C0C3000C0330C30C0C00C30C0C00030C0C00C330C0C00C0C300030C0C000C030C3"
+        "330C0C000C030C30C0C00C3000300C00C0C3000330003000C0C00C30C0C00030C0C0"
+        "0C330030033330C0C0C0C00C0C3000C0C0C3000C0330C0C3000C0C0C30000C003030"
+        "0C0000C030C0C00C300C0033330C0C30C0C30000000C0030C0C00030C0C00C330030"
+        "30000C0C0C30030C0C0C000C030C30C0C0C00C0C30C330C0C0C3000C0C00C30C00C0"
+        "00330C0C0C00C0C30C300C0003303300000C0C00C30030C0C00C30C0C00C300C0003"
+        "300C0003000330300000C003030C000C0C0C300330000C030C00C0C0C330C0C0C30C"
+        "00C0C0C30C0C0C330C00C0C0C0C00C300C00C0C3300C0030C00C0003330C0C0C3300"
+        "00000C003003003030C0C0C0C00030C0C30C30C0C0C00C0C300030000C030C00C0C0"
+        "C330C00C0C330C0C0C0C00C0C30C0C30C3300000C0C0C30C000300C0033003003333"
+        "3"
+    ),
+]

+ 49 - 0
backend/tests/unit/services/test_slicer_3mf_convert.py

@@ -493,3 +493,52 @@ class TestSubstituteUnusedPlateFilaments:
         items = ["a.json", "b.json"]
 
         assert substitute_unused_plate_filaments(zip_bytes, plate_id=-1, items=items) == items
+
+    def test_a_substituted_slot_keeps_its_own_colour(self):
+        """The anchor's profile, for the slicer's material checks, but the
+        slot's own colour (#3355): copying the anchor's colour recoloured the
+        slot in the sliced file and the AMS mapping then matched it to the
+        anchor's spool."""
+        zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1])])})
+        items = [
+            json.dumps({"name": "Bambu PLA Basic", "filament_colour": ["#FFFFFF"]}),
+            json.dumps({"name": "Bambu ABS", "filament_colour": ["#000000"]}),
+            json.dumps({"name": "Bambu ABS"}),
+        ]
+
+        result = [json.loads(p) for p in substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)]
+
+        assert result[1] == {"name": "Bambu PLA Basic", "filament_colour": ["#000000"]}
+        # A slot with no colour of its own takes the anchor's profile as is.
+        assert result[2] == {"name": "Bambu PLA Basic", "filament_colour": ["#FFFFFF"]}
+
+    def test_a_painted_model_keeps_every_colour(self):
+        """The reporter's case end to end (#3355): an object on filament 3
+        with filaments 1 and 2 painted on. Every slot is used."""
+        triangles = "".join(f'<triangle v1="0" v2="1" v3="2" paint_color="{p}"/>' for p in ["4"] * 30 + ["8"] * 30)
+        zip_bytes = _make_3mf(
+            {
+                "Metadata/model_settings.config": self._model_settings_xml([(1, [3])]),
+                "3D/3dmodel.model": (
+                    b'<?xml version="1.0"?>'
+                    b'<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"'
+                    b' xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06">'
+                    b'<resources><object id="1" type="model"><components>'
+                    b'<component p:path="/3D/Objects/object_1.model" objectid="2"/>'
+                    b"</components></object></resources><build/></model>"
+                ),
+                "3D/Objects/object_1.model": (
+                    '<?xml version="1.0"?><model><resources><object id="2" type="model"><mesh>'
+                    f"<triangles>{triangles}</triangles></mesh></object></resources><build/></model>"
+                ).encode(),
+            }
+        )
+        items = [
+            json.dumps({"name": "Bambu PLA Basic", "filament_colour": [c]})
+            for c in ["#000000", "#FFFFFF", "#00AE42", "#00AE42"]
+        ]
+
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
+
+        # Slot 4 is unused and takes slot 1's profile, with its own colour.
+        assert [json.loads(p)["filament_colour"][0] for p in result] == ["#000000", "#FFFFFF", "#00AE42", "#00AE42"]

+ 50 - 63
backend/tests/unit/test_threemf_tools.py

@@ -699,65 +699,20 @@ class TestExtractPlateExtruderSetFrom3mf:
         with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
             assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == set()
 
-    def test_painted_face_above_threshold_kept(self):
-        # 60/40 split: 60 triangles painted with extruder 1, 40 with ext 2.
-        # Threshold is 5%; both above. The dominant ones are real colours.
-        triangles = []
-        for _ in range(60):
-            triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="1"/>')
-        for _ in range(40):
-            triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="2"/>')
+    @staticmethod
+    def _painted(paints: list[str], object_extruder: int | None = None) -> zipfile.ZipFile:
+        """One object on plate 1 whose triangles carry *paints* (#3355)."""
+        triangles = "".join(f'<triangle v1="0" v2="1" v3="2" paint_color="{p}"/>' for p in paints)
         per_obj = (
             '<?xml version="1.0"?>'
             '<model><resources><object id="100" type="model"><mesh>'
-            "<triangles>" + "".join(triangles) + "</triangles>"
+            f"<triangles>{triangles}</triangles>"
             "</mesh></object></resources><build/></model>"
         )
+        extruder = f'<metadata key="extruder" value="{object_extruder}"/>' if object_extruder else ""
         ms = (
             '<?xml version="1.0"?><config>'
-            '<object id="10"><metadata key="name" value="o"/></object>'
-            '<plate><metadata key="plater_id" value="1"/>'
-            '<model_instance><metadata key="object_id" value="10"/></model_instance>'
-            "</plate></config>"
-        )
-        threed = (
-            '<?xml version="1.0"?>'
-            '<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"'
-            ' xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06">'
-            "<resources>"
-            '<object id="10" type="model"><components>'
-            '<component p:path="/3D/Objects/o100.model" objectid="100"/>'
-            "</components></object>"
-            "</resources><build/></model>"
-        )
-        with _make_3mf_with(
-            {
-                "Metadata/model_settings.config": ms,
-                "3D/3dmodel.model": threed,
-                "3D/Objects/o100.model": per_obj,
-            }
-        ) as zf:
-            result = extract_plate_extruder_set_from_3mf(zf, plate_id=1)
-        # Both real colours kept (60/40 well above 5% threshold); the dropped
-        # threshold case is the regression that motivates this test.
-        assert result == {1, 2}
-
-    def test_painted_face_below_threshold_dropped_as_noise(self):
-        # 99 triangles at ext 1, 1 triangle at ext 9 (1% — below 5%
-        # threshold). The 1% leaf is a single-leaf accident.
-        triangles = []
-        for _ in range(99):
-            triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="1"/>')
-        triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="9"/>')
-        per_obj = (
-            '<?xml version="1.0"?>'
-            '<model><resources><object id="100" type="model"><mesh>'
-            "<triangles>" + "".join(triangles) + "</triangles>"
-            "</mesh></object></resources><build/></model>"
-        )
-        ms = (
-            '<?xml version="1.0"?><config>'
-            '<object id="10"><metadata key="name" value="o"/></object>'
+            f'<object id="10"><metadata key="name" value="o"/>{extruder}</object>'
             '<plate><metadata key="plater_id" value="1"/>'
             '<model_instance><metadata key="object_id" value="10"/></model_instance>'
             "</plate></config>"
@@ -770,17 +725,49 @@ class TestExtractPlateExtruderSetFrom3mf:
             '<component p:path="/3D/Objects/o100.model" objectid="100"/>'
             "</components></object></resources><build/></model>"
         )
-        with _make_3mf_with(
-            {
-                "Metadata/model_settings.config": ms,
-                "3D/3dmodel.model": threed,
-                "3D/Objects/o100.model": per_obj,
-            }
-        ) as zf:
-            result = extract_plate_extruder_set_from_3mf(zf, plate_id=1)
-        # Single-leaf accident at 1% filtered as noise; only the dominant
-        # extruder survives.
-        assert result == {1}
+        return _make_3mf_with(
+            {"Metadata/model_settings.config": ms, "3D/3dmodel.model": threed, "3D/Objects/o100.model": per_obj}
+        )
+
+    @pytest.mark.parametrize(
+        ("paint", "filament"),
+        [("4", 1), ("8", 2), ("0C", 3), ("1C", 4), ("CC", 15), ("0FC", 18), ("2FC", 20)],
+    )
+    def test_a_whole_painted_triangle_is_its_filament(self, paint, filament):
+        # How the slicer writes a leaf: state 1-2 in bits 2-3, state 3+ as
+        # 0b11 plus a nibble of state-3 (0xF plus state-18 from 18 on),
+        # last nibble first. Each digit is not a filament number.
+        with self._painted([paint]) as zf:
+            assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {filament}
+
+    def test_a_split_triangle_counts_every_painted_part(self):
+        # Split in two (code 1), children painted 2 and 3, one part unpainted
+        # in a three-way split (code 2, special side 1 = 0b0110).
+        with self._painted(["0C81", "0C806"]) as zf:
+            assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {2, 3}
+
+    def test_the_reporters_case(self):
+        # Object on filament 3, Black (1) and White (2) painted on: every slot
+        # is used, so none may be substituted.
+        with self._painted(["4"] * 30 + ["8"] * 30, object_extruder=3) as zf:
+            assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {1, 2, 3}
+
+    def test_a_small_painted_spot_is_a_used_filament(self):
+        # The slicer prints it with that filament, however small. The old 5%
+        # "noise" cutoff dropped it.
+        with self._painted(["4"] * 99 + ["1C"]) as zf:
+            assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {1, 4}
+
+    def test_a_real_bambu_studio_paint(self):
+        from backend.tests._fixtures.painted_cube_3355 import CUBE_PAINT_COLORS
+
+        with self._painted(CUBE_PAINT_COLORS, object_extruder=1) as zf:
+            assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {1, 2, 3, 4}
+
+    def test_a_truncated_paint_keeps_what_was_read(self):
+        # Split in two but only one child present: the child still counts.
+        with self._painted(["81", "C"]) as zf:
+            assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {2}
 
     def test_missing_per_object_model_file_silently_skipped(self):
         ms = (