Просмотр исходного кода

Stop an H2C refusing a multi-colour print as a hotend mismatch

The print uploaded, the printer took the command, and stopped at once with
HMS 0500-4047 -- "the available hotend quantity or model does not match the
sliced file". nozzle_mapping told the printer one of the plate's filaments
went to no hotend while ams_mapping named the tray it comes from, and the
firmware will not start a job on that contradiction.

Each filament in a 3MF names the group it belongs to, and on every other
dual-nozzle Bambu the group number is also the extruder index, so it was
read as one. On a rack machine it is not: the rack carriage holds six
hotends to the fixed carriage's one, so the slicer writes a group per
nozzle rather than per carriage. The failing plate carried groups 0, 1 and
2 against a two-entry physical_extruder_map, and the filament in group 2
was dropped -- indistinguishable downstream from a slot the plate does not
print, which is what reached the wire as -1.

extract_nozzle_mapping_from_3mf now resolves the group through the table
the file states for itself, the <nozzle id extruder_id> elements in
slice_info.config. Files carrying no such table keep the direct index, so
H2D slices are unaffected. A filament that still cannot be placed drops
the whole mapping with a logged reason instead of half an answer: the
firmware then picks its own nozzle, which is the pre-existing behaviour
and far better than an answer that contradicts itself.

Two related faults fixed in the same pass. The mapping was read across
every plate in the file, so on a multi-plate project a slot took its
extruder from whichever plate came last; it is now scoped to the plate
being dispatched, in extract_filament_requirements as well. And the array
is now one entry per filament slot, matching BambuStudio's own dispatch of
[1, 16, 16] for a three-filament plate, rather than padded to a fixed 32.

Verified against the file that failed: slot extruders [-1, 1, 0] became
[0, 1, 0], and the wire [-1, 16, 1, -1 x29] became [1, 16, 1].
maziggy 3 недель назад
Родитель
Сommit
dfeac792fb

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 8 - 4
backend/app/services/bambu_mqtt.py

