Files
OrcaSlicer/scripts/orca_extra_profile_check.py
SoftFever db3d684ee0 Scope the filament AMS length check to presets the vendor bundle references
The tree-wide length check (freed from its BBL/OFL carve-out last commit)
was flagging 21 pre-existing SeeMeCNC files that no vendor index references
and that therefore never load, turning CI red for files with no bearing on
what ships. The rule now only fires on presets a vendor's filament_list
actually references; every .json under the vendor's filament directory is
still parsed through the duplicate-key hook, so that coverage is unchanged.

Also removed a duplicated BAMBU_MAP_PATH definition: update_bambu_filament_ids.py
now imports the constant from assign_filament_ids.py, which it already imports
several other constants from, instead of recomputing the same path independently.
2026-09-04 14:42:57 +08:00

659 lines
26 KiB
Python

import os
import json
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",
"bed_size", "print_center", "g0", "wipe_tower_per_color_wipe",
"support_sharp_tails", "support_remove_small_overhangs", "support_with_sheath",
"tree_support_collision_resolution", "tree_support_with_infill",
"max_volumetric_speed", "max_print_speed", "support_closing_radius",
"remove_freq_sweep", "remove_bed_leveling", "remove_extrusion_calibration",
"support_transition_line_width", "support_transition_speed", "bed_temperature",
"bed_temperature_initial_layer", "can_switch_nozzle_type", "can_add_auxiliary_fan",
"extra_flush_volume", "spaghetti_detector", "adaptive_layer_height",
"z_hop_type", "z_lift_type", "bed_temperature_difference", "long_retraction_when_cut",
"retraction_distance_when_cut", "extruder_type", "internal_bridge_support_thickness",
"extruder_clearance_max_radius", "top_area_threshold", "reduce_wall_solid_infill",
"filament_load_time", "filament_unload_time", "smooth_coefficient",
"overhang_totally_speed", "silent_mode", "overhang_speed_classic"
}
# Utility functions for printing messages in different colors.
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
# Add helper function for duplicate key detection.
def no_duplicates_object_pairs_hook(pairs):
seen = {}
for key, value in pairs:
if key in seen:
raise ValueError(f"Duplicate key detected: {key}")
seen[key] = value
return seen
# NOTE: currently Orca expects compatible_printers to be a defined in every instantiation profile, inheritation is not supported in Profile page
def check_filament_compatible_printers(vendor, vendor_folder):
"""
Checks JSON files in the vendor folder for missing or empty 'compatible_printers'
when 'instantiation' is flagged as true.
In the OrcaFilamentLibrary 'compatible_printers' is optional: a profile without it is generic and
offered on every printer, while a profile that lists printers supersedes the generic one there.
Parameters:
vendor (str): The vendor name the folder belongs to.
vendor_folder (str or Path): The directory to search for JSON profile files.
Returns:
int: The number of profiles with missing or empty 'compatible_printers'.
"""
error = 0
vendor_path = Path(vendor_folder)
if not vendor_path.exists():
return 0
profiles = {}
# Use rglob to recursively find .json files.
for file_path in vendor_path.rglob("*.json"):
if file_path.name == 'filaments_color_codes.json': # Ignore non-profile file
continue
try:
with open(file_path, 'r', encoding='UTF-8') as fp:
# Use custom hook to detect duplicates.
data = json.load(fp, object_pairs_hook=no_duplicates_object_pairs_hook)
except ValueError as ve:
print_error(f"Duplicate key error in {file_path}: {ve}")
error += 1
continue
except Exception as e:
print_error(f"Error processing {file_path}: {e}")
error += 1
continue
profile_name = data['name']
if profile_name in profiles:
print_error(f"Duplicated profile {profile_name}: {file_path}")
error += 1
continue
profiles[profile_name] = {
'file_path': file_path,
'content': data,
}
def get_property(profile, key):
content = profile['content']
if key in content:
return content[key]
return None
def get_inherit_property(profile, key):
content = profile['content']
if key in content:
return content[key]
if 'inherits' in content:
inherits = content['inherits']
if inherits not in profiles:
raise ValueError(f"Parent profile not found: {inherits}, referenced in {profile['file_path']}")
return get_inherit_property(profiles[inherits], key)
return None
for profile in profiles.values():
instantiation = str(profile['content'].get("instantiation", "")).lower() == "true"
if instantiation and vendor != 'OrcaFilamentLibrary':
try:
compatible_printers = get_property(profile, "compatible_printers")
if not compatible_printers or (isinstance(compatible_printers, list) and not compatible_printers):
print_error(f"'compatible_printers' missing in {profile['file_path']}")
error += 1
except ValueError as ve:
print_error(f"Unable to parse {profile['file_path']}: {ve}")
error += 1
continue
return error
def load_available_filament_profiles(profiles_dir, vendor_name):
"""
Load all available filament profiles from a vendor's directory.
Parameters:
profiles_dir (Path): The directory containing vendor profile directories
vendor_name (str): The name of the vendor directory
Returns:
set: A set of filament profile names
"""
profiles = set()
vendor_path = profiles_dir / vendor_name / "filament"
if not vendor_path.exists():
return profiles
for file_path in vendor_path.rglob("*.json"):
try:
with open(file_path, 'r', encoding='UTF-8') as fp:
data = json.load(fp)
if "name" in data:
profiles.add(data["name"])
except Exception as e:
print_error(f"Error loading filament profile {file_path}: {e}")
return profiles
def check_machine_default_materials(profiles_dir, vendor_name):
"""
Checks if default materials referenced in machine profiles exist in
the vendor's filament library or in the global OrcaFilamentLibrary.
Parameters:
profiles_dir (Path): The base profiles directory
vendor_name (str): The vendor name to check
Returns:
int: Number of missing filament references found
int: the number of warnings found (0 or 1)
"""
error_count = 0
machine_dir = profiles_dir / vendor_name / "machine"
if not machine_dir.exists():
print_warning(f"No machine profiles found for vendor: {vendor_name}")
return 0, 1
# Load available filament profiles
vendor_filaments = load_available_filament_profiles(profiles_dir, vendor_name)
global_filaments = load_available_filament_profiles(profiles_dir, "OrcaFilamentLibrary")
all_available_filaments = vendor_filaments.union(global_filaments)
# Check each machine profile
for file_path in machine_dir.rglob("*.json"):
try:
with open(file_path, 'r', encoding='UTF-8') as fp:
data = json.load(fp)
default_materials = None
if "default_materials" in data:
default_materials = data["default_materials"]
elif "default_filament_profile" in data:
default_materials = data["default_filament_profile"]
if default_materials:
if isinstance(default_materials, list):
for material in default_materials:
if material not in all_available_filaments:
print_error(f"Missing filament profile: '{material}' referenced in {file_path.relative_to(profiles_dir)}")
error_count += 1
else:
# Handle semicolon-separated list of materials in a string
if ";" in default_materials:
for material in default_materials.split(";"):
material = material.strip()
if material and material not in all_available_filaments:
print_error(f"Missing filament profile: '{material}' referenced in {file_path.relative_to(profiles_dir)}")
error_count += 1
else:
# Single material in a string
if default_materials not in all_available_filaments:
print_error(f"Missing filament profile: '{default_materials}' referenced in {file_path.relative_to(profiles_dir)}")
error_count += 1
except Exception as e:
print_error(f"Error processing machine profile {file_path}: {e}")
error_count += 1
return error_count, 0
def check_name_consistency(profiles_dir, vendor_name):
"""
Make sure filament profile names match in both vendor json and subpath files.
Filament profiles work only if the name in <vendor>.json matches the name in sub_path file,
or if it's one of the sub_path file's `renamed_from`.
Parameters:
profiles_dir (Path): Base profiles directory
vendor_name (str): Vendor name
Returns:
int: Number of errors found
int: Number of warnings found (0 or 1)
"""
error_count = 0
vendor_dir = profiles_dir / vendor_name
vendor_file = profiles_dir / (vendor_name + ".json")
if not vendor_file.exists():
print_warning(f"No profiles found for vendor: {vendor_name} at {vendor_file}")
return 0, 1
try:
with open(vendor_file, 'r', encoding='UTF-8') as fp:
data = json.load(fp)
except Exception as e:
print_error(f"Error loading vendor profile {vendor_file}: {e}")
return 1, 0
for section in ['filament_list', 'machine_model_list', 'machine_list', 'process_list']:
if section not in data:
continue
for child in data[section]:
name_in_vendor = child['name']
sub_path = child['sub_path']
sub_file = vendor_dir / sub_path
if not sub_file.exists():
print_error(f"Missing sub profile: '{sub_path}' declared in {vendor_file.relative_to(profiles_dir)}")
error_count += 1
continue
try:
with open(sub_file, 'r', encoding='UTF-8') as fp:
sub_data = json.load(fp)
except Exception as e:
print_error(f"Error loading profile {sub_file}: {e}")
error_count += 1
continue
name_in_sub = sub_data['name']
if name_in_sub == name_in_vendor:
continue
# if 'renamed_from' in sub_data:
# renamed_from = [n.strip() for n in sub_data['renamed_from'].split(';')]
# if name_in_vendor in renamed_from:
# continue
print_error(f"{section} name mismatch: required '{name_in_vendor}' in {vendor_file.relative_to(profiles_dir)} but found '{name_in_sub}' in {sub_file.relative_to(profiles_dir)}")
error_count += 1
return error_count, 0
def check_filament_id(profiles_dir, vendor_name):
"""
Make sure filament_id is not longer than 8 characters, otherwise AMS won't work properly.
Runs tree-wide, every vendor alike (BBL included: the id format is what
matters, not the vendor). Every .json file under the vendor's filament
directory is still parsed through the duplicate-key hook below, so that
coverage is unchanged; only the length rule itself is scoped to presets
the vendor's index (<vendor>.json filament_list) actually references. A
file the index does not reference never loads, so its filament_id length
cannot break AMS -- and some vendors (e.g. SeeMeCNC) ship such orphaned
files pre-dating this check, with no bearing on what ships.
"""
error = 0
vendor_path = profiles_dir / vendor_name / "filament"
if not vendor_path.exists():
return 0
referenced = set()
vendor_file = profiles_dir / (vendor_name + ".json")
if vendor_file.exists():
try:
with open(vendor_file, 'r', encoding='UTF-8') as fp:
index = json.load(fp)
for entry in index.get('filament_list', []):
sub_path = entry.get('sub_path')
if sub_path:
referenced.add((profiles_dir / vendor_name / sub_path).resolve())
except Exception as e:
print_error(f"Error loading vendor profile {vendor_file}: {e}")
error += 1
# Use rglob to recursively find .json files.
for file_path in vendor_path.rglob("*.json"):
try:
with open(file_path, 'r', encoding='UTF-8') as fp:
# Use custom hook to detect duplicates.
data = json.load(fp, object_pairs_hook=no_duplicates_object_pairs_hook)
except ValueError as ve:
print_error(f"Duplicate key error in {file_path}: {ve}")
error += 1
continue
except Exception as e:
print_error(f"Error processing {file_path}: {e}")
error += 1
continue
if 'filament_id' not in data:
continue
filament_id = data['filament_id']
if len(filament_id) > 8 and file_path.resolve() in referenced:
error += 1
print_error(f"Filament id too long \"{filament_id}\": {file_path}")
return error
def check_obsolete_keys(profiles_dir, vendor_name):
"""
Check for obsolete keys in all filament profiles for a vendor.
Parameters:
profiles_dir (Path): Base profiles directory
vendor_name (str): Vendor name
obsolete_keys (set): Set of obsolete key names to check
Returns:
int: Number of obsolete keys found
"""
error_count = 0
vendor_path = profiles_dir / vendor_name / "filament"
if not vendor_path.exists():
return 0
for file_path in vendor_path.rglob("*.json"):
try:
with open(file_path, "r", encoding="UTF-8") as fp:
data = json.load(fp)
except Exception as e:
print_warning(f"Error reading profile {file_path.relative_to(profiles_dir)}: {e}")
error_count += 1
continue
for key in data.keys():
if key in OBSOLETE_KEYS:
print_warning(f"Obsolete key: '{key}' found in {file_path.relative_to(profiles_dir)}")
error_count += 1
return error_count
CONFLICT_KEYS = [
['extruder_clearance_radius', 'extruder_clearance_max_radius'],
]
VECTOR_KEYS = {
"filament_type",
}
def check_vector_type_keys(profiles_dir, vendor_name):
"""
Check that properties expected to be vectors (JSON arrays) are not stored as scalars.
For example, `filament_type` must be a list like ["PA-CF"], not a string "PA-CF".
Parameters:
profiles_dir (Path): Base profiles directory
vendor_name (str): Vendor name
Returns:
int: Number of errors found
"""
error_count = 0
vendor_path = profiles_dir / vendor_name
if not vendor_path.exists():
return 0
for file_path in vendor_path.rglob("*.json"):
try:
with open(file_path, "r", encoding="UTF-8") as fp:
data = json.load(fp)
except Exception as e:
print_error(f"Error processing {file_path.relative_to(profiles_dir)}: {e}")
error_count += 1
continue
if not isinstance(data, dict):
continue
for key in VECTOR_KEYS:
if key in data and not isinstance(data[key], list):
print_error(
f"'{key}' must be an array in {file_path.relative_to(profiles_dir)}, "
f"got {type(data[key]).__name__}: {data[key]!r}"
)
error_count += 1
return error_count
def check_conflict_keys(profiles_dir, vendor_name):
"""
Check for keys that could not be specified at the same time,
due to option renaming & backward compatibility reasons.
For example, `extruder_clearance_max_radius` and `extruder_clearance_radius` cannot co-exist
otherwise slicer won't know which one to use.
Parameters:
profiles_dir (Path): Base profiles directory
vendor_name (str): Vendor name
Returns:
int: Number of errors found
int: Number of warnings found
"""
error_count = 0
warn_count = 0
vendor_path = profiles_dir / vendor_name
if not vendor_path.exists():
print_warning(f"No machine profiles found for vendor: {vendor_name}")
return 0, 1
for file_path in vendor_path.rglob("*.json"):
try:
with open(file_path, 'r', encoding='UTF-8') as fp:
# Use custom hook to detect duplicates.
data = json.load(fp, object_pairs_hook=no_duplicates_object_pairs_hook)
except ValueError as ve:
print_error(f"Duplicate key error in {file_path.relative_to(profiles_dir)}: {ve}")
error_count += 1
continue
except Exception as e:
print_error(f"Error processing {file_path.relative_to(profiles_dir)}: {e}")
error_count += 1
continue
for key_sets in CONFLICT_KEYS:
if sum([1 if k in data else 0 for k in key_sets]) > 1:
print_error(f"Conflict keys {key_sets} co-exist in {file_path.relative_to(profiles_dir)}")
error_count += 1
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",
formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
parser.add_argument("--vendor", type=str, help="Specify a single vendor to check")
parser.add_argument("--check-filaments", action="store_true", help="Check 'compatible_printers' in filament profiles")
parser.add_argument("--check-materials", action="store_true", help="Check default materials in machine profiles")
parser.add_argument("--check-obsolete-keys", action="store_true", help="Warn if obsolete keys are found in filament profiles")
args = parser.parse_args()
print_info("Checking profiles ...")
script_dir = Path(__file__).resolve().parent
profiles_dir = script_dir.parent / "resources" / "profiles"
checked_vendor_count = 0
errors_found = 0
warnings_found = 0
def run_checks(vendor_name):
nonlocal errors_found, warnings_found, checked_vendor_count
vendor_path = profiles_dir / vendor_name
if args.check_filaments or not (args.check_materials and not args.check_filaments):
errors_found += check_filament_compatible_printers(vendor_name, vendor_path / "filament")
if args.check_materials:
new_errors, new_warnings = check_machine_default_materials(profiles_dir, vendor_name)
errors_found += new_errors
warnings_found += new_warnings
if args.check_obsolete_keys:
warnings_found += check_obsolete_keys(profiles_dir, vendor_name)
new_errors, new_warnings = check_name_consistency(profiles_dir, vendor_name)
errors_found += new_errors
warnings_found += new_warnings
new_errors, new_warnings = check_conflict_keys(profiles_dir, vendor_name)
errors_found += new_errors
warnings_found += new_warnings
errors_found += check_vector_type_keys(profiles_dir, vendor_name)
errors_found += check_filament_id(profiles_dir, vendor_name)
checked_vendor_count += 1
if args.vendor:
run_checks(args.vendor)
else:
for vendor_dir in profiles_dir.iterdir():
if not vendor_dir.is_dir() or vendor_dir.name == "OrcaFilamentLibrary":
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)
# 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}")
if errors_found > 0:
print_error(f"Files with errors : {errors_found}")
else:
print_success("Files with errors : 0")
if warnings_found > 0:
print_warning(f"Files with warnings : {warnings_found}")
else:
print_success("Files with warnings : 0")
print("=================================================")
if errors_found > 0 or warnings_found > 0 :
print_warning('Issue(s) found, try `orca_filament_lib.py --fix` to fix common issues automatically')
exit(-1 if errors_found > 0 else 0)
if __name__ == "__main__":
main()