profiles: deterministic setting_id from vendor/type/name (#14432)
* profiles: enforce globally-unique, per-vendor-namespaced setting_id Many non-Bambu vendors copied Bambu's generic setting_ids (GFSA04 alone appeared in 1557 files), so setting_id was not globally unique. This namespaces every vendor's ids and reserves Bambu/OrcaFilamentLibrary space. - Reserve "G*" (Bambu) and "O*" (OrcaFilamentLibrary) id spaces. - Assign each other vendor a 2-char prefix (first+last letter, collision resolved) and renumber every instantiated preset to <PREFIX><NNNN>. - Strip setting_id from base profiles (instantiation:false) per Bambu's convention; assign one to instantiated presets that lacked it. - Remove the pre-existing misspelled "settings_id" key (91 files). - filament_id is left untouched (it is a per-material id). - Add one-time migration script scripts/assign_vendor_setting_ids.py with a persisted registry resources/profiles/vendor_prefixes.json. Re-runs freeze existing ids; only new vendors/profiles get new ids. - Bump version in each changed vendor index file. - Extend scripts/orca_extra_profile_check.py with a CI guard: global uniqueness, in-namespace, no base setting_id, no gaps, no settings_id typo. 7425 profile files changed across 61 vendors; 0 cross-vendor collisions; validator clean; migration idempotent. BBL and OrcaFilamentLibrary id spaces untouched. * profiles: add setting_id authoring guide for new vendors / profiles * profiles: drop in-repo README; setting_id guide now lives in the wiki * profiles: derive setting_id deterministically from vendor/type/name * bump profile version
This commit is contained in:
258
scripts/assign_vendor_setting_ids.py
Normal file
258
scripts/assign_vendor_setting_ids.py
Normal file
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Assign deterministic, globally-unique setting_id to OrcaSlicer system profiles.
|
||||
|
||||
Policy (see AGENTS.md "Critical Constraints"):
|
||||
* A preset's setting_id is a pure function of its identity:
|
||||
setting_id = base62_16( uuid5(NAMESPACE, "<vendor>/<type>/<name>") )
|
||||
The same value is recomputed on the fly by the C++ app
|
||||
(Slic3r::generate_preset_setting_id); the two MUST stay byte-identical. The rule
|
||||
(generate_preset_setting_id, below) is also imported by the validator
|
||||
(orca_extra_profile_check.py). Uniqueness is therefore automatic: two presets
|
||||
collide only if they share vendor + type + name, which the validator flags.
|
||||
* Bambu (BBL) owns the authoritative "G*" id space and is the only reserved vendor:
|
||||
its ids are never rewritten (preserves backward-compat with Bambu-synced presets).
|
||||
Every other vendor - including OrcaFilamentLibrary and Custom - follows the
|
||||
deterministic rule.
|
||||
* Only instantiated presets (instantiation == "true") carry a setting_id; base /
|
||||
template profiles do not.
|
||||
|
||||
Only setting_id is rewritten. filament_id is deliberately left untouched: it is a
|
||||
per-material id, shared across a filament's nozzle variants and inherited from base
|
||||
templates, so it must not be made per-file unique.
|
||||
|
||||
Run from anywhere: python3 scripts/assign_vendor_setting_ids.py
|
||||
The script is idempotent: a second run over an unchanged tree produces no diff.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
|
||||
# Deterministic preset setting_id rule. Imported by the validator
|
||||
# (orca_extra_profile_check.py) and kept byte-identical to the C++
|
||||
# Slic3r::generate_preset_setting_id. Dedicated namespace, distinct from the cloud
|
||||
# namespace (f47ac10b-...) so the two id spaces never coincide; this constant is baked
|
||||
# into both languages - never change it.
|
||||
NAMESPACE = uuid.UUID("c1f4d9e2-7a3b-5c8d-9e0f-1a2b3c4d5e6f")
|
||||
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
ID_LENGTH = 16
|
||||
|
||||
|
||||
def generate_preset_setting_id(vendor, type_name, name):
|
||||
"""Deterministic 16-char base62 setting_id for a preset.
|
||||
|
||||
input = f"{vendor}/{type_name}/{name}"; u = uuid5(NAMESPACE, input);
|
||||
id = the low ID_LENGTH base62 digits of int(u.bytes, "big"), most-significant first.
|
||||
"""
|
||||
u = uuid.uuid5(NAMESPACE, f"{vendor}/{type_name}/{name}")
|
||||
n = int.from_bytes(u.bytes, "big")
|
||||
digits = []
|
||||
for _ in range(ID_LENGTH):
|
||||
digits.append(ALPHABET[n % 62])
|
||||
n //= 62
|
||||
return "".join(reversed(digits))
|
||||
|
||||
|
||||
PROFILES_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "resources", "profiles"))
|
||||
|
||||
# Bambu (BBL) is the only reserved vendor: it keeps its authoritative "G*" cloud ids.
|
||||
RESERVED_VENDORS = {"BBL"}
|
||||
|
||||
PROFILE_SUBDIRS = ("filament", "process", "machine")
|
||||
|
||||
|
||||
def iter_profile_files(vendor_dir):
|
||||
"""Yield (json path, type) under a vendor, in a deterministic order.
|
||||
|
||||
type is the subdir name ("filament"/"process"/"machine"), which matches
|
||||
Preset::get_type_string() on the C++ side.
|
||||
"""
|
||||
for sub in PROFILE_SUBDIRS:
|
||||
base = os.path.join(vendor_dir, sub)
|
||||
if not os.path.isdir(base):
|
||||
continue
|
||||
for root, dirs, files in os.walk(base):
|
||||
dirs.sort() # deterministic traversal across filesystems
|
||||
for name in sorted(files):
|
||||
if name.endswith(".json"):
|
||||
yield os.path.join(root, name), sub
|
||||
|
||||
|
||||
def read_profile(path):
|
||||
"""Return (setting_id, instantiation, name) as present (or None)."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
data = json.loads(f.read())
|
||||
except (ValueError, OSError):
|
||||
return None, None, None
|
||||
if not isinstance(data, dict):
|
||||
return None, None, None
|
||||
return data.get("setting_id"), data.get("instantiation"), data.get("name")
|
||||
|
||||
|
||||
def list_vendors():
|
||||
return sorted(
|
||||
d for d in os.listdir(PROFILES_DIR)
|
||||
if os.path.isdir(os.path.join(PROFILES_DIR, d))
|
||||
)
|
||||
|
||||
|
||||
_JSON_STR = r'"(?:[^"\\]|\\.)*"'
|
||||
|
||||
|
||||
def remove_key_line(text, key):
|
||||
"""Remove a top-level `"key": "..."` member, preserving formatting.
|
||||
|
||||
Handles both the common case (member has a trailing comma) and the member
|
||||
being the LAST in its object (consume the preceding comma instead, so no
|
||||
dangling comma is left). Returns (new_text, count).
|
||||
"""
|
||||
# Member followed by a comma (not the last in the object).
|
||||
trailing = re.compile(
|
||||
r'[ \t]*"' + re.escape(key) + r'"[ \t]*:[ \t]*' + _JSON_STR + r'[ \t]*,[ \t]*\r?\n'
|
||||
)
|
||||
new, n = trailing.subn("", text, count=1)
|
||||
if n:
|
||||
return new, n
|
||||
# Member is the last one: drop the preceding comma and the member itself.
|
||||
leading = re.compile(
|
||||
r',[ \t]*\r?\n[ \t]*"' + re.escape(key) + r'"[ \t]*:[ \t]*' + _JSON_STR
|
||||
)
|
||||
return leading.subn("", text, count=1)
|
||||
|
||||
|
||||
def _remove_key_in_tree(key, should_remove):
|
||||
"""Remove `key` from files where should_remove(sid, inst, text) is True."""
|
||||
removed = 0
|
||||
for vendor in list_vendors():
|
||||
for path, _type in iter_profile_files(os.path.join(PROFILES_DIR, vendor)):
|
||||
with open(path, "rb") as f:
|
||||
text = f.read().decode("utf-8")
|
||||
sid, inst, _name = read_profile(path)
|
||||
if not should_remove(sid, inst, text):
|
||||
continue
|
||||
new_text, n = remove_key_line(text, key)
|
||||
if n == 0:
|
||||
raise RuntimeError(f"Could not locate {key} line to remove: {path}")
|
||||
json.loads(new_text) # fail loudly if removal broke the JSON
|
||||
with open(path, "wb") as f:
|
||||
f.write(new_text.encode("utf-8"))
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
def remove_misspelled_settings_id():
|
||||
"""Delete the misspelled "settings_id" key (extra "s") wherever it appears.
|
||||
|
||||
The app never reads that key, so those presets effectively had no setting_id
|
||||
and get a correct one assigned by the normal pass; here we drop the junk key.
|
||||
"""
|
||||
return _remove_key_in_tree(
|
||||
"settings_id", lambda sid, inst, text: '"settings_id"' in text
|
||||
)
|
||||
|
||||
|
||||
def strip_base_setting_ids():
|
||||
"""Remove setting_id from every base profile (instantiation != "true").
|
||||
|
||||
Convention: only instantiated, user-selectable presets carry a setting_id;
|
||||
base/template profiles do not. Applied across all vendors.
|
||||
"""
|
||||
return _remove_key_in_tree(
|
||||
"setting_id", lambda sid, inst, text: bool(sid) and inst != "true"
|
||||
)
|
||||
|
||||
|
||||
def replace_id_value(text, key, new_value):
|
||||
"""Replace the first top-level `"key": "..."` value, preserving all formatting."""
|
||||
pattern = re.compile(r'("' + re.escape(key) + r'"\s*:\s*)"(?:[^"\\]|\\.)*"')
|
||||
repl = lambda m: m.group(1) + json.dumps(new_value, ensure_ascii=False)
|
||||
new_text, n = pattern.subn(repl, text, count=1)
|
||||
return new_text, n
|
||||
|
||||
|
||||
def insert_setting_id(text, new_id):
|
||||
"""Insert a `"setting_id"` line into a preset that lacks one.
|
||||
|
||||
Placed just before `filament_id` (or, failing that, `instantiation`) so it
|
||||
matches the canonical key order, reusing that anchor line's indentation and
|
||||
line ending. Only setting_id is added; filament_id is left untouched.
|
||||
"""
|
||||
for key in ("filament_id", "instantiation"):
|
||||
m = re.search(r'^([ \t]*)"' + key + r'"[ \t]*:.*?(\r?\n)', text, re.MULTILINE)
|
||||
if m:
|
||||
line = f'{m.group(1)}"setting_id": {json.dumps(new_id, ensure_ascii=False)},{m.group(2)}'
|
||||
return text[:m.start()] + line + text[m.start():], 1
|
||||
return text, 0
|
||||
|
||||
|
||||
def rewrite_file(path, new_id, has_setting_id):
|
||||
"""Set the preset's setting_id to new_id (replacing or inserting as needed).
|
||||
|
||||
filament_id is intentionally left untouched. Uses binary IO so the file's
|
||||
original line endings (LF or CRLF) and exact formatting are preserved
|
||||
byte-for-byte apart from the changed/added line. The result is re-parsed to
|
||||
guarantee it is still valid JSON.
|
||||
"""
|
||||
with open(path, "rb") as f:
|
||||
text = f.read().decode("utf-8")
|
||||
if has_setting_id:
|
||||
text, n = replace_id_value(text, "setting_id", new_id)
|
||||
else:
|
||||
text, n = insert_setting_id(text, new_id)
|
||||
if n == 0:
|
||||
raise RuntimeError(f"Could not set setting_id on {path}")
|
||||
json.loads(text) # fail loudly if the edit broke the JSON
|
||||
with open(path, "wb") as f:
|
||||
f.write(text.encode("utf-8"))
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
# 0. Drop the misspelled "settings_id" key wherever it appears.
|
||||
typos = remove_misspelled_settings_id()
|
||||
|
||||
# 1. Strip setting_id from base profiles everywhere (only instantiated presets keep one).
|
||||
stripped = strip_base_setting_ids()
|
||||
|
||||
# 2. Assign the deterministic setting_id to every instantiated preset of every
|
||||
# non-reserved vendor.
|
||||
changed = added = 0
|
||||
vendors_touched = []
|
||||
for vendor in list_vendors():
|
||||
if vendor in RESERVED_VENDORS:
|
||||
continue
|
||||
vendor_changed = 0
|
||||
for path, type_name in iter_profile_files(os.path.join(PROFILES_DIR, vendor)):
|
||||
sid, inst, name = read_profile(path)
|
||||
if inst != "true":
|
||||
continue
|
||||
if not name:
|
||||
raise RuntimeError(f"instantiated preset has no \"name\": {path}")
|
||||
new_id = generate_preset_setting_id(vendor, type_name, name)
|
||||
if sid == new_id:
|
||||
continue # already correct - idempotent
|
||||
rewrite_file(path, new_id, has_setting_id=sid is not None)
|
||||
changed += 1
|
||||
vendor_changed += 1
|
||||
if sid is None:
|
||||
added += 1
|
||||
if vendor_changed:
|
||||
vendors_touched.append((vendor, vendor_changed))
|
||||
|
||||
print(f"Misspelled settings_id removed : {typos}")
|
||||
print(f"Base setting_ids stripped : {stripped}")
|
||||
print(f"Reserved vendors : {sorted(RESERVED_VENDORS)}")
|
||||
print(f"Vendors updated : {len(vendors_touched)}")
|
||||
for v, n in vendors_touched:
|
||||
print(f" {v} ({n} files)")
|
||||
print(f"Files rewritten : {changed} (of which newly assigned: {added})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -3,6 +3,8 @@ import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from assign_vendor_setting_ids import generate_preset_setting_id
|
||||
|
||||
OBSOLETE_KEYS = {
|
||||
"acceleration", "scale", "rotate", "duplicate", "duplicate_grid",
|
||||
"bed_size", "print_center", "g0", "wipe_tower_per_color_wipe",
|
||||
@@ -450,6 +452,101 @@ def check_conflict_keys(profiles_dir, vendor_name):
|
||||
return error_count, warn_count
|
||||
|
||||
|
||||
# Bambu (BBL) keeps its authoritative "G*" cloud ids, which are NOT produced by the
|
||||
# deterministic formula, so BBL is exempt from the formula match (Rule 2) only. It is
|
||||
# still checked for presence, uniqueness, base-no-id and the typo key like every other
|
||||
# vendor. Every other vendor (incl. OrcaFilamentLibrary and Custom) must also match the
|
||||
# formula.
|
||||
SETTING_ID_FORMULA_EXEMPT_VENDORS = {"BBL"}
|
||||
PROFILE_SUBDIRS = ("filament", "process", "machine")
|
||||
|
||||
|
||||
def check_setting_id_uniqueness(profiles_dir):
|
||||
"""
|
||||
Validate setting_id across every vendor (see scripts/assign_vendor_setting_ids.py):
|
||||
1. Every instantiated preset must HAVE a setting_id. (all vendors)
|
||||
2. A stored setting_id must equal generate_preset_setting_id(vendor, type, name); a stale
|
||||
value means the JSON was edited without rerunning assign_vendor_setting_ids.py.
|
||||
(all vendors EXCEPT the formula-exempt ones, e.g. BBL)
|
||||
3. Base profiles (instantiation != "true") must not carry a setting_id. (all vendors)
|
||||
4. setting_id must be globally unique - no two files may share one. (all vendors)
|
||||
5. No profile may use the misspelled key "settings_id". (all vendors)
|
||||
Formula-exempt vendors (BBL) keep their authoritative ids, so only Rule 2 is skipped
|
||||
for them; they are still held to presence, uniqueness, base-no-id and the typo check.
|
||||
"""
|
||||
errors = 0
|
||||
owners = {} # setting_id -> list of relative_path (every vendor)
|
||||
for vendor_dir in sorted(profiles_dir.iterdir()):
|
||||
if not vendor_dir.is_dir():
|
||||
continue
|
||||
vendor = vendor_dir.name
|
||||
formula_exempt = vendor in SETTING_ID_FORMULA_EXEMPT_VENDORS
|
||||
for sub in PROFILE_SUBDIRS:
|
||||
base = vendor_dir / sub
|
||||
if not base.is_dir():
|
||||
continue
|
||||
for file_path in base.rglob("*.json"):
|
||||
try:
|
||||
data = json.loads(file_path.read_bytes())
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
rel = file_path.relative_to(profiles_dir)
|
||||
# Rule 5: catch the misspelled "settings_id" key.
|
||||
if "settings_id" in data:
|
||||
errors += 1
|
||||
print_error(
|
||||
f'profile {rel} uses the misspelled key "settings_id" '
|
||||
f'(should be "setting_id"); run assign_vendor_setting_ids.py'
|
||||
)
|
||||
sid = data.get("setting_id")
|
||||
instantiated = data.get("instantiation") == "true"
|
||||
if not instantiated:
|
||||
# Rule 3: base/template profiles must not carry a setting_id.
|
||||
if sid:
|
||||
errors += 1
|
||||
print_error(
|
||||
f'base profile {rel} (instantiation != "true") must not have a '
|
||||
f'setting_id ("{sid}"); run assign_vendor_setting_ids.py'
|
||||
)
|
||||
continue
|
||||
# Rule 1: every instantiated preset must have a setting_id.
|
||||
if not sid:
|
||||
errors += 1
|
||||
print_error(
|
||||
f"instantiated preset {rel} is missing a setting_id; "
|
||||
f"run assign_vendor_setting_ids.py"
|
||||
)
|
||||
continue
|
||||
# Rule 2: the stored id must match the deterministic rule. BBL keeps its
|
||||
# authoritative G* ids and is exempt from this check only.
|
||||
if not formula_exempt:
|
||||
expected = generate_preset_setting_id(vendor, sub, data.get("name", ""))
|
||||
if sid != expected:
|
||||
errors += 1
|
||||
print_error(
|
||||
f'setting_id "{sid}" in {rel} does not match the expected '
|
||||
f'"{expected}" for {vendor}/{sub}/{data.get("name", "")}; '
|
||||
f"run assign_vendor_setting_ids.py"
|
||||
)
|
||||
continue
|
||||
# Rule 4: collect for the global-uniqueness check below.
|
||||
owners.setdefault(sid, []).append(rel)
|
||||
|
||||
# Rule 4: a setting_id shared by two files is an error. For managed vendors this means
|
||||
# a duplicate vendor/type/name; for formula-exempt vendors (BBL) a copy-pasted id.
|
||||
for sid, locs in sorted(owners.items()):
|
||||
if len(locs) < 2:
|
||||
continue
|
||||
errors += 1
|
||||
print_error(
|
||||
f'setting_id "{sid}" is shared by {len(locs)} files ({sorted(map(str, locs))}); '
|
||||
f"setting_id must be globally unique"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check 3D printer profiles for common issues",
|
||||
@@ -505,6 +602,10 @@ def main():
|
||||
continue
|
||||
run_checks(vendor_dir.name)
|
||||
|
||||
# Global (cross-vendor) check: setting_id must be unique and stay in-namespace.
|
||||
# Runs once over the whole tree regardless of the --vendor filter.
|
||||
errors_found += check_setting_id_uniqueness(profiles_dir)
|
||||
|
||||
# ✨ Output finale in stile "compilatore"
|
||||
print("\n==================== SUMMARY ====================")
|
||||
print_info(f"Checked vendors : {checked_vendor_count}")
|
||||
|
||||
Reference in New Issue
Block a user