@@ -286,8 +286,11 @@ def apply_tray_exist_bits(
 # doing exactly that is what #2800 was.
 _RACK_NOZZLE_IDS = frozenset(range(16, 22))
 
-# BambuStudio dispatches a fixed-length nozzle_mapping on rack models: one
-# physical nozzle ID per filament slot, -1 for slots the plate does not print.
+# Ceiling on the nozzle_mapping we will build, not the length we send. The wire
+# carries one physical nozzle ID per filament slot the plate declares, and -1
+# for a slot it does not print: BambuStudio's own dispatch of a three-filament
+# H2C plate is [1, 16, 16], and the hardware A/B in #2800 ran four-slot plates
+# as four entries. Padding to a fixed 32 was an over-generalisation of that.
 _RACK_WIRE_SLOTS = 32
 
 # The two carriages, as extruder indices in the form the queue stores (already
@@ -317,7 +320,8 @@ def resolve_rack_nozzle_mapping(
     plate does not print. ``rack_nozzle_id`` is the rack position the printer
     reports as live.
 
-    Returns a ``_RACK_WIRE_SLOTS``-long list of physical nozzle IDs, or None
+    Returns one physical nozzle ID per slot given, matching BambuStudio's own
+    dispatch length, or None
     when the mapping cannot be resolved with confidence -- in which case the
     caller omits the field entirely and the firmware falls back to its own
     nozzle pick, exactly as it did before this translation existed. Omitting
@@ -367,7 +371,7 @@ def resolve_rack_nozzle_mapping(
     if _RACK_EXTRUDER_ID not in normalised:
         return None
 
-    wire = [-1] * _RACK_WIRE_SLOTS
+    wire = [-1] * len(normalised)
     for index, extruder in enumerate(normalised):
         if extruder < 0:
             continue

+ 5 - 1
backend/app/services/filament_requirements.py

@@ -91,7 +91,11 @@ def extract_filament_requirements(file_path: Path, plate_id: int | None = None)
             # Dual-nozzle printers (H2D / X2D) — annotate which extruder each
             # slot is fed into. Empty mapping for single-nozzle printers, in
             # which case we just don't add the key.
-            nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
+            # Same plate the filaments above were collected from: a multi-plate
+            # file can assign one slot to different extruders per plate, and
+            # annotating slot 2 with plate 3's nozzle is worse than not
+            # annotating it.
+            nozzle_mapping = extract_nozzle_mapping_from_3mf(zf, plate_id=plate_id)
             if nozzle_mapping:
                 for filament in filaments:
                     filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])

+ 1 - 1
backend/app/services/print_scheduler.py

@@ -5761,7 +5761,7 @@ class PrintScheduler:
         # thrown away on every dispatch.
         nozzle_slot_extruders = None
         if not item.nozzle_mapping and file_path is not None and is_nozzle_rack_model(printer.model):
-            slot_extruders = extract_slot_extruders_from_3mf(file_path)
+            slot_extruders = extract_slot_extruders_from_3mf(file_path, plate_id=item.plate_id or 1)
             if slot_extruders:
                 nozzle_slot_extruders = json.dumps(slot_extruders)
 

+ 137 - 25
backend/app/utils/threemf_tools.py

@@ -16,6 +16,10 @@ from dataclasses import dataclass, field
 from pathlib import Path
 from threading import Lock
 
+# Parsing goes through defusedxml; the element type it hands back is the stdlib
+# one, and defusedxml does not re-export it, so annotations name it directly.
+from xml.etree.ElementTree import Element as XmlElement
+
 import defusedxml.ElementTree as ET
 
 logger = logging.getLogger(__name__)
@@ -330,7 +334,7 @@ def extract_embedded_presets_from_3mf(zf: zipfile.ZipFile) -> dict[str, str | No
 _MAX_DENSE_FILAMENT_SLOTS = 64
 
 
-def extract_slot_extruders_from_3mf(file_path: Path) -> list[int] | None:
+def extract_slot_extruders_from_3mf(file_path: Path, plate_id: int | None = None) -> list[int] | None:
     """Per-slot extruder assignment as a dense list, or None (#2800).
 
     Same data as :func:`extract_nozzle_mapping_from_3mf`, reshaped for the
@@ -340,6 +344,11 @@ def extract_slot_extruders_from_3mf(file_path: Path) -> list[int] | None:
     picking a nozzle themselves, which can level with one hotend and print
     with another, several millimetres off the bed.
 
+    ``plate_id`` scopes the answer to the plate actually being dispatched. A
+    multi-plate 3MF carries one filament list per plate and they need not
+    agree, so without it a slot can take its extruder from a plate this print
+    is not going to run.
+
     Takes a path rather than an open archive because the dispatcher is
     handling the file, not the zip, and a broken file there must not take the
     print down: an unreadable or non-3MF path returns None, and the caller
@@ -347,7 +356,7 @@ def extract_slot_extruders_from_3mf(file_path: Path) -> list[int] | None:
     """
     try:
         with zipfile.ZipFile(file_path) as zf:
-            by_slot = extract_nozzle_mapping_from_3mf(zf)
+            by_slot = extract_nozzle_mapping_from_3mf(zf, plate_id=plate_id)
     except (zipfile.BadZipFile, OSError) as exc:
         logger.warning("Failed to read nozzle mapping from %s: %s", file_path, exc)
         return None
@@ -371,7 +380,63 @@ def extract_slot_extruders_from_3mf(file_path: Path) -> list[int] | None:
     return [by_slot.get(slot, -1) for slot in range(1, highest_slot + 1)]
 
 
-def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | None:
+def _plates_in_scope(si_root: XmlElement, plate_id: int | None) -> list[XmlElement]:
+    """The ``<plate>`` elements a lookup should read, narrowed to one if asked.
+
+    A 3MF holds every plate in the project, each with its own filament list, and
+    two plates may assign the same slot to different extruders. Falls back to
+    every plate when no id is given or none matches, which is what this module
+    did before plates were distinguished at all.
+    """
+    plates = si_root.findall(".//plate")
+    if not plates:
+        return [si_root]
+    if plate_id is None:
+        return plates
+    for plate in plates:
+        for metadata in plate.findall("metadata"):
+            if metadata.get("key") != "index":
+                continue
+            try:
+                if int(metadata.get("value") or "") == plate_id:
+                    return [plate]
+            except (TypeError, ValueError):
+                pass
+    return plates
+
+
+def _group_extruder_indices(plates: list[XmlElement]) -> dict[int, int] | None:
+    """Map each filament group to the slicer extruder index it prints on.
+
+    ``slice_info.config`` states this directly, as ``<nozzle id="<group>"
+    extruder_id="<1-based extruder>"/>``. Reading it matters on nozzle-rack
+    printers, where the group id is *not* an extruder index: the H2C's rack
+    carriage can host six hotends (``extruder_max_nozzle_count`` is ``['1',
+    '6']``), so the slicer emits more groups than the machine has extruders and
+    several groups share one carriage. A plate of the reporter's carried groups
+    0, 1 and 2 against a two-entry ``physical_extruder_map``.
+
+    Returns None when the file states no table, or when two plates in scope
+    disagree about a group — in which case the caller keeps treating the group
+    id as the extruder index, which is what every H2D file in practice wants
+    and what this module has always done.
+    """
+    table: dict[int, int] = {}
+    for plate in plates:
+        for nozzle in plate.findall(".//nozzle"):
+            try:
+                group_id = int(nozzle.get("id") or "")
+                extruder_index = int(nozzle.get("extruder_id") or "") - 1
+            except (TypeError, ValueError):
+                return None
+            if extruder_index < 0:
+                return None
+            if table.setdefault(group_id, extruder_index) != extruder_index:
+                return None
+    return table or None
+
+
+def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile, plate_id: int | None = None) -> dict[int, int] | None:
     """Extract per-slot nozzle/extruder mapping from a 3MF file.
 
     On dual-nozzle printers (H2D, H2D Pro), each filament slot is assigned to a
@@ -380,17 +445,27 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
     attributes, not from the user's filament_nozzle_map preference.
 
     Priority:
-        1. group_id on <filament> elements in slice_info.config (actual assignment)
+        1. group_id on <filament> elements in slice_info.config (actual assignment),
+           resolved through the file's own group-to-extruder table
         2. filament_nozzle_map in project_settings.config (user preference fallback)
 
     Both are mapped through physical_extruder_map to get MQTT extruder IDs (0=right, 1=left).
 
+    Returns None rather than a partial answer whenever a filament the plate
+    prints cannot be placed. The gap does not stay a gap downstream: the dense
+    form fills it with -1, which already means "slot not printed", and
+    dispatching that against an ams_mapping that *does* name a tray for the slot
+    is a contradiction the firmware rejects outright with HMS 0500-4047, "the
+    available hotend quantity or model does not match the sliced file". Giving
+    no mapping at all costs only the firmware's own nozzle pick.
+
     Args:
         zf: An open ZipFile of the 3MF archive
+        plate_id: 1-based plate to read, or None for every plate in the file
 
     Returns:
         Dictionary mapping {slot_id: extruder_id} for dual-nozzle files,
-        or None if single-nozzle, missing data, or parse error.
+        or None if single-nozzle, missing data, unplaceable, or parse error.
     """
     try:
         if "Metadata/project_settings.config" not in zf.namelist():
@@ -417,12 +492,17 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
 
         # Parse slice_info once: needed by both the single-active shortcut
         # (to verify the slice is actually single-group, #1825) and Priority 1.
-        si_root: ET.Element | None = None
+        si_root: XmlElement | None = None
+        filament_elems: list[XmlElement] = []
+        group_extruders: dict[int, int] | None = None
         distinct_group_ids: set[int] = set()
         if "Metadata/slice_info.config" in zf.namelist():
             si_content = zf.read("Metadata/slice_info.config").decode()
             si_root = ET.fromstring(si_content)
-            for filament_elem in si_root.findall(".//filament"):
+            plates = _plates_in_scope(si_root, plate_id)
+            group_extruders = _group_extruder_indices(plates)
+            filament_elems = [elem for plate in plates for elem in plate.findall(".//filament")]
+            for filament_elem in filament_elems:
                 gid = filament_elem.get("group_id")
                 if gid is not None:
                     try:
@@ -440,29 +520,61 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
             nozzle_mapping: dict[int, int] = {}
             active_idx = active_extruders.index(1)
             target_extruder = int(physical_extruder_map[active_idx])
-            if si_root is not None:
-                for filament_elem in si_root.findall(".//filament"):
-                    try:
-                        nozzle_mapping[int(filament_elem.get("id"))] = target_extruder
-                    except (ValueError, TypeError):
-                        pass
+            for filament_elem in filament_elems:
+                try:
+                    nozzle_mapping[int(filament_elem.get("id"))] = target_extruder
+                except (ValueError, TypeError):
+                    pass
             return nozzle_mapping or None
 
         # Priority 1: Use group_id from slice_info filament elements.
         # This reflects the actual slicer assignment (respects "Auto For Flush").
         nozzle_mapping: dict[int, int] = {}
-        if si_root is not None:
-            for filament_elem in si_root.findall(".//filament"):
-                group_id_str = filament_elem.get("group_id")
-                filament_id_str = filament_elem.get("id")
-                if group_id_str is not None and filament_id_str:
-                    try:
-                        group_id = int(group_id_str)
-                        slot_id = int(filament_id_str)
-                        if group_id < len(physical_extruder_map):
-                            nozzle_mapping[slot_id] = int(physical_extruder_map[group_id])
-                    except (ValueError, TypeError, IndexError):
-                        pass
+        ungrouped = 0
+        for filament_elem in filament_elems:
+            group_id_str = filament_elem.get("group_id")
+            filament_id_str = filament_elem.get("id")
+            if not filament_id_str:
+                continue
+            if group_id_str is None:
+                # Counted rather than returned on: a file where *no* filament
+                # carries a group falls through to Priority 2 as it always has.
+                # Only a file that groups some and not others is unplaceable.
+                ungrouped += 1
+                continue
+            try:
+                group_id = int(group_id_str)
+                slot_id = int(filament_id_str)
+            except (ValueError, TypeError):
+                logger.warning(
+                    "Ignoring nozzle mapping: unreadable filament id=%r group_id=%r",
+                    filament_id_str,
+                    group_id_str,
+                )
+                return None
+            # The group id is an extruder index only where the file states no
+            # table of its own — true of every H2D slice, not of an H2C one.
+            extruder_index = group_id if group_extruders is None else group_extruders.get(group_id)
+            if extruder_index is None or not 0 <= extruder_index < len(physical_extruder_map):
+                logger.warning(
+                    "Ignoring nozzle mapping: filament slot %s is in group %s, which "
+                    "resolves to extruder %r outside physical_extruder_map %r",
+                    slot_id,
+                    group_id,
+                    extruder_index,
+                    physical_extruder_map,
+                )
+                return None
+            nozzle_mapping[slot_id] = int(physical_extruder_map[extruder_index])
+
+        if nozzle_mapping and ungrouped:
+            logger.warning(
+                "Ignoring nozzle mapping: %d filament(s) carry no group_id while %d do, "
+                "so the ungrouped slots would dispatch as unprinted",
+                ungrouped,
+                len(nozzle_mapping),
+            )
+            return None
 
         if nozzle_mapping:
             return nozzle_mapping

+ 155 - 6
backend/tests/unit/test_nozzle_rack_mapping_2800.py

@@ -38,10 +38,18 @@ class TestIsNozzleRackModel:
 class TestResolveRackNozzleMapping:
     def test_rack_slot_takes_the_live_rack_position(self):
         mapping = resolve_rack_nozzle_mapping([1], rack_nozzle_id=17)
-        assert mapping is not None
-        assert len(mapping) == _RACK_WIRE_SLOTS
-        assert mapping[0] == 17
-        assert set(mapping[1:]) == {-1}
+        assert mapping == [17]
+
+    def test_the_wire_is_as_long_as_the_plate_has_slots(self):
+        """One entry per filament slot, not a fixed-length padded array.
+
+        BambuStudio's own dispatch of a three-filament H2C plate is
+        [1, 16, 16] -- three entries, captured from the maintainer's machine.
+        The earlier fixed 32-length padding was a generalisation from nothing.
+        """
+        assert resolve_rack_nozzle_mapping([1], rack_nozzle_id=17) == [17]
+        assert resolve_rack_nozzle_mapping([0, 1, 0], rack_nozzle_id=16) == [1, 16, 1]
+        assert len(resolve_rack_nozzle_mapping([1, 0, 0, -1], rack_nozzle_id=16)) == 4
 
     def test_the_fixed_hotend_takes_its_own_physical_id(self):
         """Both carriages are translated; neither extruder index reaches the wire.
@@ -118,8 +126,7 @@ class TestResolveRackNozzleMapping:
         [1, 17, ...] and [17, 1, ...] depending on filament slot order.
         """
         wire = resolve_rack_nozzle_mapping([0, -1, -1, 1], rack_nozzle_id=17)
-        assert wire[:4] == [1, -1, -1, 17]
-        assert set(wire[4:]) == {-1}
+        assert wire == [1, -1, -1, 17]
 
         swapped = resolve_rack_nozzle_mapping([1, 0], rack_nozzle_id=17)
         assert swapped[:2] == [17, 1]
@@ -299,3 +306,145 @@ class TestSlotExtrudersFromFile:
         """
         source = _write_dual_nozzle_3mf(tmp_path / f"s{abs(slot_id)}.3mf", {slot_id: 1})
         assert extract_slot_extruders_from_3mf(source) is None
+
+
+def _write_h2c_3mf(path, plates):
+    """3MF in the shape BambuStudio writes for a nozzle-rack machine.
+
+    ``plates`` is a list of ``(index, {slot: group}, {group: 1-based extruder})``.
+    The per-plate ``<nozzle>`` elements are the group-to-extruder table; the
+    fixture above deliberately omits them, because H2D files carry group ids
+    that already are extruder indices and both shapes have to keep working.
+    """
+    body = ""
+    for index, filaments, nozzles in plates:
+        elems = "".join(f'<filament id="{slot}" group_id="{group}"/>' for slot, group in filaments.items())
+        elems += "".join(f'<nozzle id="{group}" extruder_id="{ext}"/>' for group, ext in nozzles.items())
+        body += f'<plate><metadata key="index" value="{index}"/>{elems}</plate>'
+    with zipfile.ZipFile(path, "w") as zf:
+        zf.writestr(
+            "Metadata/project_settings.config",
+            json.dumps(
+                {
+                    "physical_extruder_map": [1, 0],
+                    # One nozzle on the fixed carriage, four racked, as the
+                    # reporter's H2C reports itself.
+                    "extruder_nozzle_stats": ["High Flow#1", "High Flow#4"],
+                }
+            ),
+        )
+        zf.writestr("Metadata/slice_info.config", f"<config>{body}</config>")
+    return path
+
+
+class TestGroupsBeyondTheExtruderCount:
+    """A rack carriage hosts six hotends, so groups outnumber extruders.
+
+    The H2C's `extruder_max_nozzle_count` is ['1', '6'], and the slicer emits
+    one filament group per nozzle it wants rather than one per carriage. Group
+    ids therefore run past the end of `physical_extruder_map`, which has an
+    entry per *extruder*. Treating the group id as an index into it silently
+    dropped those filaments, and a dropped filament densifies to -1 -- the same
+    value that means "this plate does not print the slot".
+    """
+
+    def test_the_dropped_filament_from_the_hms_0500_4047_report(self, tmp_path):
+        """The maintainer's own plate, first print on a new H2C.
+
+        Three filaments in groups 2, 0 and 1 against a two-entry
+        physical_extruder_map. Slot 1 fell out of the mapping and dispatched as
+        [-1, 16, 1, ...] while ams_mapping named tray 6 for that same slot; the
+        printer stopped with "the available hotend quantity or model does not
+        match the sliced file". The file's own nozzle table says group 2 prints
+        on extruder 2, the rack side, same as group 1.
+        """
+        source = _write_h2c_3mf(
+            tmp_path / "benchy.3mf",
+            [(1, {1: 2, 2: 0, 3: 1}, {0: 1, 1: 2, 2: 2})],
+        )
+        assert extract_slot_extruders_from_3mf(source, plate_id=1) == [0, 1, 0]
+        wire = resolve_rack_nozzle_mapping([0, 1, 0], rack_nozzle_id=16)
+        assert wire == [1, 16, 1]
+
+    def test_a_group_the_file_never_places_omits_the_whole_mapping(self, tmp_path):
+        """Refusing beats answering for the slots that did resolve.
+
+        A partial mapping is not a smaller answer, it is a wrong one: the gap
+        reaches the printer as -1, contradicting the ams_mapping entry for the
+        same slot. Omitting costs only the firmware's own nozzle pick.
+        """
+        source = _write_h2c_3mf(
+            tmp_path / "orphan.3mf",
+            [(1, {1: 5, 2: 0}, {0: 1, 1: 2})],
+        )
+        assert extract_slot_extruders_from_3mf(source, plate_id=1) is None
+
+    def test_a_slot_the_plate_genuinely_skips_still_reads_minus_one(self, tmp_path):
+        """-1 keeps its meaning where it is earned rather than inferred."""
+        source = _write_h2c_3mf(
+            tmp_path / "gap.3mf",
+            [(1, {1: 0, 3: 1}, {0: 1, 1: 2})],
+        )
+        assert extract_slot_extruders_from_3mf(source, plate_id=1) == [1, -1, 0]
+
+    def test_filaments_grouped_and_ungrouped_in_one_plate_omit_the_mapping(self, tmp_path):
+        """Half an answer has the same failure mode as a dropped group."""
+        path = tmp_path / "mixed.3mf"
+        with zipfile.ZipFile(path, "w") as zf:
+            zf.writestr(
+                "Metadata/project_settings.config",
+                json.dumps(
+                    {
+                        "physical_extruder_map": [1, 0],
+                        "extruder_nozzle_stats": ["High Flow#1", "High Flow#4"],
+                    }
+                ),
+            )
+            zf.writestr(
+                "Metadata/slice_info.config",
+                '<config><plate><metadata key="index" value="1"/>'
+                '<filament id="1" group_id="0"/><filament id="2"/>'
+                '<nozzle id="0" extruder_id="1"/></plate></config>',
+            )
+        assert extract_slot_extruders_from_3mf(path, plate_id=1) is None
+
+    def test_files_without_a_nozzle_table_keep_the_group_as_the_index(self, tmp_path):
+        """Every H2D slice in the wild carries groups 0 and 1 and no table."""
+        source = _write_dual_nozzle_3mf(tmp_path / "h2d.3mf", {1: 0, 2: 1})
+        assert extract_slot_extruders_from_3mf(source, plate_id=1) == [1, 0]
+
+
+class TestPlateScoping:
+    """A 3MF holds every plate in the project, not just the one being printed."""
+
+    def test_each_plate_answers_for_itself(self, tmp_path):
+        source = _write_h2c_3mf(
+            tmp_path / "two.3mf",
+            [
+                (1, {1: 0, 2: 1}, {0: 1, 1: 2}),
+                (2, {1: 1, 2: 0}, {0: 1, 1: 2}),
+            ],
+        )
+        assert extract_slot_extruders_from_3mf(source, plate_id=1) == [1, 0]
+        assert extract_slot_extruders_from_3mf(source, plate_id=2) == [0, 1]
+
+    def test_an_unknown_plate_falls_back_to_the_whole_file(self, tmp_path):
+        """Not every file indexes its plates; this must not become a hard stop."""
+        source = _write_h2c_3mf(tmp_path / "one.3mf", [(1, {1: 0, 2: 1}, {0: 1, 1: 2})])
+        assert extract_slot_extruders_from_3mf(source, plate_id=7) == [1, 0]
+
+    def test_plates_that_disagree_about_a_group_fall_back_to_the_index(self, tmp_path):
+        """Reading every plate at once can only be done on the old terms.
+
+        With no plate asked for, two plates naming different extruders for one
+        group leave no table worth trusting, so the group id is read as the
+        extruder index exactly as it was before the table existed.
+        """
+        source = _write_h2c_3mf(
+            tmp_path / "conflict.3mf",
+            [
+                (1, {1: 0}, {0: 1}),
+                (2, {2: 0}, {0: 2}),
+            ],
+        )
+        assert extract_slot_extruders_from_3mf(source, plate_id=None) == [1, 1]

Некоторые файлы не были показаны из-за большого количества измененных файлов