mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-08-25 02:47:46 +00:00
filament_id: deterministic mint rule, sanctioned-state ledger, validator hardening
Phase 0 of the filament_id cleanup (filament_id_plan.md): - scripts/assign_filament_ids.py: mint OF-prefixed 8-char ids as uuid5(FILAMENT_ID_NAMESPACE, filament_family/<vendor>/<family>) in the base62 derivation of the setting_id precedent; loader-faithful effective-id resolver (vendor inherits chain + OrcaFilamentLibrary fallback); CLI: default assign run (idempotent no-op on a fully-idded tree), --mint Vendor/Family, --update-snapshot, --check. - scripts/filament_id_snapshot.json: the sanctioned-state ledger (1092 ids, 1965 family claims over the current tree). The tree must equal it exactly, both directions, so every id/claim change lands as a reviewable diff; a PR snapshot diff is the maintainer gate. scripts/retired_filament_ids.json is the append-only retirement ledger; --update-snapshot refuses to resurrect retired ids and to sanction new reserved-namespace claims (GF*/QD_*/P-hex/ null) for non-owner vendors without --allow-shared-catalog. - orca_extra_profile_check.py: runs check_filament_ids() tree-wide (format, snapshot equality, mint conformance, retired reuse, alias hygiene for tuned OFL generics, reserved namespaces, structure ratchet). The pre-existing check_filament_id() 8-char rule stays BBL/OFL-scoped: grandfathered longer ids exist elsewhere (e.g. Prusa's 36-char name ids) and are frozen via the snapshot instead. - PresetBundle::check_duplicate_filament_subtypes now includes OrcaFilamentLibrary presets in every vendor's per-printer duplicate check; alias-shadowed library presets are excluded via the existing m_excluded_from population (verified live in the validator load path), so only genuinely visible duplicates are flagged. AMS tray-id resolution logs a warning when a filament_id matches 2+ compatible presets (pick unchanged). - doc/developer-reference/filament_id.md: the authoring rule; PR template gains a no-hand-written-ids checkbox. - scripts/tests/test_filament_id.py: 46 stdlib-unittest tests (mint vectors, resolver semantics, every check firing/silent, ledger round-trips, byte preservation, real-tree smoke). Verified: python -m unittest discover -s scripts/tests (46 OK); python scripts/orca_extra_profile_check.py exit 0 on the unmigrated tree; assign run is a no-op; rebuilt OrcaSlicer_profile_validator -l 2 exit 0; extended -f is strictly additive vs baseline (every baseline group preserved, all new groups involve library presets, alias exclusion proven by BBL-mirror absence on BBL printers).
This commit is contained in:
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -20,6 +20,8 @@
|
||||
> Please describe the tests that you have conducted to verify the changes made in this PR.
|
||||
-->
|
||||
|
||||
- [ ] New filament/material profiles: I wrote **no** `filament_id` key by hand — I ran `python scripts/assign_filament_ids.py` and `--update-snapshot` and committed both diffs (see `doc/developer-reference/filament_id.md`)
|
||||
|
||||
<!--
|
||||
> A guide for users on how to download the artifacts from this PR.
|
||||
-->
|
||||
|
||||
169
doc/developer-reference/filament_id.md
Normal file
169
doc/developer-reference/filament_id.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# Filament IDs (`filament_id`)
|
||||
|
||||
`filament_id` identifies a **material family**: one commercial product line = one id, shared by
|
||||
all of that material's per-printer / per-nozzle variants. Devices use it to match a physical
|
||||
spool or tray to a filament preset. It is never per-color, per-printer, per-nozzle, or
|
||||
per-preset (per-preset identity is `setting_id`).
|
||||
|
||||
This page is the rule for authoring `filament_id` in system profiles
|
||||
(`resources/profiles/**`). CI enforces everything below; the short version is:
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Never write a `filament_id` value by hand.** New families get their id from
|
||||
> `python scripts/assign_filament_ids.py`; existing families already have one — inherit it.
|
||||
|
||||
## Who consumes the id
|
||||
|
||||
Every device integration funnels a tray material id (`tray_info_idx`) through the same
|
||||
matching pipeline (`PresetBundle::sync_ams_list` and friends):
|
||||
|
||||
| Ecosystem | Where the id comes from |
|
||||
| --- | --- |
|
||||
| Bambu AMS | device side (RFID / user tray setting) — the `GF*` catalog |
|
||||
| Qidi box | built from device enums (`QD_*`); needs an exactly matching visible preset |
|
||||
| Creality CFS | runtime brand/type scoring returns the current preset's id |
|
||||
| Klipper (AFC / Happy Hare) | runtime lookup by filament type |
|
||||
| Snapmaker | runtime color/vendor/type match |
|
||||
|
||||
Tray-to-preset matching is printer-scoped, but **several consumers match globally by id alone,
|
||||
first hit wins**: tray display names, `filament_is_support`, vitrification warnings, and
|
||||
multi-nozzle filament grouping in the slicing pipeline. Two *different* materials sharing one
|
||||
id feed wrong data to those consumers even when the presets live in different vendors — so
|
||||
cross-material id sharing is never safe. Within one printer, duplicate ids silently break AMS
|
||||
matching (first match wins, the tray-edit dialog hides the second preset); the profile
|
||||
validator's `-f` check rejects this.
|
||||
|
||||
## Do I need a new id? The one-question test
|
||||
|
||||
> **Would a user consider this a different spool product than anything already in the tree?**
|
||||
|
||||
Different polymer, different sub-brand (Basic / Matte / Silk / HF), fiber-filled sibling, or a
|
||||
second selectable diameter → **new family, new id**. The same spool tuned for another printer
|
||||
or nozzle → **join the existing family** (inherit its `@base`, write no id key). Tuning a
|
||||
generic material → **join the OrcaFilamentLibrary family** (inherit `Generic X @System`, keep
|
||||
the `Generic X` base name, write no id key).
|
||||
|
||||
| Situation | id |
|
||||
| --- | --- |
|
||||
| Per-printer / per-nozzle variant of an existing material | same id (inherit, never write the key) |
|
||||
| Sub-brand or product line (PLA vs PLA Matte vs PLA Silk vs PLA HF) | new id each |
|
||||
| Color | never a new id |
|
||||
| Second diameter selectable on the same printer (1.75 + 2.85) | sibling family, new id |
|
||||
| "High-speed" tuned for a *different printer model* | same id (it is a printer variant) |
|
||||
| "High-speed" selectable *alongside* the normal preset on one printer | new id (it is a product line) |
|
||||
|
||||
## Structure rules
|
||||
|
||||
1. **Only family roots carry the key.** Root presets (any preset *not* marked
|
||||
`"instantiation": "true"`, typically `<Family> @base` with `"instantiation": "false"`)
|
||||
declare `filament_id`; instantiated variants inherit a root and never
|
||||
write the key. A family may have several roots (e.g. per-series bases) — all of them must
|
||||
declare the *identical* id.
|
||||
2. **The family name is the base name**: the preset name with everything from the first
|
||||
(optionally space-preceded) `@` stripped. `MyBrand PLA @Orca 3D Fuse1` and `MyBrand PLA@HS`
|
||||
both belong to family `MyBrand PLA`.
|
||||
3. **Within a family, variants' `compatible_printers` are pairwise disjoint** — per printer
|
||||
preset, at most one compatible instantiated preset per id. The C++ validator (`-f`)
|
||||
enforces this.
|
||||
4. **Generics belong to OrcaFilamentLibrary.** A vendor tuning a generic material inherits
|
||||
`Generic X @System`, keeps the `Generic X` base name (that alias is what hides the library
|
||||
preset on your printers), sets a non-empty `compatible_printers`, and writes no id key.
|
||||
A vendor-*branded* filament never rides a generic family id.
|
||||
5. **Ids are immutable once shipped.** Renaming a family does not change its id — use
|
||||
`renamed_from`. A shipped id is never recycled for a different material: old ids live on in
|
||||
user presets and 3mf files and a recycled id would silently match the wrong material.
|
||||
|
||||
## Minting — nobody invents ids
|
||||
|
||||
New ids are deterministic, computed exactly like the `setting_id` precedent
|
||||
(`scripts/assign_vendor_setting_ids.py`):
|
||||
|
||||
```text
|
||||
FILAMENT_ID_NAMESPACE = uuid5(setting-id NAMESPACE, "filament_id")
|
||||
= c4d3ff49-4c32-5534-a3e3-00894157ab97
|
||||
filament_id = "OF" + base62_6( uuid5(FILAMENT_ID_NAMESPACE, "filament_family/<vendor>/<family>") )
|
||||
```
|
||||
|
||||
`base62_6` is the low 6 base62 digits (alphabet `0-9A-Za-z`) of the UUID taken as a big-endian
|
||||
integer, most-significant digit first — 8 chars total, within the AMS length limit. `<vendor>`
|
||||
is the vendor bundle name (the stem of the vendor index json, e.g. `Qidi`,
|
||||
`OrcaFilamentLibrary`); `<family>` is the family base name. On the rare collision with any
|
||||
existing or retired id, the minter salts the input (`…/1`, `…/2`, …) until free and the result
|
||||
is frozen in the file. Example: family `MyBrand PLA` in vendor `Orca 3D` mints `OFvCEY5V`.
|
||||
|
||||
Workflow for a new family:
|
||||
|
||||
```bash
|
||||
# 1. Author the family with NO filament_id key anywhere.
|
||||
python scripts/assign_filament_ids.py # 2. mint + insert ids into the family root(s)
|
||||
python scripts/assign_filament_ids.py --update-snapshot # 3. record the new claims in the ledger
|
||||
python scripts/assign_filament_ids.py --check # 4. verify — the same checks CI runs
|
||||
# 5. Commit the profile edits together with scripts/filament_id_snapshot.json.
|
||||
```
|
||||
|
||||
`--mint "Vendor/Family"` prints the id for one family without touching anything. The default
|
||||
run is idempotent and never rewrites a valid existing id.
|
||||
|
||||
If you skip the tooling, CI fails and prints the remedy: the expected id for your family, and
|
||||
the instruction to run `python scripts/assign_filament_ids.py --update-snapshot` and commit
|
||||
the resulting diff.
|
||||
|
||||
## Reserved namespaces — never mint or hand-write into
|
||||
|
||||
| Space | Owner | Rule |
|
||||
| --- | --- | --- |
|
||||
| `GF*` | Bambu AMS/RFID catalog | BBL vendor only; byte-copies elsewhere only where the snapshot already sanctions them |
|
||||
| `QD_*` | Qidi device protocol | frozen device contract; Qidi vendor only |
|
||||
| `P` + 7 hex chars (case-insensitive), `"null"` | user-created custom filaments (`CreatePresetsDialog.cpp`) | never appears in system profiles |
|
||||
| every already-shipped id | grandfather snapshot | frozen as-is; new claims need maintainer sign-off |
|
||||
| every retired id | `scripts/retired_filament_ids.json` | never used again, for anything |
|
||||
|
||||
## How CI enforces this
|
||||
|
||||
Profile CI (`check_profiles.yml` → `scripts/orca_extra_profile_check.py`) runs
|
||||
`check_filament_ids()` tree-wide. Its ground truth is
|
||||
**`scripts/filament_id_snapshot.json` — the sanctioned state**: the id state derived from the
|
||||
tree must equal the snapshot exactly, in both directions. Any change to the id landscape
|
||||
therefore surfaces as a diff to that file, and **that snapshot diff is what maintainers review
|
||||
and gate in a PR**. Never edit the snapshot by hand — `--update-snapshot` regenerates it
|
||||
deterministically (running it twice changes nothing).
|
||||
|
||||
The checks, in brief:
|
||||
|
||||
- **Format** — every id is either in the snapshot, `OF` + 6 base62 chars, BBL's, or Qidi `QD_*`.
|
||||
- **Snapshot equality** — tree claims == snapshot claims, both directions.
|
||||
- **Mint conformance** — a non-grandfathered `OF*` claim must equal the mint (or a salt
|
||||
iteration) of its `(vendor, family)`; the error prints the expected id.
|
||||
- **Retired reuse** — any tree id present in `scripts/retired_filament_ids.json` is an error.
|
||||
Ids that fully vanish from the tree are appended there by `--update-snapshot`; the file is
|
||||
**append-only**.
|
||||
- **Alias hygiene** — a tuned generic must keep the library preset's base name and a non-empty
|
||||
`compatible_printers` (structure rule 4); the error names the rename as the cause.
|
||||
- **Reserved namespaces** — `GF*` outside BBL, `QD_*` outside Qidi, `P<7-hex>` or `null`
|
||||
anywhere, unless that exact claim is grandfathered in the snapshot.
|
||||
- **Structure** — no `filament_id` key on instantiated presets; no declared-vs-inherited id
|
||||
drift; every instantiated system filament must resolve an effective id through its
|
||||
`inherits` chain (an id-less one is a hard load error in C++ that discards the whole vendor
|
||||
bundle).
|
||||
|
||||
Sharing a **reserved-catalog** id with a new family or vendor (e.g. shipping a Bambu-cataloged
|
||||
product under another vendor with its authentic `GF*` id) is refused by `--update-snapshot`
|
||||
unless you pass `--allow-shared-catalog` — and it still lands in the snapshot diff for
|
||||
maintainer review. Any other new sharing of an existing id is caught by the mint-conformance
|
||||
check instead.
|
||||
|
||||
## FAQ
|
||||
|
||||
- **A new color of an existing product?** Never a new id — colors are not families.
|
||||
- **A second diameter (1.75 mm and 2.85 mm) of the same product?** A sibling family with its
|
||||
own id: two diameters are separately selectable spool products.
|
||||
- **A high-speed tune of an existing material for another printer model?** Same family:
|
||||
inherit the family's root, write no id key.
|
||||
- **A tuned generic ("our profile for Generic PLA")?** Inherit `Generic PLA @System`, keep the
|
||||
`Generic PLA` base name, set `compatible_printers`, write no id key.
|
||||
- **I need to rename a family.** Keep the id, add `renamed_from`. Ids never change.
|
||||
- **CI says my family needs an id.** Run `python scripts/assign_filament_ids.py`, then
|
||||
`--update-snapshot`, and commit both diffs. Do not type an id by hand.
|
||||
|
||||
For general profile authoring, see the profile development guide on the
|
||||
[OrcaSlicer wiki](https://www.orcaslicer.com/wiki).
|
||||
851
scripts/assign_filament_ids.py
Normal file
851
scripts/assign_filament_ids.py
Normal file
@@ -0,0 +1,851 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mint deterministic filament_id values for OrcaSlicer system filament families and
|
||||
validate the tree against the sanctioned-state ledger.
|
||||
|
||||
Policy (companion to assign_vendor_setting_ids.py; see filament_id_plan.md):
|
||||
* filament_id is a MATERIAL-FAMILY id: one commercial product line = one id,
|
||||
shared by all of that material's per-printer/per-nozzle variants. The key is
|
||||
declared only on family ROOT presets (instantiation != "true"); instantiated
|
||||
variants inherit it through `inherits` and never write the key themselves.
|
||||
* New ids are a pure function of the family's identity:
|
||||
filament_id = "OF" + base62_6( uuid5(FILAMENT_ID_NAMESPACE,
|
||||
"filament_family/<vendor>/<family>") )
|
||||
where <vendor> is the vendor bundle name (stem of the vendor index json) and
|
||||
<family> is the family name = preset base name (name with /\\s?@.*$/ stripped).
|
||||
8 chars total, which satisfies the AMS length limit. Nobody invents ids by
|
||||
hand; on the astronomically rare collision with any existing or retired id the
|
||||
input is salted ("/1", "/2", ...) until free and the result is frozen in file.
|
||||
* Reserved id spaces that are never minted into or altered:
|
||||
- GF* Bambu AMS/RFID catalog (vendor BBL untouchable)
|
||||
- QD_* Qidi device protocol
|
||||
- P + 7 hex chars (case-insensitive) and the literal "null"
|
||||
user-custom presets (CreatePresetsDialog.cpp)
|
||||
- every already-shipped id, grandfathered via scripts/filament_id_snapshot.json
|
||||
* scripts/filament_id_snapshot.json is the sanctioned-state ledger: it must
|
||||
exactly equal the tree-derived state at all times, so any id/claim change shows
|
||||
up as a reviewable diff to that file (the maintainer gate). Ids that fully
|
||||
vanish from the tree are appended to scripts/retired_filament_ids.json; a
|
||||
retired id may never be used again for anything.
|
||||
|
||||
The effective-id resolution below is loader-faithful (PresetBundle.cpp
|
||||
load_vendor_configs_from_json): own filament_id key, else walk `inherits` within
|
||||
the vendor map, with OrcaFilamentLibrary base-bundle fallback; once a chain enters
|
||||
OFL it stays in OFL; a vendor chain that dead-ends id-less retries its direct
|
||||
parent in the OFL map.
|
||||
|
||||
Run from anywhere: python3 scripts/assign_filament_ids.py
|
||||
(default) mint + insert ids for id-less families; idempotent, never
|
||||
rewrites a valid existing id; a no-op on a fully-idded tree
|
||||
--mint "V/Family" print the id that would be minted; touches nothing
|
||||
--update-snapshot regenerate the snapshot from the tree; retire vanished ids
|
||||
--check run the validation checks (also run by CI through
|
||||
orca_extra_profile_check.py); exit nonzero on errors
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from assign_vendor_setting_ids import ALPHABET, NAMESPACE # noqa: E402
|
||||
|
||||
# Dedicated namespace for filament_id, derived from the setting_id namespace baked
|
||||
# into both Python and C++ (assign_vendor_setting_ids.NAMESPACE). Never change it.
|
||||
# FILAMENT_ID_NAMESPACE == UUID("c4d3ff49-4c32-5534-a3e3-00894157ab97")
|
||||
FILAMENT_ID_NAMESPACE = uuid.uuid5(NAMESPACE, "filament_id")
|
||||
FILAMENT_ID_LENGTH = 6 # base62 digits after the "OF" prefix -> 8 chars total
|
||||
|
||||
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PROFILES_DIR = os.path.normpath(os.path.join(SCRIPTS_DIR, "..", "resources", "profiles"))
|
||||
SNAPSHOT_PATH = os.path.join(SCRIPTS_DIR, "filament_id_snapshot.json")
|
||||
RETIRED_PATH = os.path.join(SCRIPTS_DIR, "retired_filament_ids.json")
|
||||
|
||||
OFL = "OrcaFilamentLibrary"
|
||||
|
||||
OF_ID_RE = re.compile(r"^OF[0-9A-Za-z]{6}$")
|
||||
# User-custom id space minted by CreatePresetsDialog.cpp ("P" + md5(name)[0:7]);
|
||||
# reserved case-insensitively, together with its "null" sentinel.
|
||||
USER_CUSTOM_ID_RE = re.compile(r"^P[0-9A-Fa-f]{7}$", re.IGNORECASE)
|
||||
# Family name = preset base name: strip the first "@..." suffix. The space before
|
||||
# "@" is optional because names like "Afinia PLA@HS" exist.
|
||||
BASE_NAME_RE = re.compile(r"\s?@.*$")
|
||||
# OFL generic presets relevant for alias hygiene (check 5).
|
||||
OFL_GENERIC_RE = re.compile(r"^Generic .* @System$")
|
||||
# Salt iterations accepted by the mint-conformance check (check 3).
|
||||
MAX_CHECK_SALT = 8
|
||||
|
||||
UPDATE_HINT = 'run "python scripts/assign_filament_ids.py --update-snapshot" and commit the diff for maintainer review'
|
||||
|
||||
|
||||
# Same output helpers/format as orca_extra_profile_check.py (not imported from
|
||||
# there to avoid a circular import: that script imports check_filament_ids).
|
||||
def print_error(msg):
|
||||
print(f"\033[91m[ERROR]\033[0m {msg}") # Red
|
||||
|
||||
def print_warning(msg):
|
||||
print(f"\033[93m[WARNING]\033[0m {msg}") # Yellow
|
||||
|
||||
def print_info(msg):
|
||||
print(f"\033[94m[INFO]\033[0m {msg}") # Blue
|
||||
|
||||
def print_success(msg):
|
||||
print(f"\033[92m[SUCCESS]\033[0m {msg}") # Green
|
||||
|
||||
|
||||
def _utf8_console():
|
||||
"""Make stdout/stderr survive non-ASCII profile names on cp1252 consoles."""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
try:
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def base_name(name):
|
||||
"""Family name of a preset: name with the first "@..." suffix stripped."""
|
||||
return BASE_NAME_RE.sub("", name, count=1)
|
||||
|
||||
|
||||
def generate_filament_id(vendor, family, salt=0):
|
||||
"""Deterministic "OF" + 6-char base62 filament_id for a material family.
|
||||
|
||||
input = "filament_family/<vendor>/<family>" (+ "/<salt>" when salted);
|
||||
u = uuid5(FILAMENT_ID_NAMESPACE, input); the id tail is the low
|
||||
FILAMENT_ID_LENGTH base62 digits of int(u.bytes, "big"), most-significant
|
||||
first — the same derivation as generate_preset_setting_id.
|
||||
"""
|
||||
key = f"filament_family/{vendor}/{family}"
|
||||
if salt:
|
||||
key = f"{key}/{salt}"
|
||||
u = uuid.uuid5(FILAMENT_ID_NAMESPACE, key)
|
||||
n = int.from_bytes(u.bytes, "big")
|
||||
digits = []
|
||||
for _ in range(FILAMENT_ID_LENGTH):
|
||||
digits.append(ALPHABET[n % 62])
|
||||
n //= 62
|
||||
return "OF" + "".join(reversed(digits))
|
||||
|
||||
|
||||
def mint_filament_id(vendor, family, taken):
|
||||
"""Mint the family's id, salting past any id in `taken` (existing + retired)."""
|
||||
for salt in range(10000):
|
||||
candidate = generate_filament_id(vendor, family, salt)
|
||||
if candidate not in taken:
|
||||
return candidate
|
||||
raise RuntimeError(f"could not mint a free filament_id for {vendor}/{family}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tree loading + loader-faithful effective-id resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_json(path):
|
||||
with open(path, "r", encoding="utf-8-sig") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def list_vendor_names(profiles_dir):
|
||||
"""Vendor bundles = subdirectories with a matching <name>.json index file.
|
||||
|
||||
(Ignores stray non-bundle entries such as the tracked "user" directory,
|
||||
which has no user.json index.)
|
||||
"""
|
||||
profiles_dir = str(profiles_dir)
|
||||
return sorted(
|
||||
os.path.splitext(f)[0] for f in os.listdir(profiles_dir)
|
||||
if f.endswith(".json")
|
||||
and os.path.isdir(os.path.join(profiles_dir, os.path.splitext(f)[0]))
|
||||
)
|
||||
|
||||
|
||||
def load_vendor_filaments(profiles_dir, vendor):
|
||||
"""Load a vendor's filament presets from its index's filament_list.
|
||||
|
||||
Returns (presets dict name -> record, list of unreadable-file messages).
|
||||
"""
|
||||
profiles_dir = str(profiles_dir)
|
||||
presets = {}
|
||||
errors = []
|
||||
try:
|
||||
idx = load_json(os.path.join(profiles_dir, vendor + ".json"))
|
||||
except (OSError, ValueError) as e:
|
||||
return presets, [f"unreadable vendor index {vendor}.json: {e}"]
|
||||
for entry in idx.get("filament_list", []):
|
||||
rel = f"{vendor}/{entry.get('sub_path', '')}"
|
||||
path = os.path.join(profiles_dir, vendor, entry.get("sub_path", ""))
|
||||
try:
|
||||
data = load_json(path)
|
||||
except (OSError, ValueError) as e:
|
||||
errors.append(f"unreadable filament profile {rel}: {e}")
|
||||
continue
|
||||
name = data.get("name", entry.get("name"))
|
||||
presets[name] = {
|
||||
"name": name,
|
||||
"file": rel,
|
||||
"path": path,
|
||||
"filament_id": data.get("filament_id"),
|
||||
"inherits": data.get("inherits"),
|
||||
"instantiation": str(data.get("instantiation", "")).lower() == "true",
|
||||
"compatible_printers": data.get("compatible_printers") or [],
|
||||
}
|
||||
return presets, errors
|
||||
|
||||
|
||||
def resolve_filament_id(name, filaments, ofl_filaments, seen=None, in_ofl=False, skip_own=False):
|
||||
"""Walk the inherits chain for the effective filament_id, loader-faithfully.
|
||||
|
||||
Mirrors PresetBundle.cpp load_vendor_configs_from_json: a hop resolves in the
|
||||
vendor's own map first, then falls back to the OFL base-bundle map. OFL's map
|
||||
was memoized entirely within OFL, so once a chain enters OFL it stays in OFL
|
||||
(a vendor file sharing an OFL preset's name must not shadow OFL-internal
|
||||
hops). Additionally, a vendor preset that never resolves an id inside the
|
||||
vendor is re-tried against the OFL map keyed by its direct parent name.
|
||||
|
||||
skip_own ignores the first preset's own filament_id key (used to compute the
|
||||
id its inherits chain would resolve WITHOUT the declaration — check 7b drift).
|
||||
|
||||
Returns (filament_id or None, source, ofl_entry) where source is one of
|
||||
"own"/"inherited"/"missing"/"dangling"/"cycle" and ofl_entry is the name of
|
||||
the OFL preset through which a vendor chain entered OFL (None when the id was
|
||||
declared vendor-side or resolution started inside OFL).
|
||||
"""
|
||||
if seen is None:
|
||||
seen = set()
|
||||
if name in seen:
|
||||
return None, "cycle", None
|
||||
seen.add(name)
|
||||
entry = None
|
||||
if in_ofl:
|
||||
rec = ofl_filaments.get(name)
|
||||
else:
|
||||
rec = filaments.get(name)
|
||||
if rec is None and name in ofl_filaments:
|
||||
rec, in_ofl, entry = ofl_filaments[name], True, name
|
||||
if rec is None:
|
||||
return None, "dangling", None
|
||||
if rec.get("filament_id") and not skip_own:
|
||||
return rec["filament_id"], "own" if len(seen) == 1 else "inherited", entry
|
||||
parent = rec.get("inherits")
|
||||
if parent:
|
||||
fid, src, sub_entry = resolve_filament_id(parent, filaments, ofl_filaments, seen, in_ofl)
|
||||
if fid or in_ofl:
|
||||
return fid, src, entry if entry is not None else sub_entry
|
||||
# Vendor chain dead-ended id-less: the loader would have consulted the
|
||||
# OFL map at each vendor hop's inherits; retry this hop's parent in OFL.
|
||||
if parent in ofl_filaments:
|
||||
fid, src, _ = resolve_filament_id(parent, filaments, ofl_filaments, set(), True)
|
||||
return fid, src, parent
|
||||
return fid, src, sub_entry
|
||||
return None, "missing", entry
|
||||
|
||||
|
||||
def analyze_tree(profiles_dir):
|
||||
"""Load every vendor bundle and derive the full filament_id state.
|
||||
|
||||
Returns a dict with the tree-derived snapshot sections plus the working data
|
||||
the checks and the assign pass need. All claims are "Vendor/Family" strings
|
||||
over INSTANTIATED system filaments, tree-wide including OFL and BBL.
|
||||
"""
|
||||
profiles_dir = str(profiles_dir)
|
||||
vendor_names = list_vendor_names(profiles_dir)
|
||||
ofl_filaments, ofl_errors = (
|
||||
load_vendor_filaments(profiles_dir, OFL) if OFL in vendor_names else ({}, [])
|
||||
)
|
||||
|
||||
vendors = {}
|
||||
read_errors = list(ofl_errors)
|
||||
for vendor in vendor_names:
|
||||
if vendor == OFL:
|
||||
filaments = ofl_filaments
|
||||
else:
|
||||
filaments, errs = load_vendor_filaments(profiles_dir, vendor)
|
||||
read_errors.extend(errs)
|
||||
for rec in filaments.values():
|
||||
eff, src, ofl_entry = resolve_filament_id(rec["name"], filaments, ofl_filaments)
|
||||
rec["eff_filament_id"] = eff
|
||||
rec["id_source"] = src
|
||||
rec["ofl_entry"] = ofl_entry
|
||||
vendors[vendor] = filaments
|
||||
|
||||
# id -> set of "Vendor/Family" claims over instantiated presets. Every id
|
||||
# occurring in the tree is a key; ids only ever DECLARED (e.g. on a root
|
||||
# whose children all override them) keep an empty claim list, so that the
|
||||
# snapshot exactly equals the tree-derived state and the format/retirement
|
||||
# checks can grandfather them.
|
||||
ids = {}
|
||||
vendor_ids = {} # vendor -> set of ids occurring there (declared or effective)
|
||||
declared_ids = {} # vendor -> set of ids DECLARED in that vendor's own files
|
||||
instantiated_with_id = [] # "Vendor/PresetName" (own filament_id key on an instantiated preset)
|
||||
overrides = [] # (vendor, name, declared, inherited, file)
|
||||
missing_effective = [] # (vendor, name, file) instantiated presets resolving no id
|
||||
alias_candidates = [] # (vendor, rec, ofl_entry) presets id-resolved through an OFL generic
|
||||
|
||||
for vendor, filaments in vendors.items():
|
||||
occurring = vendor_ids.setdefault(vendor, set())
|
||||
for rec in filaments.values():
|
||||
if rec.get("filament_id"):
|
||||
occurring.add(rec["filament_id"])
|
||||
declared_ids.setdefault(vendor, set()).add(rec["filament_id"])
|
||||
ids.setdefault(rec["filament_id"], set())
|
||||
if rec.get("inherits"):
|
||||
inherited, _src, _e = resolve_filament_id(
|
||||
rec["name"], filaments, ofl_filaments, skip_own=True)
|
||||
if inherited and inherited != rec["filament_id"]:
|
||||
overrides.append(
|
||||
(vendor, rec["name"], rec["filament_id"], inherited, rec["file"]))
|
||||
if not rec["instantiation"]:
|
||||
continue
|
||||
eff = rec.get("eff_filament_id")
|
||||
if not eff:
|
||||
missing_effective.append((vendor, rec["name"], rec["file"]))
|
||||
continue
|
||||
occurring.add(eff)
|
||||
ids.setdefault(eff, set()).add(f"{vendor}/{base_name(rec['name'])}")
|
||||
if rec.get("filament_id"):
|
||||
instantiated_with_id.append(f"{vendor}/{rec['name']}")
|
||||
if vendor != OFL and rec["ofl_entry"] and OFL_GENERIC_RE.match(rec["ofl_entry"]):
|
||||
alias_candidates.append((vendor, rec, rec["ofl_entry"]))
|
||||
|
||||
# Alias hygiene (check 5): a vendor preset that tunes an OFL generic (no id
|
||||
# anywhere in its vendor-side chain) is matched to the OFL preset by ALIAS
|
||||
# (base name); renaming it re-exposes the OFL generic and creates a live
|
||||
# duplicate, and empty compatible_printers cannot claim any printer.
|
||||
alias_violations = [] # (vendor, name, ofl_entry, reason, file)
|
||||
for vendor, rec, entry in alias_candidates:
|
||||
expected = base_name(entry)
|
||||
own_base = base_name(rec["name"])
|
||||
if own_base != expected:
|
||||
alias_violations.append((
|
||||
vendor, rec["name"], entry,
|
||||
f'base name "{own_base}" != "{expected}" — the rename re-exposes the '
|
||||
f"OFL preset on its printers (alias shadowing is name-based)",
|
||||
rec["file"]))
|
||||
elif not rec["compatible_printers"]:
|
||||
alias_violations.append((
|
||||
vendor, rec["name"], entry,
|
||||
"empty compatible_printers cannot shadow the OFL preset anywhere",
|
||||
rec["file"]))
|
||||
|
||||
return {
|
||||
"vendors": vendors,
|
||||
"read_errors": read_errors,
|
||||
"ids": {fid: sorted(claims) for fid, claims in ids.items()},
|
||||
"vendor_ids": vendor_ids,
|
||||
"declared_ids": declared_ids,
|
||||
"instantiated_with_id": sorted(instantiated_with_id),
|
||||
"overrides": overrides,
|
||||
"id_overrides": sorted(f"{v}/{n}" for v, n, _d, _i, _f in overrides),
|
||||
"missing_effective": sorted(missing_effective),
|
||||
"alias_violations": alias_violations,
|
||||
"alias_exceptions": sorted(f"{v}/{n}" for v, n, _e, _r, _f in alias_violations),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snapshot / retired-ledger IO
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def snapshot_from_analysis(analysis):
|
||||
return {
|
||||
"ids": {fid: sorted(claims) for fid, claims in analysis["ids"].items()},
|
||||
"instantiated_with_id": analysis["instantiated_with_id"],
|
||||
"id_overrides": analysis["id_overrides"],
|
||||
"alias_exceptions": analysis["alias_exceptions"],
|
||||
}
|
||||
|
||||
|
||||
def load_snapshot(path):
|
||||
"""Return the snapshot dict, or None when the file does not exist."""
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
data = load_json(path)
|
||||
for key in ("ids", "instantiated_with_id", "id_overrides", "alias_exceptions"):
|
||||
data.setdefault(key, {} if key == "ids" else [])
|
||||
return data
|
||||
|
||||
|
||||
def load_retired(path):
|
||||
"""Return the retired-ids map {id: ["Vendor/Family", ...]} ({} if absent)."""
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
return load_json(path).get("retired", {})
|
||||
|
||||
|
||||
def write_ledger(path, obj):
|
||||
"""Deterministic serialization: sorted keys, indent 1, LF, trailing newline."""
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
||||
json.dump(obj, f, indent=1, ensure_ascii=False, sort_keys=True)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reserved namespaces
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def reserved_space_owner(fid):
|
||||
"""(is_reserved, owner_vendor or None) for the frozen id spaces."""
|
||||
if fid.startswith("GF"):
|
||||
return True, "BBL"
|
||||
if fid.startswith("QD_"):
|
||||
return True, "Qidi"
|
||||
if USER_CUSTOM_ID_RE.match(fid) or fid == "null":
|
||||
return True, None # user-custom space: no system vendor may own it
|
||||
return False, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Checks (imported and called tree-wide by orca_extra_profile_check.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_filament_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH,
|
||||
retired_path=RETIRED_PATH):
|
||||
"""Validate filament_id state across every vendor. Returns the error count.
|
||||
|
||||
1. Format: every id occurring in the tree (declared or effective) must be in
|
||||
the snapshot, or match ^OF[0-9A-Za-z]{6}$, or belong to vendor BBL, or be
|
||||
a QD_* id within vendor Qidi.
|
||||
2. Snapshot equality, both directions: the tree-derived id->families multimap
|
||||
must equal the snapshot exactly (the snapshot diff is the maintainer gate).
|
||||
3. Mint conformance: an OF-format id's claim that is not grandfathered must
|
||||
equal the mint of (vendor, family) or a salted iteration.
|
||||
4. Retired ids may never occur again.
|
||||
5. Alias hygiene: a tuned OFL generic must keep the generic's base name and
|
||||
claim printers via non-empty compatible_printers.
|
||||
6. Reserved namespaces (GF*/QD_*/P-hex/"null") only for their owner vendors,
|
||||
except claims grandfathered in the snapshot.
|
||||
7. Structure ratchet: (a) no NEW instantiated preset carries its own
|
||||
filament_id key; (b) no NEW declared-vs-inherited id drift; (c) every
|
||||
instantiated filament resolves an effective id (a hard load error in C++).
|
||||
"""
|
||||
_utf8_console()
|
||||
errors = 0
|
||||
analysis = analyze_tree(profiles_dir)
|
||||
snapshot = load_snapshot(snapshot_path)
|
||||
if snapshot is None:
|
||||
print_error(f"filament_id snapshot not found at {snapshot_path}; {UPDATE_HINT}")
|
||||
return 1
|
||||
retired = load_retired(retired_path)
|
||||
|
||||
for msg in analysis["read_errors"]:
|
||||
print_error(msg)
|
||||
errors += 1
|
||||
|
||||
snap_ids = snapshot["ids"]
|
||||
tree_ids = analysis["ids"]
|
||||
|
||||
# -- 1. format ----------------------------------------------------------
|
||||
for vendor in sorted(analysis["vendor_ids"]):
|
||||
for fid in sorted(analysis["vendor_ids"][vendor]):
|
||||
if fid in snap_ids or OF_ID_RE.match(fid):
|
||||
continue
|
||||
if vendor == "BBL":
|
||||
continue
|
||||
if vendor == "Qidi" and fid.startswith("QD_"):
|
||||
continue
|
||||
print_error(
|
||||
f'filament_id "{fid}" ({vendor}) is neither grandfathered in the '
|
||||
f'snapshot nor a minted "OF" id; new family ids must come from '
|
||||
f'"python scripts/assign_filament_ids.py" (see --mint)')
|
||||
errors += 1
|
||||
|
||||
# -- 2. snapshot equality (both directions) -----------------------------
|
||||
for fid in sorted(tree_ids):
|
||||
if fid not in snap_ids:
|
||||
print_error(
|
||||
f'filament_id "{fid}" is not sanctioned by '
|
||||
f"scripts/filament_id_snapshot.json; {UPDATE_HINT}")
|
||||
errors += 1
|
||||
continue
|
||||
for claim in tree_ids[fid]:
|
||||
if claim not in snap_ids[fid]:
|
||||
print_error(
|
||||
f'filament_id "{fid}" claim "{claim}" is not sanctioned by '
|
||||
f"scripts/filament_id_snapshot.json; {UPDATE_HINT}")
|
||||
errors += 1
|
||||
for fid in sorted(snap_ids):
|
||||
if fid not in tree_ids:
|
||||
print_error(
|
||||
f'filament_id stability: snapshot id "{fid}" vanished from the tree '
|
||||
f"(ids are immutable once shipped); {UPDATE_HINT}")
|
||||
errors += 1
|
||||
continue
|
||||
for claim in snap_ids[fid]:
|
||||
if claim not in tree_ids[fid]:
|
||||
print_error(
|
||||
f'filament_id stability: snapshot claim "{claim}" of id "{fid}" '
|
||||
f"vanished from the tree (ids are immutable once shipped); {UPDATE_HINT}")
|
||||
errors += 1
|
||||
|
||||
# -- 3. mint conformance for OF-format ids ------------------------------
|
||||
for fid in sorted(tree_ids):
|
||||
if not OF_ID_RE.match(fid):
|
||||
continue
|
||||
for claim in tree_ids[fid]:
|
||||
if claim in snap_ids.get(fid, []):
|
||||
continue # grandfathered
|
||||
vendor, family = claim.split("/", 1)
|
||||
if fid not in analysis["declared_ids"].get(vendor, set()):
|
||||
# The claiming vendor never declares this id — it is inherited
|
||||
# from another vendor's root (an OFL generic family): correctly
|
||||
# tuned generics ride the OFL family id by design, so only the
|
||||
# snapshot gate (check 2) applies to this claim.
|
||||
continue
|
||||
minted = [generate_filament_id(vendor, family, s) for s in range(MAX_CHECK_SALT + 1)]
|
||||
if fid not in minted:
|
||||
print_error(
|
||||
f'filament_id "{fid}" of family "{claim}" does not match its mint: '
|
||||
f'expected "{minted[0]}" (or a salted iteration); paste the expected '
|
||||
f"id into the family root (or, for an intentionally shared id, "
|
||||
f"{UPDATE_HINT})")
|
||||
errors += 1
|
||||
|
||||
# -- 4. retired ids may never come back ---------------------------------
|
||||
for vendor in sorted(analysis["vendor_ids"]):
|
||||
for fid in sorted(analysis["vendor_ids"][vendor]):
|
||||
if fid in retired:
|
||||
print_error(
|
||||
f'filament_id "{fid}" ({vendor}) is retired '
|
||||
f"(scripts/retired_filament_ids.json) and may never be reused")
|
||||
errors += 1
|
||||
|
||||
# -- 5. alias hygiene for tuned OFL generics -----------------------------
|
||||
exceptions = set(snapshot["alias_exceptions"])
|
||||
for vendor, name, entry, reason, file in analysis["alias_violations"]:
|
||||
if f"{vendor}/{name}" in exceptions:
|
||||
continue
|
||||
print_error(
|
||||
f'preset "{name}" ({file}) tunes the OFL generic "{entry}" but {reason}; '
|
||||
f'keep the OFL base name and non-empty compatible_printers, or give the '
|
||||
f"family its own minted id")
|
||||
errors += 1
|
||||
|
||||
# -- 6. reserved namespaces ----------------------------------------------
|
||||
for fid in sorted(tree_ids):
|
||||
is_reserved, owner = reserved_space_owner(fid)
|
||||
if not is_reserved:
|
||||
continue
|
||||
for claim in tree_ids[fid]:
|
||||
vendor = claim.split("/", 1)[0]
|
||||
if vendor == owner:
|
||||
continue
|
||||
if claim in snap_ids.get(fid, []):
|
||||
continue # grandfathered
|
||||
space = f"owned by {owner}" if owner else "reserved for user-custom presets"
|
||||
print_error(
|
||||
f'filament_id "{fid}" of "{claim}" is in a reserved id space '
|
||||
f"({space}) and must not be claimed by system presets of other vendors")
|
||||
errors += 1
|
||||
|
||||
# -- 7. structure ratchet -------------------------------------------------
|
||||
grandfathered = set(snapshot["instantiated_with_id"])
|
||||
for key in analysis["instantiated_with_id"]:
|
||||
if key not in grandfathered:
|
||||
print_error(
|
||||
f'instantiated preset "{key}" declares its own filament_id key; the key '
|
||||
f"belongs on the family root preset only (variants inherit it)")
|
||||
errors += 1
|
||||
grandfathered = set(snapshot["id_overrides"])
|
||||
for vendor, name, declared, inherited, file in analysis["overrides"]:
|
||||
if f"{vendor}/{name}" in grandfathered:
|
||||
continue
|
||||
print_error(
|
||||
f'preset "{name}" ({file}) declares filament_id "{declared}" but its '
|
||||
f'inherits chain resolves "{inherited}"; a preset must not override its '
|
||||
f"family's id")
|
||||
errors += 1
|
||||
for vendor, name, file in analysis["missing_effective"]:
|
||||
expected = generate_filament_id(vendor, base_name(name))
|
||||
print_error(
|
||||
f'instantiated filament "{name}" ({file}) resolves no filament_id anywhere '
|
||||
f"in its inherits chain — this is a hard load error in the C++ loader; "
|
||||
f'run "python scripts/assign_filament_ids.py" (expected id for family '
|
||||
f'"{vendor}/{base_name(name)}": "{expected}", salted if taken)')
|
||||
errors += 1
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --update-snapshot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def update_snapshot(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH,
|
||||
retired_path=RETIRED_PATH, allow_shared_catalog=False):
|
||||
"""Regenerate the snapshot from the tree; retire ids that fully vanished.
|
||||
|
||||
Refuses to sanction NEW reserved-namespace ids (or new claims on them) for
|
||||
non-owner vendors unless --allow-shared-catalog is passed. Idempotent: a
|
||||
second run over an unchanged tree changes nothing. Returns 0 on success.
|
||||
"""
|
||||
analysis = analyze_tree(profiles_dir)
|
||||
for msg in analysis["read_errors"]:
|
||||
print_error(msg)
|
||||
new_snap = snapshot_from_analysis(analysis)
|
||||
old_snap = load_snapshot(snapshot_path)
|
||||
old_ids = old_snap["ids"] if old_snap else {}
|
||||
|
||||
# Gate: new reserved-namespace ids / claims for non-owner vendors.
|
||||
refusals = []
|
||||
for fid, claims in sorted(new_snap["ids"].items()):
|
||||
is_reserved, owner = reserved_space_owner(fid)
|
||||
if not is_reserved:
|
||||
continue
|
||||
for claim in claims:
|
||||
if claim in old_ids.get(fid, []):
|
||||
continue
|
||||
vendor = claim.split("/", 1)[0]
|
||||
if vendor == owner:
|
||||
continue
|
||||
refusals.append((fid, claim, owner))
|
||||
if not claims and fid not in old_ids:
|
||||
# Declared-only new id: attribute it to its declaring vendor(s).
|
||||
for vendor in sorted(analysis["vendor_ids"]):
|
||||
if fid in analysis["vendor_ids"][vendor] and vendor != owner:
|
||||
refusals.append((fid, f"{vendor}/(declared only)", owner))
|
||||
if refusals and not allow_shared_catalog:
|
||||
for fid, claim, owner in refusals:
|
||||
space = f"owned by {owner}" if owner else "reserved for user-custom presets"
|
||||
print_error(
|
||||
f'refusing to sanction new claim "{claim}" on reserved-namespace id '
|
||||
f'"{fid}" ({space}); pass --allow-shared-catalog only for '
|
||||
f"maintainer-approved shared-catalog families")
|
||||
return 1
|
||||
|
||||
# Retirement is permanent: refuse to sanction a tree that resurrects a
|
||||
# retired id (check 4 would reject the resulting snapshot forever anyway).
|
||||
retired = load_retired(retired_path)
|
||||
reused = sorted(set(new_snap["ids"]) & set(retired))
|
||||
if reused:
|
||||
for fid in reused:
|
||||
print_error(
|
||||
f'refusing to sanction retired filament_id "{fid}" '
|
||||
f"(scripts/retired_filament_ids.json is append-only; retired ids may "
|
||||
f"never be reused — mint a fresh id for the family instead)")
|
||||
return 1
|
||||
|
||||
# Retire ids that fully vanished from the tree (append-only ledger).
|
||||
newly_retired = []
|
||||
for fid in sorted(old_ids):
|
||||
if fid not in new_snap["ids"]:
|
||||
retired[fid] = sorted(set(retired.get(fid, [])) | set(old_ids[fid]))
|
||||
newly_retired.append(fid)
|
||||
|
||||
# Diff summary.
|
||||
added_ids = sorted(set(new_snap["ids"]) - set(old_ids))
|
||||
removed_ids = sorted(set(old_ids) - set(new_snap["ids"]))
|
||||
added_claims = sum(
|
||||
len(set(claims) - set(old_ids.get(fid, [])))
|
||||
for fid, claims in new_snap["ids"].items())
|
||||
removed_claims = sum(
|
||||
len(set(claims) - set(new_snap["ids"].get(fid, [])))
|
||||
for fid, claims in old_ids.items())
|
||||
old_snap = old_snap or {"ids": {}, "instantiated_with_id": [], "id_overrides": [],
|
||||
"alias_exceptions": []}
|
||||
changed = new_snap != old_snap
|
||||
|
||||
if changed:
|
||||
write_ledger(snapshot_path, new_snap)
|
||||
if newly_retired or not os.path.exists(retired_path):
|
||||
write_ledger(retired_path, {"retired": retired})
|
||||
|
||||
print_info(f"snapshot ids : {len(new_snap['ids'])} (+{len(added_ids)} / -{len(removed_ids)})")
|
||||
print_info(f"claims added : {added_claims}")
|
||||
print_info(f"claims removed : {removed_claims}")
|
||||
print_info(f"ids retired now : {len(newly_retired)}" +
|
||||
(f" ({', '.join(newly_retired)})" if newly_retired else ""))
|
||||
for section in ("instantiated_with_id", "id_overrides", "alias_exceptions"):
|
||||
before, after = len(old_snap.get(section, [])), len(new_snap[section])
|
||||
print_info(f"{section:<18}: {after} ({after - before:+d})")
|
||||
if changed:
|
||||
print_success(f"snapshot written to {snapshot_path}")
|
||||
else:
|
||||
print_success("snapshot already up to date; nothing changed")
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default run: mint + insert ids for id-less families
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def insert_filament_id(text, new_id):
|
||||
"""Insert a `"filament_id"` line into a preset that lacks one.
|
||||
|
||||
Placed just before `instantiation` (or, failing that, after the `name` line)
|
||||
so it matches the canonical key order, reusing that anchor line's indentation
|
||||
and line ending. Same byte-preserving approach as assign_vendor_setting_ids.
|
||||
"""
|
||||
m = re.search(r'^([ \t]*)"instantiation"[ \t]*:.*?(\r?\n)', text, re.MULTILINE)
|
||||
if m:
|
||||
line = f'{m.group(1)}"filament_id": {json.dumps(new_id, ensure_ascii=False)},{m.group(2)}'
|
||||
return text[:m.start()] + line + text[m.start():], 1
|
||||
m = re.search(r'^([ \t]*)"name"[ \t]*:.*?(\r?\n)', text, re.MULTILINE)
|
||||
if m:
|
||||
line = f'{m.group(1)}"filament_id": {json.dumps(new_id, ensure_ascii=False)},{m.group(2)}'
|
||||
return text[:m.end()] + line + text[m.end():], 1
|
||||
return text, 0
|
||||
|
||||
|
||||
def write_filament_id(path, new_id):
|
||||
"""Insert new_id into the profile at path, byte-preserving everything else.
|
||||
|
||||
Binary IO keeps the file's original line endings (LF or CRLF) and exact
|
||||
formatting apart from the inserted line; the result is re-parsed to
|
||||
guarantee it is still valid JSON.
|
||||
"""
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
text, n = insert_filament_id(text, new_id)
|
||||
if n == 0:
|
||||
raise RuntimeError(f"Could not insert filament_id into {path}")
|
||||
json.loads(text) # fail loudly if the edit broke the JSON
|
||||
with open(path, "wb") as f:
|
||||
f.write((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||
|
||||
|
||||
def assign_missing_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH,
|
||||
retired_path=RETIRED_PATH):
|
||||
"""Mint + insert ids for id-less families. Never rewrites a valid existing id.
|
||||
|
||||
A family = (vendor, base name) group over instantiated filaments with no
|
||||
effective id. The minted id is inserted into the family's root(s): the
|
||||
presets its members inherit that carry no id, or the member itself when it
|
||||
has no vendor-side parent. Returns (files_changed, errors).
|
||||
"""
|
||||
analysis = analyze_tree(profiles_dir)
|
||||
errors = 0
|
||||
for msg in analysis["read_errors"]:
|
||||
print_error(msg)
|
||||
errors += 1
|
||||
|
||||
# Group id-less instantiated presets into families.
|
||||
families = {} # (vendor, family) -> [rec]
|
||||
for vendor, name, _file in analysis["missing_effective"]:
|
||||
rec = analysis["vendors"][vendor][name]
|
||||
if rec["id_source"] in ("cycle", "dangling"):
|
||||
print_error(f'cannot mint for "{vendor}/{name}": broken inherits chain '
|
||||
f'({rec["id_source"]})')
|
||||
errors += 1
|
||||
continue
|
||||
families.setdefault((vendor, base_name(name)), []).append(rec)
|
||||
|
||||
if not families:
|
||||
print_success("every instantiated filament already resolves a filament_id; "
|
||||
"nothing to do (0 files changed)")
|
||||
return 0, errors
|
||||
|
||||
# Ids already spoken for: whole tree (declared or effective) + ledgers.
|
||||
snapshot = load_snapshot(snapshot_path) or {"ids": {}}
|
||||
taken = set(snapshot["ids"]) | set(load_retired(retired_path))
|
||||
for occurring in analysis["vendor_ids"].values():
|
||||
taken |= occurring
|
||||
|
||||
# Family root(s): the direct vendor-side parents of the members (id-less by
|
||||
# construction), or the member itself when it has none.
|
||||
roots = {} # (vendor, family) -> {preset name: rec}
|
||||
root_claims = {} # (vendor, root name) -> set of families wanting to write it
|
||||
for key, members in sorted(families.items()):
|
||||
vendor = key[0]
|
||||
vendor_map = analysis["vendors"][vendor]
|
||||
family_roots = {}
|
||||
for rec in members:
|
||||
parent = rec.get("inherits")
|
||||
root = vendor_map.get(parent) if parent else None
|
||||
if root is None or root.get("filament_id"):
|
||||
root = rec # root-less member carries the id itself
|
||||
family_roots[root["name"]] = root
|
||||
root_claims.setdefault((vendor, root["name"]), set()).add(key)
|
||||
roots[key] = family_roots
|
||||
|
||||
files_changed = 0
|
||||
families_minted = 0
|
||||
for key, family_roots in sorted(roots.items()):
|
||||
vendor, family = key
|
||||
shared = [n for n in family_roots
|
||||
if len(root_claims[(vendor, n)]) > 1]
|
||||
if shared:
|
||||
others = sorted({f"{v}/{f}" for n in shared
|
||||
for (v, f) in root_claims[(vendor, n)] if (v, f) != key})
|
||||
print_error(
|
||||
f'cannot mint for family "{vendor}/{family}": root(s) '
|
||||
f"{sorted(shared)} are shared with famil(ies) {others}; split the "
|
||||
f"roots so each family has its own")
|
||||
errors += 1
|
||||
continue
|
||||
new_id = mint_filament_id(vendor, family, taken)
|
||||
taken.add(new_id)
|
||||
families_minted += 1
|
||||
for name in sorted(family_roots):
|
||||
root = family_roots[name]
|
||||
write_filament_id(root["path"], new_id)
|
||||
files_changed += 1
|
||||
print_info(f'family "{vendor}/{family}": filament_id "{new_id}" -> {root["file"]}')
|
||||
|
||||
print_info(f"families minted : {families_minted}")
|
||||
print_info(f"files changed : {files_changed}")
|
||||
if files_changed:
|
||||
print_warning(f"now {UPDATE_HINT}")
|
||||
return files_changed, errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main(argv=None):
|
||||
_utf8_console()
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Mint deterministic filament_id values for id-less filament "
|
||||
"families and validate the tree against the sanctioned snapshot.")
|
||||
parser.add_argument("--mint", metavar='"Vendor/Family"',
|
||||
help="print the id that would be minted for a family; "
|
||||
"touches nothing")
|
||||
parser.add_argument("--update-snapshot", action="store_true",
|
||||
help="regenerate scripts/filament_id_snapshot.json from the "
|
||||
"tree; ids that fully vanished are retired")
|
||||
parser.add_argument("--check", action="store_true",
|
||||
help="run the filament_id checks; exit nonzero on errors")
|
||||
parser.add_argument("--allow-shared-catalog", action="store_true",
|
||||
help="with --update-snapshot: allow sanctioning new claims "
|
||||
"on reserved-namespace ids for non-owner vendors")
|
||||
parser.add_argument("--profiles", default=PROFILES_DIR,
|
||||
help="profiles directory (default: resources/profiles)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.mint:
|
||||
if "/" not in args.mint:
|
||||
parser.error('--mint expects "Vendor/Family"')
|
||||
vendor, family = args.mint.split("/", 1)
|
||||
snapshot = load_snapshot(SNAPSHOT_PATH) or {"ids": {}}
|
||||
taken = set(snapshot["ids"]) | set(load_retired(RETIRED_PATH))
|
||||
print(mint_filament_id(vendor, family, taken))
|
||||
return 0
|
||||
|
||||
if args.update_snapshot:
|
||||
return update_snapshot(args.profiles, SNAPSHOT_PATH, RETIRED_PATH,
|
||||
allow_shared_catalog=args.allow_shared_catalog)
|
||||
|
||||
if args.check:
|
||||
errors = check_filament_ids(args.profiles, SNAPSHOT_PATH, RETIRED_PATH)
|
||||
if errors:
|
||||
print_error(f"filament_id check: {errors} error(s)")
|
||||
return 1
|
||||
print_success("filament_id check: no errors")
|
||||
return 0
|
||||
|
||||
_changed, errors = assign_missing_ids(args.profiles, SNAPSHOT_PATH, RETIRED_PATH)
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
7811
scripts/filament_id_snapshot.json
Normal file
7811
scripts/filament_id_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from assign_vendor_setting_ids import generate_preset_setting_id
|
||||
from assign_filament_ids import check_filament_ids
|
||||
|
||||
OBSOLETE_KEYS = {
|
||||
"acceleration", "scale", "rotate", "duplicate", "duplicate_grid",
|
||||
@@ -288,6 +289,12 @@ def check_name_consistency(profiles_dir, vendor_name):
|
||||
def check_filament_id(vendor, vendor_folder):
|
||||
"""
|
||||
Make sure filament_id is not longer than 8 characters, otherwise AMS won't work properly
|
||||
|
||||
NOTE: superseded by check_filament_ids (assign_filament_ids.py) for non-BBL/OFL
|
||||
vendors, which validates format/uniqueness/structure tree-wide against the
|
||||
grandfather snapshot. This length check stays scoped to BBL/OFL because other
|
||||
vendors ship grandfathered >8-char ids (e.g. Prusa's 36-char name-ids), so it
|
||||
cannot simply go tree-wide.
|
||||
"""
|
||||
if vendor not in ('BBL', 'OrcaFilamentLibrary'):
|
||||
return 0
|
||||
@@ -606,6 +613,11 @@ def main():
|
||||
# Runs once over the whole tree regardless of the --vendor filter.
|
||||
errors_found += check_setting_id_uniqueness(profiles_dir)
|
||||
|
||||
# Global filament_id check (see scripts/assign_filament_ids.py): effective ids
|
||||
# are resolved loader-faithfully and validated against the sanctioned snapshot
|
||||
# (scripts/filament_id_snapshot.json). Runs once over the whole tree.
|
||||
errors_found += check_filament_ids(profiles_dir)
|
||||
|
||||
# ✨ Output finale in stile "compilatore"
|
||||
print("\n==================== SUMMARY ====================")
|
||||
print_info(f"Checked vendors : {checked_vendor_count}")
|
||||
|
||||
3
scripts/retired_filament_ids.json
Normal file
3
scripts/retired_filament_ids.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"retired": {}
|
||||
}
|
||||
0
scripts/tests/__init__.py
Normal file
0
scripts/tests/__init__.py
Normal file
669
scripts/tests/test_filament_id.py
Normal file
669
scripts/tests/test_filament_id.py
Normal file
@@ -0,0 +1,669 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for scripts/assign_filament_ids.py (stdlib unittest, no external deps).
|
||||
|
||||
Run from the repo root: python -m unittest discover -s scripts/tests -v
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
import assign_filament_ids as afi # noqa: E402
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
REAL_PROFILES = os.path.join(REPO_ROOT, "resources", "profiles")
|
||||
|
||||
OFL = "OrcaFilamentLibrary"
|
||||
|
||||
|
||||
def load_json_file(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# helpers: synthetic profile trees
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def preset(name, filament_id=None, inherits=None, instantiation=True,
|
||||
compatible_printers=None):
|
||||
data = {"type": "filament", "name": name}
|
||||
if inherits is not None:
|
||||
data["inherits"] = inherits
|
||||
if filament_id is not None:
|
||||
data["filament_id"] = filament_id
|
||||
data["instantiation"] = "true" if instantiation else "false"
|
||||
if compatible_printers is not None:
|
||||
data["compatible_printers"] = compatible_printers
|
||||
return data
|
||||
|
||||
|
||||
class SyntheticTree:
|
||||
"""A throwaway resources/profiles-shaped directory plus ledger paths."""
|
||||
|
||||
def __init__(self):
|
||||
self.dir = tempfile.mkdtemp(prefix="filament_id_test_")
|
||||
self.profiles = os.path.join(self.dir, "profiles")
|
||||
os.makedirs(self.profiles)
|
||||
self.snapshot = os.path.join(self.dir, "filament_id_snapshot.json")
|
||||
self.retired = os.path.join(self.dir, "retired_filament_ids.json")
|
||||
|
||||
def cleanup(self):
|
||||
shutil.rmtree(self.dir, ignore_errors=True)
|
||||
|
||||
def preset_path(self, vendor, name):
|
||||
return os.path.join(self.profiles, vendor, "filament", name + ".json")
|
||||
|
||||
def add_vendor(self, vendor, presets):
|
||||
vendor_dir = os.path.join(self.profiles, vendor, "filament")
|
||||
os.makedirs(vendor_dir, exist_ok=True)
|
||||
index = {"name": vendor, "version": "01.00.00.00", "filament_list": []}
|
||||
for data in presets:
|
||||
fname = data["name"] + ".json"
|
||||
with open(os.path.join(vendor_dir, fname), "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
index["filament_list"].append(
|
||||
{"name": data["name"], "sub_path": f"filament/{fname}"})
|
||||
with open(os.path.join(self.profiles, vendor + ".json"), "w",
|
||||
encoding="utf-8") as f:
|
||||
json.dump(index, f, indent=4, ensure_ascii=False)
|
||||
|
||||
def add_to_index(self, vendor, name):
|
||||
idx_path = os.path.join(self.profiles, vendor + ".json")
|
||||
with open(idx_path, encoding="utf-8") as f:
|
||||
index = json.load(f)
|
||||
index["filament_list"].append(
|
||||
{"name": name, "sub_path": f"filament/{name}.json"})
|
||||
with open(idx_path, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, indent=4, ensure_ascii=False)
|
||||
|
||||
def write_preset(self, vendor, data, register=True):
|
||||
path = self.preset_path(vendor, data["name"])
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
if register:
|
||||
self.add_to_index(vendor, data["name"])
|
||||
|
||||
def remove_preset(self, vendor, name):
|
||||
os.remove(self.preset_path(vendor, name))
|
||||
idx_path = os.path.join(self.profiles, vendor + ".json")
|
||||
with open(idx_path, encoding="utf-8") as f:
|
||||
index = json.load(f)
|
||||
index["filament_list"] = [
|
||||
e for e in index["filament_list"] if e["name"] != name]
|
||||
with open(idx_path, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, indent=4, ensure_ascii=False)
|
||||
|
||||
# -- pipeline wrappers ---------------------------------------------------
|
||||
|
||||
def update_snapshot(self, allow_shared_catalog=False):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = afi.update_snapshot(self.profiles, self.snapshot, self.retired,
|
||||
allow_shared_catalog=allow_shared_catalog)
|
||||
return rc, buf.getvalue()
|
||||
|
||||
def check(self):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
errors = afi.check_filament_ids(self.profiles, self.snapshot, self.retired)
|
||||
return errors, buf.getvalue()
|
||||
|
||||
def assign(self):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
changed, errors = afi.assign_missing_ids(self.profiles, self.snapshot,
|
||||
self.retired)
|
||||
return changed, errors, buf.getvalue()
|
||||
|
||||
|
||||
def make_clean_tree():
|
||||
"""Baseline tree: OFL base+generic, a vendor family, a clean tuned generic."""
|
||||
t = SyntheticTree()
|
||||
t.add_vendor(OFL, [
|
||||
preset("fdm_pla", filament_id="OGFL99", instantiation=False),
|
||||
preset("Generic PLA @System", inherits="fdm_pla",
|
||||
compatible_printers=[]),
|
||||
])
|
||||
t.add_vendor("VendorA", [
|
||||
preset("APLA @base", filament_id="AX01", instantiation=False),
|
||||
preset("APLA @P1", inherits="APLA @base",
|
||||
compatible_printers=["P1 0.4 nozzle"]),
|
||||
# Correctly tuned OFL generic: keeps the OFL base name, claims a printer.
|
||||
preset("Generic PLA @P1", inherits="Generic PLA @System",
|
||||
compatible_printers=["P1 0.4 nozzle"]),
|
||||
])
|
||||
rc, _out = t.update_snapshot()
|
||||
assert rc == 0
|
||||
return t
|
||||
|
||||
|
||||
class SyntheticTreeCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.t = make_clean_tree()
|
||||
self.addCleanup(self.t.cleanup)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMint(unittest.TestCase):
|
||||
def test_namespace_literal(self):
|
||||
# Frozen: derived from the setting_id namespace; baked into the ledger.
|
||||
self.assertEqual(afi.FILAMENT_ID_NAMESPACE,
|
||||
uuid.UUID("c4d3ff49-4c32-5534-a3e3-00894157ab97"))
|
||||
|
||||
def test_known_vector(self):
|
||||
# Hardcoded, independently computed vector: freezes prefix, input string
|
||||
# layout ("filament_family/<vendor>/<family>") and base62 derivation.
|
||||
self.assertEqual(afi.generate_filament_id("Qidi", "HATCHBOX PLA"), "OFiJYDPC")
|
||||
|
||||
def test_determinism_and_format(self):
|
||||
for vendor, family in [("Qidi", "HATCHBOX PLA"), ("Creality", "CR PLA"),
|
||||
("OrcaFilamentLibrary", "Generic ABS"),
|
||||
("BBL", "拓竹 PLA")]: # unicode family
|
||||
a = afi.generate_filament_id(vendor, family)
|
||||
b = afi.generate_filament_id(vendor, family)
|
||||
self.assertEqual(a, b)
|
||||
self.assertRegex(a, r"^OF[0-9A-Za-z]{6}$")
|
||||
self.assertEqual(len(a), 8)
|
||||
|
||||
def test_salt_changes_id(self):
|
||||
base = afi.generate_filament_id("Qidi", "HATCHBOX PLA")
|
||||
salted = afi.generate_filament_id("Qidi", "HATCHBOX PLA", salt=1)
|
||||
self.assertNotEqual(base, salted)
|
||||
self.assertRegex(salted, r"^OF[0-9A-Za-z]{6}$")
|
||||
|
||||
def test_mint_salt_iteration(self):
|
||||
v, fam = "Qidi", "HATCHBOX PLA"
|
||||
taken = {afi.generate_filament_id(v, fam, s) for s in range(2)}
|
||||
self.assertEqual(afi.mint_filament_id(v, fam, taken),
|
||||
afi.generate_filament_id(v, fam, salt=2))
|
||||
self.assertEqual(afi.mint_filament_id(v, fam, set()),
|
||||
afi.generate_filament_id(v, fam))
|
||||
|
||||
|
||||
class TestBaseName(unittest.TestCase):
|
||||
def test_family_derivation(self):
|
||||
cases = [
|
||||
("X @base", "X"),
|
||||
("Afinia PLA@HS", "Afinia PLA"),
|
||||
("PolyTerra PLA", "PolyTerra PLA"),
|
||||
("HATCHBOX PLA @Qidi X-Plus 4 0.6 nozzle", "HATCHBOX PLA"),
|
||||
("A @B @C", "A"), # first @ wins
|
||||
("Filár PLA 拓竹 @0.4 nozzle", "Filár PLA 拓竹"),
|
||||
]
|
||||
for name, family in cases:
|
||||
self.assertEqual(afi.base_name(name), family, msg=name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolver (loader-faithful semantics)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolver(unittest.TestCase):
|
||||
@staticmethod
|
||||
def rec(name, filament_id=None, inherits=None):
|
||||
return {"name": name, "filament_id": filament_id, "inherits": inherits}
|
||||
|
||||
def resolve(self, name, vendor_recs, ofl_recs, **kw):
|
||||
fmap = {r["name"]: r for r in vendor_recs}
|
||||
omap = {r["name"]: r for r in ofl_recs}
|
||||
return afi.resolve_filament_id(name, fmap, omap, **kw)
|
||||
|
||||
def test_own_id(self):
|
||||
fid, src, entry = self.resolve("A", [self.rec("A", "ID1")], [])
|
||||
self.assertEqual((fid, src, entry), ("ID1", "own", None))
|
||||
|
||||
def test_inherited_within_vendor(self):
|
||||
fid, src, entry = self.resolve(
|
||||
"A", [self.rec("A", inherits="B"), self.rec("B", inherits="C"),
|
||||
self.rec("C", "ID3")], [])
|
||||
self.assertEqual((fid, src, entry), ("ID3", "inherited", None))
|
||||
|
||||
def test_ofl_fallback(self):
|
||||
# Vendor preset inherits a name that only exists in the OFL map.
|
||||
fid, src, entry = self.resolve(
|
||||
"A", [self.rec("A", inherits="Generic PLA @System")],
|
||||
[self.rec("Generic PLA @System", inherits="fdm_pla"),
|
||||
self.rec("fdm_pla", "OGFL99")])
|
||||
self.assertEqual(fid, "OGFL99")
|
||||
self.assertEqual(entry, "Generic PLA @System")
|
||||
|
||||
def test_ofl_stays_in_ofl(self):
|
||||
# Once a chain enters OFL it stays there: a vendor file sharing an
|
||||
# OFL-internal hop's name must not shadow it.
|
||||
fid, _src, entry = self.resolve(
|
||||
"A",
|
||||
[self.rec("A", inherits="ofl_entry"), self.rec("fdm_pla", "WRONG")],
|
||||
[self.rec("ofl_entry", inherits="fdm_pla"), self.rec("fdm_pla", "RIGHT")])
|
||||
self.assertEqual(fid, "RIGHT")
|
||||
self.assertEqual(entry, "ofl_entry")
|
||||
|
||||
def test_dead_end_retries_parent_in_ofl(self):
|
||||
# The vendor chain dead-ends id-less on a parent that also exists in
|
||||
# OFL: the loader re-consults the OFL map for that direct parent.
|
||||
fid, _src, entry = self.resolve(
|
||||
"A", [self.rec("A", inherits="shared"), self.rec("shared")],
|
||||
[self.rec("shared", "OFLID1")])
|
||||
self.assertEqual(fid, "OFLID1")
|
||||
self.assertEqual(entry, "shared")
|
||||
|
||||
def test_cycle(self):
|
||||
fid, src, _e = self.resolve(
|
||||
"A", [self.rec("A", inherits="B"), self.rec("B", inherits="A")], [])
|
||||
self.assertEqual((fid, src), (None, "cycle"))
|
||||
|
||||
def test_dangling_parent(self):
|
||||
fid, src, _e = self.resolve("A", [self.rec("A", inherits="nope")], [])
|
||||
self.assertEqual((fid, src), (None, "dangling"))
|
||||
|
||||
def test_missing_id(self):
|
||||
fid, src, _e = self.resolve("A", [self.rec("A")], [])
|
||||
self.assertEqual((fid, src), (None, "missing"))
|
||||
|
||||
def test_skip_own_resolves_inherited(self):
|
||||
fid, _src, _e = self.resolve(
|
||||
"A", [self.rec("A", "OWN", inherits="B"), self.rec("B", "PARENT")], [],
|
||||
skip_own=True)
|
||||
self.assertEqual(fid, "PARENT")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reserved namespaces
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReservedSpaces(unittest.TestCase):
|
||||
def test_owners(self):
|
||||
self.assertEqual(afi.reserved_space_owner("GFL99"), (True, "BBL"))
|
||||
self.assertEqual(afi.reserved_space_owner("QD_X4_PLA"), (True, "Qidi"))
|
||||
self.assertEqual(afi.reserved_space_owner("P1234abc"), (True, None))
|
||||
self.assertEqual(afi.reserved_space_owner("pAbCdEf1"), (True, None)) # case-insensitive
|
||||
self.assertEqual(afi.reserved_space_owner("null"), (True, None))
|
||||
self.assertEqual(afi.reserved_space_owner("OFiJYDPC"), (False, None))
|
||||
self.assertEqual(afi.reserved_space_owner("P1234abcd"), (False, None)) # 8 hex chars: not the user space
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# checks on synthetic trees
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestChecks(SyntheticTreeCase):
|
||||
def test_clean_tree_is_silent(self):
|
||||
errors, out = self.t.check()
|
||||
self.assertEqual(errors, 0, out)
|
||||
self.assertNotIn("[ERROR]", out)
|
||||
|
||||
def test_check1_unknown_non_of_id(self):
|
||||
self.t.write_preset("VendorA", preset("BPLA @base", filament_id="BOGUS_9",
|
||||
instantiation=False))
|
||||
self.t.write_preset("VendorA", preset("BPLA @P1", inherits="BPLA @base",
|
||||
compatible_printers=["P1"]))
|
||||
errors, out = self.t.check()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("neither grandfathered in the snapshot", out)
|
||||
self.assertIn("BOGUS_9", out)
|
||||
|
||||
def test_check2_new_claim_needs_snapshot_update(self):
|
||||
self.t.write_preset("VendorA", preset("ANEW @P2", inherits="APLA @base",
|
||||
compatible_printers=["P2"]))
|
||||
errors, out = self.t.check()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn('claim "VendorA/ANEW" is not sanctioned', out)
|
||||
self.assertIn("--update-snapshot", out)
|
||||
|
||||
def test_check2_vanished_claim_is_stability_error(self):
|
||||
self.t.remove_preset("VendorA", "APLA @P1")
|
||||
errors, out = self.t.check()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("stability", out)
|
||||
self.assertIn('"VendorA/APLA"', out)
|
||||
|
||||
def test_check3_of_id_must_match_mint(self):
|
||||
self.t.write_preset("VendorA", preset("BNEW @base", filament_id="OFZZZZZZ",
|
||||
instantiation=False))
|
||||
self.t.write_preset("VendorA", preset("BNEW @P1", inherits="BNEW @base",
|
||||
compatible_printers=["P1"]))
|
||||
errors, out = self.t.check()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("does not match its mint", out)
|
||||
self.assertIn(afi.generate_filament_id("VendorA", "BNEW"), out)
|
||||
|
||||
def test_check3_salted_mint_is_accepted(self):
|
||||
salted = afi.generate_filament_id("VendorA", "BNEW", salt=3)
|
||||
self.t.write_preset("VendorA", preset("BNEW @base", filament_id=salted,
|
||||
instantiation=False))
|
||||
self.t.write_preset("VendorA", preset("BNEW @P1", inherits="BNEW @base",
|
||||
compatible_printers=["P1"]))
|
||||
_errors, out = self.t.check() # check 2 still wants a snapshot update
|
||||
self.assertNotIn("does not match its mint", out)
|
||||
|
||||
def test_check4_retired_id_reuse(self):
|
||||
afi.write_ledger(self.t.retired, {"retired": {"AX01": ["VendorA/APLA"]}})
|
||||
errors, out = self.t.check()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("retired", out)
|
||||
self.assertIn("AX01", out)
|
||||
|
||||
def test_check5_renamed_tuned_generic(self):
|
||||
self.t.write_preset("VendorA", preset("Tuned PLA @P1",
|
||||
inherits="Generic PLA @System",
|
||||
compatible_printers=["P1"]))
|
||||
errors, out = self.t.check()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("rename re-exposes the OFL preset", out)
|
||||
self.assertIn("Tuned PLA @P1", out)
|
||||
|
||||
def test_check5_empty_compatible_printers(self):
|
||||
self.t.write_preset("VendorA", preset("Generic PLA @P2",
|
||||
inherits="Generic PLA @System",
|
||||
compatible_printers=[]))
|
||||
errors, out = self.t.check()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("cannot shadow the OFL preset", out)
|
||||
|
||||
def test_check5_exception_is_grandfathered(self):
|
||||
self.t.write_preset("VendorA", preset("Tuned PLA @P1",
|
||||
inherits="Generic PLA @System",
|
||||
compatible_printers=["P1"]))
|
||||
rc, _out = self.t.update_snapshot()
|
||||
self.assertEqual(rc, 0)
|
||||
errors, out = self.t.check()
|
||||
self.assertEqual(errors, 0, out)
|
||||
|
||||
def test_check6_reserved_namespace_claims(self):
|
||||
for fid, marker in [("GFX99", "owned by BBL"),
|
||||
("QD_X_PLA", "owned by Qidi"),
|
||||
("P1a2b3c4", "user-custom"),
|
||||
("null", "user-custom")]:
|
||||
with self.subTest(fid=fid):
|
||||
name = f"R{fid} @base"
|
||||
self.t.write_preset("VendorA", preset(name, filament_id=fid,
|
||||
instantiation=False))
|
||||
self.t.write_preset("VendorA", preset(f"R{fid} @P1", inherits=name,
|
||||
compatible_printers=["P1"]))
|
||||
errors, out = self.t.check()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("reserved id space", out)
|
||||
self.assertIn(marker, out)
|
||||
|
||||
def test_check7a_instantiated_preset_with_own_key(self):
|
||||
self.t.write_preset("VendorA", preset("APLA @P1", filament_id="AX01",
|
||||
inherits="APLA @base",
|
||||
compatible_printers=["P1 0.4 nozzle"]),
|
||||
register=False)
|
||||
errors, out = self.t.check()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("declares its own filament_id key", out)
|
||||
|
||||
def test_check7b_declared_vs_inherited_drift(self):
|
||||
self.t.write_preset("VendorA", preset("APLA @P1", filament_id="AX02",
|
||||
inherits="APLA @base",
|
||||
compatible_printers=["P1 0.4 nozzle"]),
|
||||
register=False)
|
||||
errors, out = self.t.check()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn('declares filament_id "AX02" but its inherits chain resolves "AX01"', out)
|
||||
|
||||
def test_check7c_unresolvable_instantiated_filament(self):
|
||||
self.t.write_preset("VendorA", preset("DNEW @P1", compatible_printers=["P1"]))
|
||||
errors, out = self.t.check()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("resolves no filament_id", out)
|
||||
self.assertIn("hard load error", out)
|
||||
|
||||
def test_missing_snapshot_is_an_error(self):
|
||||
os.remove(self.t.snapshot)
|
||||
errors, out = self.t.check()
|
||||
self.assertEqual(errors, 1)
|
||||
self.assertIn("snapshot not found", out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --update-snapshot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUpdateSnapshot(SyntheticTreeCase):
|
||||
def test_idempotent_and_deterministic(self):
|
||||
with open(self.t.snapshot, "rb") as f:
|
||||
first = f.read()
|
||||
rc, out = self.t.update_snapshot()
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("nothing changed", out)
|
||||
with open(self.t.snapshot, "rb") as f:
|
||||
self.assertEqual(f.read(), first)
|
||||
self.assertTrue(first.endswith(b"\n"))
|
||||
self.assertNotIn(b"\r", first)
|
||||
snap = json.loads(first.decode("utf-8"))
|
||||
self.assertEqual(list(snap["ids"]), sorted(snap["ids"]))
|
||||
self.assertEqual(snap["ids"]["AX01"], ["VendorA/APLA"])
|
||||
self.assertEqual(snap["ids"]["OGFL99"],
|
||||
["OrcaFilamentLibrary/Generic PLA", "VendorA/Generic PLA"])
|
||||
|
||||
def test_vanished_id_is_retired(self):
|
||||
self.t.remove_preset("VendorA", "APLA @base")
|
||||
self.t.remove_preset("VendorA", "APLA @P1")
|
||||
rc, out = self.t.update_snapshot()
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("AX01", out)
|
||||
retired = load_json_file(self.t.retired)
|
||||
self.assertEqual(retired["retired"], {"AX01": ["VendorA/APLA"]})
|
||||
snap = load_json_file(self.t.snapshot)
|
||||
self.assertNotIn("AX01", snap["ids"])
|
||||
# append-only + idempotent: a second run keeps the ledger intact
|
||||
rc, out = self.t.update_snapshot()
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("nothing changed", out)
|
||||
retired2 = load_json_file(self.t.retired)
|
||||
self.assertEqual(retired, retired2)
|
||||
# ... and a retired id may never be minted again
|
||||
self.assertNotIn(afi.mint_filament_id("VendorA", "APLA",
|
||||
set(retired["retired"])), retired["retired"])
|
||||
|
||||
def test_refuses_new_reserved_namespace_claims(self):
|
||||
self.t.write_preset("VendorA", preset("CNEW @base", filament_id="GFX99",
|
||||
instantiation=False))
|
||||
self.t.write_preset("VendorA", preset("CNEW @P1", inherits="CNEW @base",
|
||||
compatible_printers=["P1"]))
|
||||
with open(self.t.snapshot, "rb") as f:
|
||||
before = f.read()
|
||||
rc, out = self.t.update_snapshot()
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("refusing to sanction", out)
|
||||
with open(self.t.snapshot, "rb") as f:
|
||||
self.assertEqual(f.read(), before) # nothing written on refusal
|
||||
rc, _out = self.t.update_snapshot(allow_shared_catalog=True)
|
||||
self.assertEqual(rc, 0)
|
||||
snap = load_json_file(self.t.snapshot)
|
||||
self.assertEqual(snap["ids"]["GFX99"], ["VendorA/CNEW"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# default run: mint + insert
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAssign(SyntheticTreeCase):
|
||||
def test_noop_on_fully_idded_tree(self):
|
||||
changed, errors, out = self.t.assign()
|
||||
self.assertEqual((changed, errors), (0, 0))
|
||||
self.assertIn("nothing to do (0 files changed)", out)
|
||||
|
||||
def test_mints_into_family_root_and_rootless_member(self):
|
||||
self.t.write_preset("VendorA", preset("FNEW @base", instantiation=False))
|
||||
self.t.write_preset("VendorA", preset("FNEW @P1", inherits="FNEW @base",
|
||||
compatible_printers=["P1"]))
|
||||
self.t.write_preset("VendorA", preset("FNEW @P2", inherits="FNEW @base",
|
||||
compatible_printers=["P2"]))
|
||||
self.t.write_preset("VendorA", preset("GNEW @P1", compatible_printers=["P1"]))
|
||||
changed, errors, _out = self.t.assign()
|
||||
self.assertEqual(errors, 0)
|
||||
self.assertEqual(changed, 2) # one root + one root-less member
|
||||
root = load_json_file(self.t.preset_path("VendorA", "FNEW @base"))
|
||||
self.assertEqual(root["filament_id"],
|
||||
afi.generate_filament_id("VendorA", "FNEW"))
|
||||
member = load_json_file(self.t.preset_path("VendorA", "GNEW @P1"))
|
||||
self.assertEqual(member["filament_id"],
|
||||
afi.generate_filament_id("VendorA", "GNEW"))
|
||||
# variants themselves never get the key
|
||||
child = load_json_file(self.t.preset_path("VendorA", "FNEW @P1"))
|
||||
self.assertNotIn("filament_id", child)
|
||||
# idempotent: second run is a no-op
|
||||
changed, errors, out = self.t.assign()
|
||||
self.assertEqual((changed, errors), (0, 0))
|
||||
self.assertIn("nothing to do", out)
|
||||
|
||||
def test_shared_root_between_families_is_refused(self):
|
||||
self.t.write_preset("VendorA", preset("shared_base", instantiation=False))
|
||||
self.t.write_preset("VendorA", preset("HNEW @P1", inherits="shared_base",
|
||||
compatible_printers=["P1"]))
|
||||
self.t.write_preset("VendorA", preset("INEW @P1", inherits="shared_base",
|
||||
compatible_printers=["P1"]))
|
||||
changed, errors, out = self.t.assign()
|
||||
self.assertEqual(changed, 0)
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn("shared with famil", out)
|
||||
|
||||
|
||||
class TestInsertEditing(unittest.TestCase):
|
||||
CRLF_TEXT = (
|
||||
'{\r\n'
|
||||
'\t"type": "filament",\r\n'
|
||||
'\t"name": "JNEW @base",\r\n'
|
||||
'\t"inherits": "fdm_pla",\r\n'
|
||||
'\t"from": "system",\r\n'
|
||||
'\t"instantiation": "false",\r\n'
|
||||
'\t"filament_type": [\r\n'
|
||||
'\t\t"PLA"\r\n'
|
||||
'\t]\r\n'
|
||||
'}\r\n'
|
||||
)
|
||||
|
||||
def test_insert_before_instantiation_preserves_bytes(self):
|
||||
text, n = afi.insert_filament_id(self.CRLF_TEXT, "OFabc123")
|
||||
self.assertEqual(n, 1)
|
||||
json.loads(text)
|
||||
inserted = '\t"filament_id": "OFabc123",\r\n'
|
||||
self.assertIn(inserted + '\t"instantiation"', text)
|
||||
# every original byte is preserved: removing the inserted line restores
|
||||
# the input exactly (CRLF stays CRLF, tabs stay tabs)
|
||||
self.assertEqual(text.replace(inserted, "", 1), self.CRLF_TEXT)
|
||||
|
||||
def test_insert_after_name_when_no_instantiation_line(self):
|
||||
lf_text = '{\n "type": "filament",\n "name": "K",\n "from": "system"\n}\n'
|
||||
text, n = afi.insert_filament_id(lf_text, "OFabc123")
|
||||
self.assertEqual(n, 1)
|
||||
json.loads(text)
|
||||
self.assertIn('"name": "K",\n "filament_id": "OFabc123",\n', text)
|
||||
self.assertNotIn("\r", text)
|
||||
|
||||
def test_insert_no_anchor_fails(self):
|
||||
_text, n = afi.insert_filament_id('{"type": "filament"}', "OFabc123")
|
||||
self.assertEqual(n, 0)
|
||||
|
||||
def test_write_filament_id_keeps_crlf_on_disk(self):
|
||||
t = SyntheticTree()
|
||||
self.addCleanup(t.cleanup)
|
||||
t.add_vendor("VendorA", [])
|
||||
path = t.preset_path("VendorA", "JNEW @base")
|
||||
with open(path, "wb") as f:
|
||||
f.write(self.CRLF_TEXT.encode("utf-8"))
|
||||
t.add_to_index("VendorA", "JNEW @base")
|
||||
afi.write_filament_id(path, "OFabc123")
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
self.assertEqual(raw.count(b"\n"), raw.count(b"\r\n")) # still CRLF-only
|
||||
self.assertEqual(
|
||||
raw.replace(b'\t"filament_id": "OFabc123",\r\n', b"", 1),
|
||||
self.CRLF_TEXT.encode("utf-8"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the real tree
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@unittest.skipUnless(os.path.isdir(REAL_PROFILES), "resources/profiles not present")
|
||||
class TestRealTree(unittest.TestCase):
|
||||
def test_shipped_snapshot_matches_tree(self):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
errors = afi.check_filament_ids(REAL_PROFILES)
|
||||
self.assertEqual(errors, 0, buf.getvalue())
|
||||
|
||||
def test_every_instantiated_filament_resolves_an_id(self):
|
||||
analysis = afi.analyze_tree(REAL_PROFILES)
|
||||
self.assertEqual(analysis["missing_effective"], [])
|
||||
self.assertEqual(analysis["read_errors"], [])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# review-fix regressions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReviewFixes(SyntheticTreeCase):
|
||||
def test_update_snapshot_refuses_retired_id_reuse(self):
|
||||
fid = afi.generate_filament_id("VendorA", "LONER")
|
||||
self.t.write_preset("VendorA", preset("LONER @base", filament_id=fid,
|
||||
instantiation=False))
|
||||
self.t.write_preset("VendorA", preset("LONER @P1", inherits="LONER @base",
|
||||
compatible_printers=["P1 0.4 nozzle"]))
|
||||
rc, _ = self.t.update_snapshot()
|
||||
self.assertEqual(rc, 0)
|
||||
self.t.remove_preset("VendorA", "LONER @base")
|
||||
self.t.remove_preset("VendorA", "LONER @P1")
|
||||
rc, out = self.t.update_snapshot()
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn(fid, afi.load_retired(self.t.retired))
|
||||
# Resurrect the same id: --update-snapshot must refuse, not sanction.
|
||||
self.t.write_preset("VendorA", preset("LONER @base", filament_id=fid,
|
||||
instantiation=False))
|
||||
self.t.write_preset("VendorA", preset("LONER @P1", inherits="LONER @base",
|
||||
compatible_printers=["P1 0.4 nozzle"]))
|
||||
rc, out = self.t.update_snapshot()
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("retired", out)
|
||||
self.assertNotIn(fid, load_json_file(self.t.snapshot)["ids"])
|
||||
|
||||
def test_check3_skips_of_id_inherited_from_other_vendor(self):
|
||||
# Post-Phase-1 world: an OFL generic carries its own minted OF id and a
|
||||
# vendor tunes it correctly (same base name, non-empty printers). The
|
||||
# new claim must trip only the snapshot gate, never mint conformance.
|
||||
fid = afi.generate_filament_id(OFL, "Generic PLA Matte")
|
||||
self.t.write_preset(OFL, preset("Generic PLA Matte @base", filament_id=fid,
|
||||
instantiation=False))
|
||||
self.t.write_preset(OFL, preset("Generic PLA Matte @System",
|
||||
inherits="Generic PLA Matte @base",
|
||||
compatible_printers=[]))
|
||||
rc, _ = self.t.update_snapshot()
|
||||
self.assertEqual(rc, 0)
|
||||
self.t.write_preset("VendorA", preset("Generic PLA Matte @P1",
|
||||
inherits="Generic PLA Matte @System",
|
||||
compatible_printers=["P1 0.4 nozzle"]))
|
||||
errors, out = self.t.check()
|
||||
self.assertNotIn("does not match its mint", out)
|
||||
self.assertIn("not sanctioned", out)
|
||||
self.assertEqual(errors, 1, out)
|
||||
# After sanctioning the claim the tree is fully green again.
|
||||
rc, _ = self.t.update_snapshot()
|
||||
self.assertEqual(rc, 0)
|
||||
errors, out = self.t.check()
|
||||
self.assertEqual(errors, 0, out)
|
||||
|
||||
def test_check7c_prints_expected_mint(self):
|
||||
self.t.write_preset("VendorA", preset("Orphan PLA @P1",
|
||||
compatible_printers=["P1 0.4 nozzle"]))
|
||||
errors, out = self.t.check()
|
||||
self.assertGreater(errors, 0)
|
||||
self.assertIn(afi.generate_filament_id("VendorA", "Orphan PLA"), out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3109,6 +3109,24 @@ void PresetBundle::update_num_filaments(unsigned int to_del_flament_id)
|
||||
}
|
||||
|
||||
|
||||
// Orca: the AMS lookups below resolve a tray's filament_id to the FIRST compatible base
|
||||
// preset. When several presets match the same id for the selected printer the pick is
|
||||
// arbitrary (a profile bug - see the validator's check_duplicate_filament_subtypes), so
|
||||
// scan past a successful match and warn about the runners-up. Behavior is unchanged.
|
||||
static void warn_ambiguous_filament_id_match(const PresetCollection &filaments, PresetCollection::ConstIterator match, const std::string &filament_id)
|
||||
{
|
||||
if (match == filaments.end())
|
||||
return;
|
||||
std::string others;
|
||||
for (auto it = std::next(match); it != filaments.end(); ++it)
|
||||
if (it->is_compatible && filaments.get_preset_base(*it) == &*it && it->filament_id == filament_id)
|
||||
others += (others.empty() ? "\"" : ", \"") + it->name + "\"";
|
||||
if (!others.empty())
|
||||
BOOST_LOG_TRIVIAL(warning) << "Ambiguous AMS filament match: filament_id \"" << filament_id
|
||||
<< "\" matches multiple presets compatible with the selected printer; picked \"" << match->name
|
||||
<< "\", also matches " << others;
|
||||
}
|
||||
|
||||
void PresetBundle::get_ams_cobox_infos(AMSComboInfo& combox_info)
|
||||
{
|
||||
combox_info.clear();
|
||||
@@ -3131,6 +3149,7 @@ void PresetBundle::get_ams_cobox_infos(AMSComboInfo& combox_info)
|
||||
}
|
||||
auto iter = std::find_if(filaments.begin(), filaments.end(),
|
||||
[this, &filament_id](auto &f) { return f.is_compatible && filaments.get_preset_base(f) == &f && f.filament_id == filament_id; });
|
||||
warn_ambiguous_filament_id_match(filaments, iter, filament_id);
|
||||
if (iter == filaments.end()) {
|
||||
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(": filament_id %1% not found or system or compatible") % filament_id;
|
||||
auto filament_type = ams.opt_string("filament_type", 0u);
|
||||
@@ -3233,6 +3252,7 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
|
||||
auto iter = std::find_if(filaments.begin(), filaments.end(), [this, &filament_id, &has_type, filament_type](auto &f) {
|
||||
has_type |= f.config.opt_string("filament_type", 0u) == filament_type;
|
||||
return f.is_compatible && filaments.get_preset_base(f) == &f && f.filament_id == filament_id; });
|
||||
warn_ambiguous_filament_id_match(filaments, iter, filament_id);
|
||||
if (iter == filaments.end()) {
|
||||
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": filament_id %1% not found or system or compatible") % filament_id;
|
||||
if (!filament_type.empty()) {
|
||||
@@ -5657,7 +5677,11 @@ bool PresetBundle::check_duplicate_filament_subtypes() const
|
||||
// inherited from its @base at load time), grouped by vendor so we only test a
|
||||
// printer against its own vendor's filaments. A vendor's compatible_printers
|
||||
// only names that vendor's printers, so same-vendor scoping is correctness
|
||||
// preserving and avoids an O(all printers x all filaments) sweep.
|
||||
// preserving and avoids an O(all printers x all filaments) sweep. The one
|
||||
// exception is the Orca Filament Library: its presets have empty
|
||||
// compatible_printers (= compatible with every printer, minus the alias-shadowing
|
||||
// exclusions that is_compatible_with_printer checks via m_excluded_from), so they
|
||||
// are tested against every vendor's printers as well.
|
||||
std::map<std::string, std::vector<const Preset *>> filaments_by_vendor;
|
||||
for (const auto &preset : filaments) {
|
||||
if (!preset.is_system || preset.filament_id.empty() || preset.vendor == nullptr)
|
||||
@@ -5665,20 +5689,29 @@ bool PresetBundle::check_duplicate_filament_subtypes() const
|
||||
filaments_by_vendor[preset.vendor->name].push_back(&preset);
|
||||
}
|
||||
|
||||
const std::vector<const Preset *> no_filaments;
|
||||
const auto library_it = filaments_by_vendor.find(ORCA_FILAMENT_LIBRARY);
|
||||
const std::vector<const Preset *> &library_filaments = library_it == filaments_by_vendor.end() ? no_filaments : library_it->second;
|
||||
|
||||
bool found_duplicates = false;
|
||||
for (const auto &printer : printers) {
|
||||
if (!printer.is_system || printer.vendor == nullptr)
|
||||
continue;
|
||||
auto vendor_it = filaments_by_vendor.find(printer.vendor->name);
|
||||
if (vendor_it == filaments_by_vendor.end())
|
||||
const std::vector<const Preset *> &vendor_filaments = vendor_it == filaments_by_vendor.end() ? no_filaments : vendor_it->second;
|
||||
if (vendor_filaments.empty() && library_filaments.empty())
|
||||
continue;
|
||||
|
||||
const PresetWithVendorProfile active_printer = printers.get_preset_with_vendor_profile(printer);
|
||||
// std::map keeps the reported errors in a deterministic (sorted) order.
|
||||
std::map<std::string, std::vector<const Preset *>> by_filament_id;
|
||||
for (const Preset *fil : vendor_it->second)
|
||||
for (const Preset *fil : vendor_filaments)
|
||||
if (is_compatible_with_printer(filaments.get_preset_with_vendor_profile(*fil), active_printer))
|
||||
by_filament_id[fil->filament_id].push_back(fil);
|
||||
if (&vendor_filaments != &library_filaments)
|
||||
for (const Preset *fil : library_filaments)
|
||||
if (is_compatible_with_printer(filaments.get_preset_with_vendor_profile(*fil), active_printer))
|
||||
by_filament_id[fil->filament_id].push_back(fil);
|
||||
|
||||
for (const auto &entry : by_filament_id) {
|
||||
if (entry.second.size() < 2)
|
||||
@@ -5686,9 +5719,15 @@ bool PresetBundle::check_duplicate_filament_subtypes() const
|
||||
found_duplicates = true;
|
||||
// List each conflicting preset with a clickable file:// URI on its own
|
||||
// line, so the profile author can jump straight to the files to fix.
|
||||
// A preset from another bundle (the Orca Filament Library) is tagged with
|
||||
// its vendor so the source bundle is obvious.
|
||||
std::string presets;
|
||||
for (const Preset *p : entry.second)
|
||||
presets += "\n - " + p->name + "\n " + preset_file_uri(p->file);
|
||||
for (const Preset *p : entry.second) {
|
||||
presets += "\n - " + p->name;
|
||||
if (p->vendor != nullptr && p->vendor->name != printer.vendor->name)
|
||||
presets += " [" + p->vendor->name + "]";
|
||||
presets += "\n " + preset_file_uri(p->file);
|
||||
}
|
||||
BOOST_LOG_TRIVIAL(error)
|
||||
<< "Ambiguous AMS filament match: " << entry.second.size()
|
||||
<< " filament presets share filament_id \"" << entry.first
|
||||
|
||||
Reference in New Issue
Block a user