mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 09:03:43 +02:00
Let a filled or foamed filament keep its own name (issue #2902)
The reduction that gave an AMS slot a material type read PLA-AERO,
PLA-GF, ASA-GF and PPS-GF as their base material, so a slot loaded with
foaming or glass-filled filament went out saying plain PLA or ASA. That
is worse than the bug it replaced. "PLA-AERO" matched nothing before,
which was useless but honest; "PLA" matches every PLA plate in the
queue, so the dispatcher would have sent one to filament that will not
print it -- and the contract the first fix claimed, that it could only
ever repair a slot, no longer held. @doncaruana caught PLA Aero on the
issue.
All four are values Bambuddy itself offers: filament_fields.json is the
material list the Profiles editor puts in a dropdown, and the reduction
table was assembled from the cloud filament names and the frontend
preset parser without ever being checked against it. It is checked now,
so the next type added to one and not the other fails a test rather than
a print. ASA-AERO joins them from the cloud catalogue (GFB02).
The table hyphenates because the slicers do, while a spool says "PLA
Aero" and every Bambu preset name says "Bambu PLA Aero". Adjacent words
are joined and taken when the join is a type exactly -- exactly, because
letting the prefix and suffix rules reach across a space would make
"Support for PLA" a type by its tail.
Also from @doncaruana, and the better half of his point: a preset is
chosen from a list the slicer defines, so it already knows its own type
and nothing has to be read out of a product name. The resolver now hands
that answer back and both assign routes prefer it. It cannot be the only
source -- material is required on a spool and slicer_filament is not, and
the spool this issue was reported for had no preset at all -- so the
reduction stays as the fallback for spools without one.
Two things had to move with it. The auto-unlink guard compared the
slot's reported type against the reduced material, so a spool whose
preset outranked its material column would have been unlinked from the
slot it had just been assigned to; it now accepts any type the assign
path could have written. And two lookups keyed by material took the
catch-all for a type they had no row for, which sent an ASA-GF spool out
at 200/240 -- too cold to extrude -- and preheated its chamber to
nothing. Both fall back to the base material last, so PLA-CF, PETG-CF
and PA-CF keep the rows they are listed with, and ASA-CF and ABS-GF pick
up ranges they had been missing all along.
What counts as a material name is decided by the base for the same
reason: saying yes throws the value away and rescues the slot from the
generic-material fallback, so the answer has to be no when that fallback
has nothing to offer. ABS-GF reduces to a generic ABS the printer can
resolve; PPS-CF reduces to nothing and is left as it stands. Adding a
type to the table therefore cannot quietly change that answer, which is
how these five slipped through in the first place.
------
Hand the bundled chamber-preheat table back the way it is read
Every lookup of the per-filament chamber map happens after the keys are
upper-cased, and the parser documents exactly that: keys uppercased,
DEFAULT always present so the resolution loop can index it
unconditionally. The three fallback paths returned the bundled constant
as declared, with the lowercase "default" row the Settings editor writes
and displays, so an install that had never opened the setting got a dict
the loop could not read its fallback out of and used a hardcoded 0 for
any filament without a row of its own.
It reported the right number only because that bundled default is 0.
Raising it would have changed nothing for everyone who had not
customised the map, with the map in Settings still showing the value
that was not being used.
The test that should have caught this asserted the fallback under either
spelling, and its docstring contradicted itself between title and
comment. It pins the contract now, over all four ways the parser can
fall back.
This commit is contained in:
@@ -63,11 +63,10 @@ from backend.app.services.spool_csv import (
|
||||
from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
|
||||
from backend.app.utils.filament_ids import (
|
||||
GENERIC_FILAMENT_IDS,
|
||||
MATERIAL_TEMPS,
|
||||
filament_id_to_setting_id,
|
||||
normalize_slicer_filament,
|
||||
)
|
||||
from backend.app.utils.filament_types import is_material_name, printer_filament_type
|
||||
from backend.app.utils.filament_types import is_material_name, nozzle_temp_range, printer_filament_type
|
||||
from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -136,7 +135,7 @@ async def apply_spool_to_slot_via_mqtt(
|
||||
# the builtin-name realignment, AND the defensive PFUS/PFCN/material-name
|
||||
# sanitization. When it returns an empty tray_info_idx the local
|
||||
# current-tray-state + generic-material fallback below rescues the slot.
|
||||
tray_info_idx, setting_id, sub_brand_override = await resolve_slicer_filament(
|
||||
tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=current_user,
|
||||
slicer_filament=spool.slicer_filament,
|
||||
@@ -145,6 +144,11 @@ async def apply_spool_to_slot_via_mqtt(
|
||||
)
|
||||
if sub_brand_override:
|
||||
tray_sub_brands = sub_brand_override
|
||||
# A preset says what its material is; the reduction above only infers it
|
||||
# from whatever wording the spool's material column happens to carry. When
|
||||
# the spool has a preset, its answer wins (issue #2902, @doncaruana).
|
||||
if type_override:
|
||||
tray_type = printer_filament_type(type_override)
|
||||
|
||||
if not tray_info_idx:
|
||||
if (
|
||||
@@ -187,9 +191,7 @@ async def apply_spool_to_slot_via_mqtt(
|
||||
# Same order as the generic-id lookup above: the spool's own wording wins,
|
||||
# the reduced type rescues what it does not cover. Without the second
|
||||
# lookup a PLA+ spool took the 200/240 catch-all instead of PLA's 190/230.
|
||||
temp_min, temp_max = (
|
||||
MATERIAL_TEMPS.get((spool.material or "").upper()) or MATERIAL_TEMPS.get(tray_type.upper()) or (200, 240)
|
||||
)
|
||||
temp_min, temp_max = nozzle_temp_range(spool.material, tray_type)
|
||||
if spool.nozzle_temp_min is not None:
|
||||
temp_min = spool.nozzle_temp_min
|
||||
if spool.nozzle_temp_max is not None:
|
||||
|
||||
@@ -32,10 +32,9 @@ from backend.app.services.spoolman import (
|
||||
)
|
||||
from backend.app.utils.filament_ids import (
|
||||
GENERIC_FILAMENT_IDS,
|
||||
MATERIAL_TEMPS,
|
||||
normalize_slicer_filament,
|
||||
)
|
||||
from backend.app.utils.filament_types import printer_filament_type
|
||||
from backend.app.utils.filament_types import nozzle_temp_range, printer_filament_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -970,9 +969,7 @@ async def link_spool(
|
||||
or ""
|
||||
)
|
||||
setting_id = ""
|
||||
temp_defaults = (
|
||||
MATERIAL_TEMPS.get(material_upper) or MATERIAL_TEMPS.get(tray_type.upper()) or (200, 240)
|
||||
)
|
||||
temp_defaults = nozzle_temp_range(material, tray_type)
|
||||
temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
|
||||
temp_max = temp_defaults[1]
|
||||
|
||||
|
||||
@@ -61,11 +61,10 @@ from backend.app.services.spoolman import (
|
||||
from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot
|
||||
from backend.app.utils.filament_ids import (
|
||||
GENERIC_FILAMENT_IDS,
|
||||
MATERIAL_TEMPS,
|
||||
filament_id_to_setting_id,
|
||||
normalize_slicer_filament,
|
||||
)
|
||||
from backend.app.utils.filament_types import printer_filament_type
|
||||
from backend.app.utils.filament_types import nozzle_temp_range, printer_filament_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1505,7 +1504,7 @@ async def assign_spoolman_slot(
|
||||
# configured profile never reached the printer. Shared with the
|
||||
# internal-mode route via the same helper so the two flows can't
|
||||
# drift again.
|
||||
tray_info_idx, setting_id, sub_brand_override = await resolve_slicer_filament(
|
||||
tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=current_user,
|
||||
slicer_filament=mapped.get("slicer_filament"),
|
||||
@@ -1514,6 +1513,11 @@ async def assign_spoolman_slot(
|
||||
)
|
||||
if sub_brand_override:
|
||||
tray_sub_brands = sub_brand_override
|
||||
# A preset carries its own type; the reduction above only infers
|
||||
# one from Spoolman's free-text material. The preset wins when the
|
||||
# spool has one (issue #2902, @doncaruana).
|
||||
if type_override:
|
||||
tray_type = printer_filament_type(type_override)
|
||||
|
||||
material_upper = material.upper().strip()
|
||||
# Fall back to generic-material id when slicer_filament is empty
|
||||
@@ -1539,7 +1543,7 @@ async def assign_spoolman_slot(
|
||||
if tray_info_idx and not setting_id:
|
||||
setting_id = filament_id_to_setting_id(tray_info_idx)
|
||||
|
||||
temp_defaults = MATERIAL_TEMPS.get(material_upper) or MATERIAL_TEMPS.get(tray_type.upper()) or (200, 240)
|
||||
temp_defaults = nozzle_temp_range(material, tray_type)
|
||||
temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
|
||||
temp_max = temp_defaults[1]
|
||||
|
||||
|
||||
+34
-2
@@ -2114,10 +2114,42 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
||||
spool = assignment.spool
|
||||
if spool:
|
||||
spool_color = (spool.rgba or "FFFFFFFF").upper()
|
||||
spool_type = printer_filament_type(spool.material).upper()
|
||||
# Two ways the assign path can have arrived at the
|
||||
# slot's type, so both count as "we wrote this".
|
||||
# The material column is one; the spool's preset is
|
||||
# the other, and it outranks the material when the
|
||||
# spool has one -- a spool whose material says PLA
|
||||
# and whose preset is "Bambu PLA Aero" puts
|
||||
# PLA-AERO in the slot (#2902). Read from the stored
|
||||
# preset name rather than resolving the preset,
|
||||
# because this runs on every AMS push and a cloud
|
||||
# lookup here would be both slow and unavailable on
|
||||
# the unauthenticated replay path.
|
||||
spool_types = {printer_filament_type(spool.material).upper()}
|
||||
if spool.slicer_filament_name:
|
||||
spool_types.add(printer_filament_type(spool.slicer_filament_name).upper())
|
||||
# An imported local preset stores its type outright,
|
||||
# which is what the assign path used -- and the name
|
||||
# above may be unset. One keyed read, and only on a
|
||||
# mismatch, which is rare.
|
||||
#
|
||||
# slicer_filament is free text up to fifty characters,
|
||||
# so the digits have to be checked against the range
|
||||
# of the integer primary key they are about to be
|
||||
# compared with. Postgres raises on an out-of-range
|
||||
# integer rather than simply not matching, and that
|
||||
# would poison this session and abandon the rest of
|
||||
# the cleanup pass.
|
||||
lp_ref = (spool.slicer_filament or "").strip()
|
||||
if lp_ref.isdigit() and int(lp_ref) <= 2147483647:
|
||||
from backend.app.models.local_preset import LocalPreset as _LP
|
||||
|
||||
lp_type = await db.scalar(select(_LP.filament_type).where(_LP.id == int(lp_ref)))
|
||||
if lp_type:
|
||||
spool_types.add(printer_filament_type(lp_type).upper())
|
||||
if (
|
||||
_colors_similar(cur_color, spool_color)
|
||||
and printer_filament_type(cur_type).upper() == spool_type
|
||||
and printer_filament_type(cur_type).upper() in spool_types
|
||||
):
|
||||
logger.info(
|
||||
"Auto-unlink: spool %d AMS%d-T%d — fingerprint mismatch but tray matches spool, updating fp",
|
||||
|
||||
@@ -4476,6 +4476,22 @@ class PrintScheduler:
|
||||
"default": 0,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _bundled_preheat_targets(cls) -> dict[str, int]:
|
||||
"""The bundled map under the same key casing a parsed one gets.
|
||||
|
||||
The constant is declared with a lowercase ``default`` because that is
|
||||
the key the Settings editor writes and displays. Every read of the map
|
||||
happens after ``str(key).upper()``, so handing the constant back as
|
||||
declared broke the contract the parser documents: an install that had
|
||||
never touched the setting returned a dict with no ``DEFAULT`` in it,
|
||||
and the resolution loop's fallback silently found nothing. It read the
|
||||
right number only because the bundled default happens to be 0 -- change
|
||||
that constant and every unconfigured install would keep preheating to
|
||||
zero with no way to tell why.
|
||||
"""
|
||||
return {key.upper(): value for key, value in cls.DEFAULT_PREHEAT_FILAMENT_TARGETS.items()}
|
||||
|
||||
async def _get_preheat_filament_targets(self, db: AsyncSession) -> dict[str, int]:
|
||||
"""Parse the user-configured filament→chamber-target map, falling back
|
||||
to DEFAULT_PREHEAT_FILAMENT_TARGETS on missing / malformed JSON. Keys
|
||||
@@ -4483,14 +4499,14 @@ class PrintScheduler:
|
||||
returned dict so the resolution loop can index it unconditionally."""
|
||||
raw = await self._get_setting(db, "preheat_filament_targets")
|
||||
if not raw:
|
||||
return dict(self.DEFAULT_PREHEAT_FILAMENT_TARGETS)
|
||||
return self._bundled_preheat_targets()
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("not an object")
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
logger.warning("preheat_filament_targets unparseable, using defaults: %s", exc)
|
||||
return dict(self.DEFAULT_PREHEAT_FILAMENT_TARGETS)
|
||||
return self._bundled_preheat_targets()
|
||||
# Coerce values to int; drop unparseable rows so a stray string
|
||||
# doesn't crash the loop.
|
||||
out: dict[str, int] = {}
|
||||
@@ -4539,7 +4555,14 @@ class PrintScheduler:
|
||||
normalised = self._normalize_filament_type(tray.get("tray_type") or "")
|
||||
if not normalised:
|
||||
continue
|
||||
target = targets.get(normalised, targets.get("DEFAULT", 0))
|
||||
# A filled or foamed variant wants its base material's chamber
|
||||
# when the map has no row of its own: ASA-GF is ASA and needs
|
||||
# ASA's 45 degrees, not the 0 an unknown type falls to. The
|
||||
# specific type is still tried first, so PETG-CF and PA-CF keep
|
||||
# the hotter rows they are listed with (#2902).
|
||||
target = targets.get(normalised)
|
||||
if target is None:
|
||||
target = targets.get(normalised.split("-")[0], targets.get("DEFAULT", 0))
|
||||
if target > best:
|
||||
best = target
|
||||
return best
|
||||
|
||||
@@ -50,6 +50,28 @@ from backend.app.utils.filament_types import is_material_name
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _preset_filament_type(raw: object) -> str | None:
|
||||
"""Read a slicer preset's ``filament_type`` field.
|
||||
|
||||
Bambu Studio and OrcaSlicer both store it as a one-element array
|
||||
(``["PLA"]``); some hand-written and older profiles store a bare string.
|
||||
``orca_profiles._extract_filament_fields`` accepts both and this has to
|
||||
agree with it, since that is what fills ``LocalPreset.filament_type``.
|
||||
|
||||
The value is still run through ``printer_filament_type`` by the caller.
|
||||
That is a no-op for every type the app knows -- ``TestTheMaterialsBambuddyOffers``
|
||||
pins exactly that -- and a pass-through for a type it does not, so nothing
|
||||
the slicer says is discarded. It only bites on a hand-edited profile whose
|
||||
``filament_type`` is a product line, which is the case this whole module
|
||||
exists to keep out of an AMS slot.
|
||||
"""
|
||||
if isinstance(raw, list):
|
||||
raw = raw[0] if raw else None
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return raw.strip()
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_slicer_filament(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
@@ -57,7 +79,7 @@ async def resolve_slicer_filament(
|
||||
slicer_filament: str | None,
|
||||
slicer_filament_name: str | None,
|
||||
material: str | None,
|
||||
) -> tuple[str, str, str | None]:
|
||||
) -> tuple[str, str, str | None, str | None]:
|
||||
"""Resolve a spool's slicer-preset reference to printer-side ids.
|
||||
|
||||
``slicer_filament``: the spool's stored reference (e.g. ``"GFA01"``,
|
||||
@@ -72,18 +94,30 @@ async def resolve_slicer_filament(
|
||||
``material``: spool material string for the local-preset fallback
|
||||
branch when the LocalPreset's setting JSON doesn't carry a filament_id.
|
||||
|
||||
Returns ``(tray_info_idx, setting_id, sub_brand_override)`` — all empty
|
||||
when nothing resolved. ``sub_brand_override`` is non-None when a more
|
||||
specific brand label is available (cloud detail name or local preset
|
||||
Returns ``(tray_info_idx, setting_id, sub_brand_override, type_override)``
|
||||
— all empty when nothing resolved. ``sub_brand_override`` is non-None when
|
||||
a more specific brand label is available (cloud detail name or local preset
|
||||
name); ``None`` means the caller should use its own default.
|
||||
|
||||
``type_override`` is the preset's own ``filament_type`` when the preset
|
||||
carries one — the slicer's answer to what the material is, rather than one
|
||||
parsed out of the spool's material column. It is what the caller should
|
||||
write into ``tray_type``. ``None`` means no preset said, and the caller
|
||||
falls back to reducing the spool's material (``printer_filament_type``).
|
||||
Raised in the #2902 thread by @doncaruana: a preset has to be chosen from
|
||||
a list the slicer defines, so its type needs no interpreting. It cannot be
|
||||
the only source, though — ``slicer_filament`` is nullable on a spool while
|
||||
``material`` is required, and the spool this issue was reported for had no
|
||||
preset at all.
|
||||
"""
|
||||
sf = (slicer_filament or "").strip()
|
||||
if not sf:
|
||||
return ("", "", None)
|
||||
return ("", "", None, None)
|
||||
|
||||
tray_info_idx = ""
|
||||
setting_id = ""
|
||||
sub_brand_override: str | None = None
|
||||
type_override: str | None = None
|
||||
|
||||
base_sf = sf.split("_")[0] if "_" in sf else sf
|
||||
|
||||
@@ -104,6 +138,15 @@ async def resolve_slicer_filament(
|
||||
if cloud is not None and cloud.is_authenticated:
|
||||
try:
|
||||
detail = await cloud.get_setting_detail(base_sf)
|
||||
# The preset's own type, straight from the slicer's own
|
||||
# profile -- no parsing of a product name (#2902). The
|
||||
# preset JSON is nested under ``setting``; some responses
|
||||
# carry it at the top level instead, the same shape spread
|
||||
# ``preset_resolver`` documents.
|
||||
cloud_setting = detail.get("setting")
|
||||
type_override = _preset_filament_type(
|
||||
(cloud_setting if isinstance(cloud_setting, dict) else detail).get("filament_type")
|
||||
)
|
||||
if detail.get("filament_id"):
|
||||
tray_info_idx = detail["filament_id"]
|
||||
cloud_name = detail.get("name", "")
|
||||
@@ -134,6 +177,10 @@ async def resolve_slicer_filament(
|
||||
lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
|
||||
lp = lp_result.scalar_one_or_none()
|
||||
if lp:
|
||||
# The slicer's own answer, extracted from the profile at import
|
||||
# time by ``orca_profiles``. Preferred over anything parsed out
|
||||
# of the spool's material column (#2902).
|
||||
type_override = _preset_filament_type(lp.filament_type)
|
||||
# Local preset's setting JSON carries the printer-recognized
|
||||
# filament_id (e.g. "P4d64437") — use that directly so the
|
||||
# slicer can resolve the specific preset. Falls through to
|
||||
@@ -219,4 +266,4 @@ async def resolve_slicer_filament(
|
||||
):
|
||||
setting_id = ""
|
||||
|
||||
return (tray_info_idx, setting_id, sub_brand_override)
|
||||
return (tray_info_idx, setting_id, sub_brand_override, type_override)
|
||||
|
||||
@@ -77,27 +77,43 @@ def filament_types_compatible(a: str | None, b: str | None) -> bool:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# What a Bambu printer and the slicers accept in an AMS slot's ``tray_type``.
|
||||
# Grounded in the catalogue this repo already carries: every "Generic X" entry
|
||||
# in ``cloud._BUILTIN_FILAMENT_NAMES`` names a real type, the "Bambu X" entries
|
||||
# add the composites, and the frontend's ``parsePresetName`` list contributes
|
||||
# Grounded in the catalogues this repo already carries: ``filament_fields.json``
|
||||
# is the list Bambuddy itself offers when a preset is created, so every value in
|
||||
# it has to appear here -- ``TestTheMaterialsBambuddyOffers`` fails if one does
|
||||
# not. On top of that, every "Generic X" entry in
|
||||
# ``cloud._BUILTIN_FILAMENT_NAMES`` names a real type, the "Bambu X" entries add
|
||||
# the composites, and the frontend's ``parsePresetName`` list contributes
|
||||
# PEEK / PEI / PC-CF / PC-ABS.
|
||||
#
|
||||
# Product lines are deliberately absent. "PLA Matte", "PETG HF" and "eSUN PLA+"
|
||||
# are things you buy, not types the firmware knows -- they belong in
|
||||
# ``tray_sub_brands``, which is where Bambu itself puts them.
|
||||
# A filled or foamed variant is a type of its own, not a flavour of the base
|
||||
# material: PLA-AERO is a foaming PLA and PLA-GF a glass-filled one, and a slot
|
||||
# that reduces either to "PLA" invites a plain PLA plate onto filament that
|
||||
# will not print it. Four of the ones the dropdown offers were missing when
|
||||
# #2902 first landed and were being reduced exactly that way, ASA-AERO -- which
|
||||
# only the cloud catalogue names (GFB02) -- with them. See the issue thread,
|
||||
# where @doncaruana caught PLA Aero.
|
||||
#
|
||||
# Product lines, by contrast, are deliberately absent. "PLA Matte", "PETG HF"
|
||||
# and "eSUN PLA+" are things you buy, not types the firmware knows -- they
|
||||
# belong in ``tray_sub_brands``, which is where Bambu itself puts them.
|
||||
_PRINTER_TYPES: tuple[str, ...] = (
|
||||
# Order within a length matters for ties: "PLA/PHA" must read as PLA.
|
||||
"PLA-AERO",
|
||||
"ASA-AERO",
|
||||
"PAHT-CF",
|
||||
"PA12-CF",
|
||||
"PETG-CF",
|
||||
"PPS-CF",
|
||||
"PPS-GF",
|
||||
"PPA-CF",
|
||||
"PPA-GF",
|
||||
"PLA-CF",
|
||||
"PLA-GF",
|
||||
"PA6-CF",
|
||||
"PA6-GF",
|
||||
"ABS-GF",
|
||||
"ASA-CF",
|
||||
"ASA-GF",
|
||||
"PET-CF",
|
||||
"PC-ABS",
|
||||
"PA-CF",
|
||||
@@ -190,6 +206,23 @@ def printer_filament_type(material: str | None) -> str:
|
||||
return _TYPE_ALIASES[upper]
|
||||
|
||||
words = [w for w in _WORDS.split(upper) if w]
|
||||
|
||||
# A hyphenated type written with a space is still that type. The table
|
||||
# spells it "PLA-AERO" because that is how the preset dropdown and the
|
||||
# slicers spell it, while a spool says "PLA Aero" and a preset name says
|
||||
# "Bambu PLA Aero" -- and the word rules below would find only the "PLA"
|
||||
# in those and hand back a slot that lies about what is loaded.
|
||||
#
|
||||
# Adjacent words only, and only when the join is a type exactly. The prefix
|
||||
# and suffix rules are deliberately not applied across a space: "Support
|
||||
# for PLA" would otherwise start reading as a type by its tail.
|
||||
for first, second in zip(words, words[1:], strict=False):
|
||||
joined = f"{first}-{second}"
|
||||
if joined in _PRINTER_TYPE_SET:
|
||||
return joined
|
||||
if joined in _TYPE_ALIASES:
|
||||
return _TYPE_ALIASES[joined]
|
||||
|
||||
for candidate in _TYPES_LONGEST_FIRST:
|
||||
if any(_word_names_type(w, candidate) for w in words):
|
||||
return _TYPE_ALIASES.get(candidate, candidate)
|
||||
@@ -197,6 +230,26 @@ def printer_filament_type(material: str | None) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def nozzle_temp_range(material: str | None, tray_type: str | None) -> tuple[int, int]:
|
||||
"""The nozzle range to send with a slot, given a spool's material and the
|
||||
type the slot will carry.
|
||||
|
||||
The spool's own wording leads, as it does for the filament-id lookup, so a
|
||||
material that has its own entry keeps it. The reduced type answers for
|
||||
everything else -- and when the reduced type is a filled or foamed variant,
|
||||
the base material answers for that, because ``MATERIAL_TEMPS`` carries
|
||||
eleven entries and none of them is ASA-GF. Without that last step an
|
||||
ASA-GF spool took the 200/240 catch-all and would not have extruded;
|
||||
"ASA" gives it ASA's 240/270 (#2902).
|
||||
"""
|
||||
base = (tray_type or "").split("-")[0]
|
||||
for key in (material, tray_type, base):
|
||||
temps = MATERIAL_TEMPS.get((key or "").upper().strip())
|
||||
if temps:
|
||||
return temps
|
||||
return (200, 240)
|
||||
|
||||
|
||||
# Both tables are keyed by material, so their keys are exactly the set of names
|
||||
# that are a material rather than a preset the printer can resolve.
|
||||
_MATERIAL_NAMES = frozenset(MATERIAL_TEMPS) | frozenset(GENERIC_FILAMENT_IDS)
|
||||
@@ -229,4 +282,18 @@ def is_material_name(value: str | None) -> bool:
|
||||
return True
|
||||
if _PRESET_ID_SHAPE.match(text):
|
||||
return False
|
||||
return printer_filament_type(text).upper() in _MATERIAL_NAMES
|
||||
reduced = printer_filament_type(text).upper()
|
||||
if reduced in _MATERIAL_NAMES:
|
||||
return True
|
||||
# A filled or foamed variant is its base material by another name, and the
|
||||
# base is what decides. Saying yes means the caller throws this value away
|
||||
# and rescues the slot from its generic-material fallback -- so the answer
|
||||
# has to be no when that fallback has nothing to offer, or the slot goes out
|
||||
# with no filament id at all, which is worse than the junk it replaced.
|
||||
# ``ABS-GF`` reduces to a generic ABS the printer can resolve; ``PPS-CF``
|
||||
# reduces to nothing, so it is left for the caller to send as it stands.
|
||||
#
|
||||
# This is also what keeps a type added to the table above from silently
|
||||
# changing the answer: "PLA-AERO" read as a material name when the table
|
||||
# had no row for it, and it still does (#2902).
|
||||
return reduced.split("-")[0] in _MATERIAL_NAMES
|
||||
|
||||
Reference in New Issue
Block a user