diff --git a/tools/config_codegen.py b/tools/config_codegen.py new file mode 100644 index 0000000000..0e37c21c52 --- /dev/null +++ b/tools/config_codegen.py @@ -0,0 +1,605 @@ +#!/usr/bin/env python3 +""" +OrcaSlicer Config Code Generator + +Reads compiled protobuf descriptor set and generates C++ source files +that replace hand-written config registration, preset lists, and invalidation chains. + +Usage: + # Step 1: Compile .proto files to a descriptor set + protoc --proto_path=src/PrintConfigs --descriptor_set_out=config.desc \ + --include_imports src/PrintConfigs/*.proto + + # Step 2: Generate Python bindings (one-time, or when config_metadata.proto changes) + protoc --proto_path=src/PrintConfigs --python_out=tools/ config_metadata.proto + + # Step 3: Run codegen + python tools/config_codegen.py config.desc codegen/generated/ + +Outputs: + - PrintConfigDef_generated.cpp (init_fff_params body) + - Preset_options_generated.cpp (s_Preset_*_options arrays) + - Invalidation_generated.cpp (opt_key -> steps map) + - OptionKeys_generated.cpp (extruder/filament key lists) +""" + +import sys +import os +import argparse +from pathlib import Path + +# Add tools/ to path so we can import generated config_metadata_pb2 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +try: + from google.protobuf import descriptor_pb2 + # Import the generated bindings - this registers extensions globally + import config_metadata_pb2 as meta_pb2 +except ImportError as e: + print(f"ERROR: {e}") + print("Ensure google-protobuf is installed: pip install protobuf") + print("And that config_metadata_pb2.py exists in tools/") + print("Generate it with: protoc --proto_path=src/PrintConfigs --python_out=tools/ config_metadata.proto") + sys.exit(1) + + +# Proto FieldDescriptorProto.Type enum values +TYPE_DOUBLE = 1 +TYPE_FLOAT = 2 +TYPE_INT64 = 3 +TYPE_UINT64 = 4 +TYPE_INT32 = 5 +TYPE_FIXED64 = 6 +TYPE_FIXED32 = 7 +TYPE_BOOL = 8 +TYPE_STRING = 9 +TYPE_MESSAGE = 11 +TYPE_UINT32 = 13 +TYPE_ENUM = 14 +TYPE_SINT32 = 17 +TYPE_SINT64 = 18 + +# Proto label +LABEL_OPTIONAL = 1 +LABEL_REQUIRED = 2 +LABEL_REPEATED = 3 + + +def mode_to_cpp(mode_val): + """Convert mode enum value to C++ constant.""" + return { + meta_pb2.MODE_SIMPLE: "comSimple", + meta_pb2.MODE_ADVANCED: "comAdvanced", + meta_pb2.MODE_DEVELOP: "comDevelop", + }.get(mode_val, "comAdvanced") + + +_PRINT_STEPS = None # set after meta_pb2 import resolves enum values +_OBJECT_STEPS = None + + +def _init_step_sets(): + global _PRINT_STEPS, _OBJECT_STEPS + if _PRINT_STEPS is None: + _PRINT_STEPS = {meta_pb2.STEP_GCODE_EXPORT, meta_pb2.STEP_SKIRT_BRIM, meta_pb2.STEP_WIPE_TOWER} + _OBJECT_STEPS = {meta_pb2.STEP_SLICE, meta_pb2.STEP_PERIMETERS, meta_pb2.STEP_INFILL, meta_pb2.STEP_SUPPORT} + + +def step_to_cpp(step_val): + """Convert invalidation step to C++ constant.""" + return { + meta_pb2.STEP_GCODE_EXPORT: "psGCodeExport", + meta_pb2.STEP_SKIRT_BRIM: "psSkirtBrim", + meta_pb2.STEP_WIPE_TOWER: "psWipeTower", + meta_pb2.STEP_SLICE: "posSlice", + meta_pb2.STEP_PERIMETERS: "posPerimeters", + meta_pb2.STEP_INFILL: "posInfill", + meta_pb2.STEP_SUPPORT: "posSupportMaterial", + meta_pb2.STEP_NONE: "", + }.get(step_val, "") + + +def proto_type_to_co_type(field_desc, is_nullable=False): + """ + Map a protobuf field descriptor to OrcaSlicer's coXXX type constant + and ConfigOptionXXX class name. + + Returns: (co_type_str, config_option_class, is_vector) + """ + ftype = field_desc.type + is_repeated = (field_desc.label == LABEL_REPEATED) + type_name = field_desc.type_name # For message types + + # Handle message types (FloatOrPercent, Point2D) + if ftype == TYPE_MESSAGE: + if "FloatOrPercent" in type_name: + if is_repeated: + return ("coFloatsOrPercents", "ConfigOptionFloatsOrPercents", True) + return ("coFloatOrPercent", "ConfigOptionFloatOrPercent", False) + elif "Point2D" in type_name: + if is_repeated: + return ("coPoints", "ConfigOptionPoints", True) + return ("coPoint", "ConfigOptionPoint", False) + + # Handle enum types + if ftype == TYPE_ENUM: + if is_repeated: + return ("coEnums", "ConfigOptionEnumsGeneric", True) + return ("coEnum", "ConfigOptionEnum", False) + + # Scalar/vector types + if ftype in (TYPE_FLOAT, TYPE_DOUBLE): + if is_repeated: + if is_nullable: + return ("coFloats", "ConfigOptionFloatsNullable", True) + return ("coFloats", "ConfigOptionFloats", True) + return ("coFloat", "ConfigOptionFloat", False) + + if ftype in (TYPE_INT32, TYPE_INT64, TYPE_SINT32, TYPE_SINT64, + TYPE_UINT32, TYPE_UINT64, TYPE_FIXED32, TYPE_FIXED64): + if is_repeated: + if is_nullable: + return ("coInts", "ConfigOptionIntsNullable", True) + return ("coInts", "ConfigOptionInts", True) + return ("coInt", "ConfigOptionInt", False) + + if ftype == TYPE_BOOL: + if is_repeated: + if is_nullable: + return ("coBools", "ConfigOptionBoolsNullable", True) + return ("coBools", "ConfigOptionBools", True) + return ("coBool", "ConfigOptionBool", False) + + if ftype == TYPE_STRING: + if is_repeated: + return ("coStrings", "ConfigOptionStrings", True) + return ("coString", "ConfigOptionString", False) + + return ("coNone", "ConfigOption", False) + + +def parse_field_options(field_desc_proto): + """ + Re-parse FieldOptions from a FieldDescriptorProto with extensions registered. + This is needed because the FileDescriptorSet parser doesn't know about our + custom extensions, so they end up as unknown fields. Re-parsing with the + extensions registered (via config_metadata_pb2 import) resolves them. + """ + from google.protobuf import descriptor_pb2 + opts = field_desc_proto.options + if not opts.ByteSize(): + return descriptor_pb2.FieldOptions() + + # Re-parse the serialized options with extensions registered + reparsed = descriptor_pb2.FieldOptions() + reparsed.ParseFromString(opts.SerializeToString()) + return reparsed + + +class FieldInfo: + """Parsed information about a single config field from proto descriptor.""" + + def __init__(self, field_desc): + self.name = field_desc.name + self.field_desc = field_desc + + # Re-parse options with extensions registered + opts = parse_field_options(field_desc) + + # Read extensions using the proper protobuf API + self.label = opts.Extensions[meta_pb2.label] or None + self.full_label = opts.Extensions[meta_pb2.full_label] or None + self.tooltip = opts.Extensions[meta_pb2.tooltip] or None + self.category = opts.Extensions[meta_pb2.category] or None + self.sidetext = opts.Extensions[meta_pb2.sidetext] or None + self.min_value = opts.Extensions[meta_pb2.min_value] if opts.HasExtension(meta_pb2.min_value) else None + self.max_value = opts.Extensions[meta_pb2.max_value] if opts.HasExtension(meta_pb2.max_value) else None + self.max_literal = opts.Extensions[meta_pb2.max_literal] if opts.HasExtension(meta_pb2.max_literal) else None + self.mode = opts.Extensions[meta_pb2.mode] # 0 = MODE_SIMPLE (default) + self.has_mode = opts.HasExtension(meta_pb2.mode) + self.ratio_over = opts.Extensions[meta_pb2.ratio_over] or None + self.multiline = opts.Extensions[meta_pb2.multiline] + self.full_width = opts.Extensions[meta_pb2.full_width] + self.height = opts.Extensions[meta_pb2.height] or None + self.is_nullable = opts.Extensions[meta_pb2.is_nullable] + self.gui_type = opts.Extensions[meta_pb2.gui_type] or None + self.gui_flags = opts.Extensions[meta_pb2.gui_flags] or None + self.enum_keys_map = opts.Extensions[meta_pb2.enum_keys_map_ref] or None + self.no_cli = opts.Extensions[meta_pb2.no_cli] + self.readonly = opts.Extensions[meta_pb2.readonly] + self.preset = opts.Extensions[meta_pb2.preset] # 0 = PRESET_PRINT + self.invalidates = list(opts.Extensions[meta_pb2.invalidates]) + self.list_membership = list(opts.Extensions[meta_pb2.list_membership]) + self.legacy_name = opts.Extensions[meta_pb2.legacy_name] or None + + # Default value and enum metadata + self.has_default = opts.Extensions[meta_pb2.has_default] + self.default_value = opts.Extensions[meta_pb2.default_value] if self.has_default else None + self.enum_value_entries = list(opts.Extensions[meta_pb2.enum_value_entries]) + self.enum_label_entries = list(opts.Extensions[meta_pb2.enum_label_entries]) + self.co_type_hint = opts.Extensions[meta_pb2.co_type_hint] or None + + # Resolve C++ type info - co_type_hint overrides auto-detection + co_type, option_class, is_vec = proto_type_to_co_type( + field_desc, self.is_nullable) + if self.co_type_hint: + co_type = self.co_type_hint + # Fix up option_class for hint-overridden types + hint_class_map = { + "coPercent": "ConfigOptionPercent", + "coPercents": "ConfigOptionPercents", + "coEnum": "ConfigOptionEnum", + "coEnums": "ConfigOptionEnumsGeneric", + } + if self.co_type_hint in hint_class_map: + option_class = hint_class_map[self.co_type_hint] + self.co_type = co_type + self.option_class = option_class + self.is_vector = is_vec + + +class CodeGenerator: + """Generates C++ source files from parsed proto descriptors.""" + + def __init__(self, descriptor_set): + self.descriptor_set = descriptor_set + self.fields = [] # All FieldInfo objects + self.virtual_keys_by_preset = { # virtual_preset_keys per preset type + meta_pb2.PRESET_PRINT: [], + meta_pb2.PRESET_FILAMENT: [], + meta_pb2.PRESET_PRINTER: [], + } + self._parse_all_fields() + + @staticmethod + def _preset_type_from_filename(name: str) -> int: + """Infer preset type from proto filename (printer/filament/print).""" + n = name.lower() + if "printer" in n: + return meta_pb2.PRESET_PRINTER + if "filament" in n: + return meta_pb2.PRESET_FILAMENT + return meta_pb2.PRESET_PRINT + + def _parse_all_fields(self): + """Parse all message fields from all proto files in the descriptor set.""" + for file_desc in self.descriptor_set.file: + # Skip google/protobuf imports + if file_desc.name.startswith("google/"): + continue + # Skip config_metadata.proto (it's just extensions, no settings) + if "config_metadata" in file_desc.name: + continue + + preset_type = self._preset_type_from_filename(file_desc.name) + + for msg_desc in file_desc.message_type: + # Skip wrapper messages (FloatOrPercent, Point2D) + if msg_desc.name in ("FloatOrPercent", "Point2D"): + continue + + # Collect message-level virtual_preset_keys + vkeys = list(msg_desc.options.Extensions[meta_pb2.virtual_preset_keys]) + self.virtual_keys_by_preset[preset_type].extend(vkeys) + + for field_desc in msg_desc.field: + self.fields.append(FieldInfo(field_desc)) + + def generate_init_fff_params(self) -> str: + """ + Generate the body of PrintConfigDef::init_fff_params(). + Output: C++ code that's a drop-in replacement for the hand-written registrations. + """ + lines = [] + lines.append("// ===== AUTO-GENERATED by tools/config_codegen.py =====") + lines.append("// DO NOT EDIT MANUALLY. Edit .proto files and re-run codegen.") + lines.append("") + + for field in self.fields: + lines.append(f' def = this->add("{field.name}", {field.co_type});') + + if field.label: + lines.append(f' def->label = L("{self._escape_cpp(field.label)}");') + + if field.full_label: + lines.append(f' def->full_label = L("{self._escape_cpp(field.full_label)}");') + + if field.category: + lines.append(f' def->category = L("{self._escape_cpp(field.category)}");') + + if field.tooltip: + tooltip_escaped = self._escape_cpp(field.tooltip) + # Split long tooltips across lines + if len(tooltip_escaped) > 80: + lines.append(f' def->tooltip = L("{tooltip_escaped}");') + else: + lines.append(f' def->tooltip = L("{tooltip_escaped}");') + + if field.sidetext: + lines.append(f' def->sidetext = L("{self._escape_cpp(field.sidetext)}");') + + if field.min_value is not None: + lines.append(f' def->min = {self._format_number(field.min_value)};') + + if field.max_value is not None: + lines.append(f' def->max = {self._format_number(field.max_value)};') + + if field.max_literal is not None: + lines.append(f' def->max_literal = {self._format_number(field.max_literal)};') + + if field.ratio_over: + lines.append(f' def->ratio_over = "{field.ratio_over}";') + + if field.has_mode: + lines.append(f' def->mode = {mode_to_cpp(field.mode)};') + + if field.is_nullable: + lines.append(f' def->nullable = true;') + + if field.readonly: + lines.append(f' def->readonly = true;') + + if field.multiline: + lines.append(f' def->multiline = true;') + + if field.full_width: + lines.append(f' def->full_width = true;') + + if field.height: + lines.append(f' def->height = {field.height};') + + if field.gui_type: + lines.append(f' def->gui_type = ConfigOptionDef::GUIType::{field.gui_type};') + + if field.gui_flags: + lines.append(f' def->gui_flags = "{field.gui_flags}";') + + if field.no_cli: + lines.append(f' def->cli = ConfigOptionDef::nocli;') + + if field.enum_keys_map: + lines.append(f' def->enum_keys_map = &{field.enum_keys_map};') + + # Enum values/labels + for ev in field.enum_value_entries: + lines.append(f' def->enum_values.push_back("{self._escape_cpp(ev)}");') + for el in field.enum_label_entries: + lines.append(f' def->enum_labels.push_back(L("{self._escape_cpp(el)}"));') + + # Default value - reconstruct full C++ from co_type + default_value + if field.has_default: + cpp_expr = self._reconstruct_default_cpp( + field.default_value or "", field.co_type, field.enum_keys_map) + lines.append(f' def->set_default_value({cpp_expr});') + + lines.append("") + + return "\n".join(lines) + + def generate_preset_options(self) -> str: + """Generate s_Preset_print_options, s_Preset_filament_options, etc.""" + lines = [] + lines.append("// ===== AUTO-GENERATED by tools/config_codegen.py =====") + lines.append("") + + for var_name, preset_type in [ + ("s_Preset_print_options", meta_pb2.PRESET_PRINT), + ("s_Preset_filament_options", meta_pb2.PRESET_FILAMENT), + ("s_Preset_printer_options", meta_pb2.PRESET_PRINTER), + ]: + # Field-derived keys + message-level virtual keys, deduplicated and sorted + field_names = [f.name for f in self.fields if f.preset == preset_type] + virtual_names = self.virtual_keys_by_preset[preset_type] + all_names = sorted(set(field_names) | set(virtual_names)) + + lines.append(f"static const std::vector {var_name} = {{") + for name in all_names: + lines.append(f' "{name}",') + lines.append("};") + lines.append("") + + return "\n".join(lines) + + def generate_invalidation_map(self) -> str: + """Generate opt_key -> invalidation steps mapping, split by PrintStep vs PrintObjectStep.""" + _init_step_sets() + lines = [] + lines.append("// ===== AUTO-GENERATED by tools/config_codegen.py =====") + lines.append("") + + lines.append("static const std::unordered_map> " + "s_print_steps_map = {") + for field in sorted(self.fields, key=lambda x: x.name): + if field.invalidates: + steps = [step_to_cpp(s) for s in field.invalidates + if s in _PRINT_STEPS and step_to_cpp(s)] + if steps: + lines.append(f' {{"{field.name}", {{{", ".join(steps)}}}}},') + lines.append("};") + lines.append("") + + lines.append("static const std::unordered_map> " + "s_object_steps_map = {") + for field in sorted(self.fields, key=lambda x: x.name): + if field.invalidates: + steps = [step_to_cpp(s) for s in field.invalidates + if s in _OBJECT_STEPS and step_to_cpp(s)] + if steps: + lines.append(f' {{"{field.name}", {{{", ".join(steps)}}}}},') + lines.append("};") + + return "\n".join(lines) + + def generate_option_key_lists(self) -> str: + """Generate extruder_option_keys, filament_option_keys, etc.""" + lines = [] + lines.append("// ===== AUTO-GENERATED by tools/config_codegen.py =====") + lines.append("") + + extruder_keys = [f for f in self.fields + if meta_pb2.LIST_EXTRUDER_OPTION_KEYS in f.list_membership] + filament_keys = [f for f in self.fields + if meta_pb2.LIST_FILAMENT_OPTION_KEYS in f.list_membership] + + for var_name, keys in [ + ("s_extruder_option_keys", extruder_keys), + ("s_filament_option_keys", filament_keys), + ]: + lines.append(f"static const std::vector {var_name} = {{") + for f in sorted(keys, key=lambda x: x.name): + lines.append(f' "{f.name}",') + lines.append("};") + lines.append("") + + return "\n".join(lines) + + @staticmethod + def _reconstruct_default_cpp(default_value, co_type, enum_keys_map=None): + """Reconstruct full C++ default expression from co_type + extracted value args. + + Maps (co_type, args) -> 'new ConfigOptionXxx(args)' or 'new ConfigOptionXxx{args}'. + """ + import re as _re + + # Type -> C++ class mappings + SCALAR_CLASS = { + "coFloat": "ConfigOptionFloat", + "coBool": "ConfigOptionBool", + "coInt": "ConfigOptionInt", + "coString": "ConfigOptionString", + "coPercent": "ConfigOptionPercent", + "coFloatOrPercent": "ConfigOptionFloatOrPercent", + "coPoint": "ConfigOptionPoint", + "coPoint3": "ConfigOptionPoint3", + } + LIST_CLASS = { + "coFloats": "ConfigOptionFloats", + "coInts": "ConfigOptionInts", + "coBools": "ConfigOptionBools", + "coStrings": "ConfigOptionStrings", + "coPercents": "ConfigOptionPercents", + "coFloatsOrPercents": "ConfigOptionFloatsOrPercents", + "coPoints": "ConfigOptionPoints", + } + + # Do NOT unescape \n → newline here; C++ string literals use \n as escape sequence. + # Only unescape escaped quotes so they appear correctly in the reconstructed expression. + args = default_value.replace('\\"', '"') + + # Empty args -> default constructor for any type + if not args: + if co_type == "coEnum": + enum_type = "int" + if enum_keys_map: + m = _re.match(r'ConfigOptionEnum<(\w+)>::', enum_keys_map) + if m: + enum_type = m.group(1) + return f"new ConfigOptionEnum<{enum_type}>()" + if co_type == "coEnums": + return "new ConfigOptionEnumsGeneric{}" + all_classes = {**SCALAR_CLASS, **LIST_CLASS} + cls = all_classes.get(co_type, "ConfigOption") + return f"new {cls}()" + + if co_type in SCALAR_CLASS: + return f"new {SCALAR_CLASS[co_type]}({args})" + + if co_type in LIST_CLASS: + return f"new {LIST_CLASS[co_type]}{{{args}}}" + + if co_type == "coEnum": + # Extract enum type from enum_keys_map, e.g. + # "ConfigOptionEnum::get_enum_values()" -> "BedType" + enum_type = "int" + if enum_keys_map: + m = _re.match(r'ConfigOptionEnum<(\w+)>::', enum_keys_map) + if m: + enum_type = m.group(1) + return f"new ConfigOptionEnum<{enum_type}>({args})" + + if co_type == "coEnums": + # List-of-enum: use ConfigOptionEnumsGenericNullable if nullable, + # otherwise ConfigOptionEnumsGeneric. + # We don't have is_nullable here, so always emit Generic; caller + # can override when is_nullable is set. + return f"new ConfigOptionEnumsGeneric{{ {args} }}" + + # Fallback: try generic + return f"new ConfigOption({args})" + + @staticmethod + def _escape_cpp(s): + """Escape a string for C++ string literal. + + Proto strings already contain C++ escape sequences (\\n, \\", etc.) + as literal backslash + char. We pass those through and only escape + unescaped quotes and actual newlines. + """ + if not s: + return "" + # Replace actual newline characters (rare) with \n escape + s = s.replace('\n', '\\n') + # Don't double-escape backslashes that are already part of escape sequences. + # The proto strings store them as literal \n, \", \t etc. + return s + + @staticmethod + def _format_number(val): + """Format a number for C++ (int vs float).""" + if val is None: + return "0" + if isinstance(val, float) and val == int(val): + return str(int(val)) + return str(val) + + +def main(): + parser = argparse.ArgumentParser( + description="Generate C++ config code from protobuf descriptors") + parser.add_argument("descriptor_set", + help="Path to compiled .desc file (protoc --descriptor_set_out)") + parser.add_argument("output_dir", + help="Directory to write generated C++ files") + args = parser.parse_args() + + # Read descriptor set + desc_path = Path(args.descriptor_set) + if not desc_path.exists(): + print(f"ERROR: Descriptor file not found: {desc_path}") + sys.exit(1) + + with open(desc_path, 'rb') as f: + raw = f.read() + + file_descriptor_set = descriptor_pb2.FileDescriptorSet() + file_descriptor_set.ParseFromString(raw) + + print(f"Loaded {len(file_descriptor_set.file)} proto files") + for fd in file_descriptor_set.file: + if not fd.name.startswith("google/"): + print(f" - {fd.name}: {len(fd.message_type)} messages") + + # Generate code + gen = CodeGenerator(file_descriptor_set) + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + outputs = { + "PrintConfigDef_generated.cpp": gen.generate_init_fff_params(), + "Preset_options_generated.cpp": gen.generate_preset_options(), + "Invalidation_generated.cpp": gen.generate_invalidation_map(), + "OptionKeys_generated.cpp": gen.generate_option_key_lists(), + } + + for filename, content in outputs.items(): + out_path = output_dir / filename + with open(out_path, 'w', encoding='utf-8') as f: + f.write(content) + print(f"Generated: {out_path}") + + print(f"\nDone. {len(gen.fields)} settings processed.") + + +if __name__ == "__main__": + main() diff --git a/tools/config_metadata_pb2.py b/tools/config_metadata_pb2.py new file mode 100644 index 0000000000..27a96a0ecf --- /dev/null +++ b/tools/config_metadata_pb2.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: config_metadata.proto +# Protobuf Python Version: 6.32.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 32, + 1, + '', + 'config_metadata.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import descriptor_pb2 as google_dot_protobuf_dot_descriptor__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x15\x63onfig_metadata.proto\x12\x04orca\x1a google/protobuf/descriptor.proto\"0\n\x0e\x46loatOrPercent\x12\r\n\x05value\x18\x01 \x01(\x01\x12\x0f\n\x07percent\x18\x02 \x01(\x08\"\x1f\n\x07Point2D\x12\t\n\x01x\x18\x01 \x01(\x01\x12\t\n\x01y\x18\x02 \x01(\x01*B\n\nConfigMode\x12\x0f\n\x0bMODE_SIMPLE\x10\x00\x12\x11\n\rMODE_ADVANCED\x10\x01\x12\x10\n\x0cMODE_DEVELOP\x10\x02*G\n\nPresetType\x12\x10\n\x0cPRESET_PRINT\x10\x00\x12\x13\n\x0fPRESET_FILAMENT\x10\x01\x12\x12\n\x0ePRESET_PRINTER\x10\x02*\xaa\x01\n\x10InvalidationStep\x12\x15\n\x11STEP_GCODE_EXPORT\x10\x00\x12\x13\n\x0fSTEP_SKIRT_BRIM\x10\x01\x12\x13\n\x0fSTEP_WIPE_TOWER\x10\x02\x12\x0e\n\nSTEP_SLICE\x10\x03\x12\x13\n\x0fSTEP_PERIMETERS\x10\x04\x12\x0f\n\x0bSTEP_INFILL\x10\x05\x12\x10\n\x0cSTEP_SUPPORT\x10\x06\x12\r\n\tSTEP_NONE\x10\x07*\x81\x01\n\x14OptionListMembership\x12\r\n\tLIST_NONE\x10\x00\x12\x1d\n\x19LIST_EXTRUDER_OPTION_KEYS\x10\x01\x12\x1d\n\x19LIST_FILAMENT_OPTION_KEYS\x10\x02\x12\x1c\n\x18LIST_VARIANT_OPTION_KEYS\x10\x03:.\n\x05label\x12\x1d.google.protobuf.FieldOptions\x18\xd1\x86\x03 \x01(\t:3\n\nfull_label\x12\x1d.google.protobuf.FieldOptions\x18\xd2\x86\x03 \x01(\t:0\n\x07tooltip\x12\x1d.google.protobuf.FieldOptions\x18\xd3\x86\x03 \x01(\t:1\n\x08\x63\x61tegory\x12\x1d.google.protobuf.FieldOptions\x18\xd4\x86\x03 \x01(\t:1\n\x08sidetext\x12\x1d.google.protobuf.FieldOptions\x18\xd5\x86\x03 \x01(\t:2\n\tmin_value\x12\x1d.google.protobuf.FieldOptions\x18\xd6\x86\x03 \x01(\x01:2\n\tmax_value\x12\x1d.google.protobuf.FieldOptions\x18\xd7\x86\x03 \x01(\x01:4\n\x0bmax_literal\x12\x1d.google.protobuf.FieldOptions\x18\xd8\x86\x03 \x01(\x01:?\n\x04mode\x12\x1d.google.protobuf.FieldOptions\x18\xd9\x86\x03 \x01(\x0e\x32\x10.orca.ConfigMode:3\n\nratio_over\x12\x1d.google.protobuf.FieldOptions\x18\xda\x86\x03 \x01(\t:2\n\tmultiline\x12\x1d.google.protobuf.FieldOptions\x18\xdd\x86\x03 \x01(\x08:3\n\nfull_width\x12\x1d.google.protobuf.FieldOptions\x18\xde\x86\x03 \x01(\x08:/\n\x06height\x12\x1d.google.protobuf.FieldOptions\x18\xdf\x86\x03 \x01(\x05:A\n\x06preset\x12\x1d.google.protobuf.FieldOptions\x18\xdb\x86\x03 \x01(\x0e\x32\x10.orca.PresetType:L\n\x0binvalidates\x12\x1d.google.protobuf.FieldOptions\x18\xdc\x86\x03 \x03(\x0e\x32\x16.orca.InvalidationStep:T\n\x0flist_membership\x12\x1d.google.protobuf.FieldOptions\x18\xe2\x86\x03 \x03(\x0e\x32\x1a.orca.OptionListMembership:4\n\x0blegacy_name\x12\x1d.google.protobuf.FieldOptions\x18\xe0\x86\x03 \x01(\t:4\n\x0bis_nullable\x12\x1d.google.protobuf.FieldOptions\x18\xe1\x86\x03 \x01(\x08:1\n\x08gui_type\x12\x1d.google.protobuf.FieldOptions\x18\xe3\x86\x03 \x01(\t:2\n\tgui_flags\x12\x1d.google.protobuf.FieldOptions\x18\xe4\x86\x03 \x01(\t::\n\x11\x65num_keys_map_ref\x12\x1d.google.protobuf.FieldOptions\x18\xe5\x86\x03 \x01(\t:/\n\x06no_cli\x12\x1d.google.protobuf.FieldOptions\x18\xe6\x86\x03 \x01(\x08:1\n\x08readonly\x12\x1d.google.protobuf.FieldOptions\x18\xe7\x86\x03 \x01(\x08:5\n\x0c\x63o_type_hint\x12\x1d.google.protobuf.FieldOptions\x18\xe8\x86\x03 \x01(\t:6\n\rdefault_value\x12\x1d.google.protobuf.FieldOptions\x18\xe9\x86\x03 \x01(\t:4\n\x0bhas_default\x12\x1d.google.protobuf.FieldOptions\x18\xec\x86\x03 \x01(\x08:;\n\x12\x65num_value_entries\x12\x1d.google.protobuf.FieldOptions\x18\xea\x86\x03 \x03(\t:;\n\x12\x65num_label_entries\x12\x1d.google.protobuf.FieldOptions\x18\xeb\x86\x03 \x03(\t:1\n\x08tab_type\x12\x1d.google.protobuf.FieldOptions\x18\xed\x86\x03 \x01(\t:1\n\x08tab_page\x12\x1d.google.protobuf.FieldOptions\x18\xee\x86\x03 \x01(\t:5\n\x0ctab_optgroup\x12\x1d.google.protobuf.FieldOptions\x18\xef\x86\x03 \x01(\t:>\n\x13virtual_preset_keys\x12\x1f.google.protobuf.MessageOptions\x18\xe1\xd4\x03 \x03(\tb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'config_metadata_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_CONFIGMODE']._serialized_start=148 + _globals['_CONFIGMODE']._serialized_end=214 + _globals['_PRESETTYPE']._serialized_start=216 + _globals['_PRESETTYPE']._serialized_end=287 + _globals['_INVALIDATIONSTEP']._serialized_start=290 + _globals['_INVALIDATIONSTEP']._serialized_end=460 + _globals['_OPTIONLISTMEMBERSHIP']._serialized_start=463 + _globals['_OPTIONLISTMEMBERSHIP']._serialized_end=592 + _globals['_FLOATORPERCENT']._serialized_start=65 + _globals['_FLOATORPERCENT']._serialized_end=113 + _globals['_POINT2D']._serialized_start=115 + _globals['_POINT2D']._serialized_end=146 +# @@protoc_insertion_point(module_scope) diff --git a/tools/run_codegen.py b/tools/run_codegen.py new file mode 100644 index 0000000000..4e8bc5fbfa --- /dev/null +++ b/tools/run_codegen.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +Convenience script: runs the codegen pipeline. + +1. Compile .proto -> binary descriptor set (protoc) +2. Generate C++ from descriptors (config_codegen.py) +3. Validate output against original + +Usage: + python tools/run_codegen.py # full pipeline + python tools/run_codegen.py --validate-only # just validate +""" + +import argparse +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PROTO_DIR = ROOT / "src" / "PrintConfigs" +PROTO_GEN_DIR = PROTO_DIR / "generated" +CODEGEN_OUT = ROOT / "codegen" / "generated" +DESC_FILE = ROOT / "config.desc" + + +def run(cmd, **kwargs): + print(f" $ {' '.join(str(c) for c in cmd)}") + result = subprocess.run(cmd, **kwargs) + if result.returncode != 0: + print(f" FAILED (exit code {result.returncode})") + return False + return True + + +def step_compile(): + print("\n=== Step 1: Compile .proto -> descriptor set ===") + proto_files = [f for f in PROTO_GEN_DIR.glob("*.proto") if not f.name.endswith("_gen.proto")] + if not proto_files: + print(" ERROR: No .proto files found") + return False + + return run([ + "protoc", + f"--proto_path={PROTO_DIR}", + f"--proto_path={PROTO_GEN_DIR}", + f"--descriptor_set_out={DESC_FILE}", + "--include_imports", + ] + [str(f) for f in proto_files]) + + +def step_generate(): + print("\n=== Step 2: Generate C++ from descriptors ===") + return run([sys.executable, str(ROOT / "tools" / "config_codegen.py"), + str(DESC_FILE), str(CODEGEN_OUT)]) + + +def step_validate(): + print("\n=== Step 3: Validate ===") + return run([sys.executable, str(ROOT / "tools" / "validate_codegen.py")]) + + +def main(): + parser = argparse.ArgumentParser(description="Run OrcaSlicer config codegen pipeline") + parser.add_argument("--validate-only", action="store_true", + help="Only run validation") + args = parser.parse_args() + + if args.validate_only: + sys.exit(0 if step_validate() else 1) + + for name, fn in [("Compile", step_compile), ("Generate", step_generate), ("Validate", step_validate)]: + if not fn(): + print(f"\n*** Pipeline FAILED at: {name} ***") + sys.exit(1) + + print("\n=== Pipeline completed successfully ===") + + +if __name__ == "__main__": + main() diff --git a/tools/validate_codegen.py b/tools/validate_codegen.py new file mode 100644 index 0000000000..f167485acc --- /dev/null +++ b/tools/validate_codegen.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +""" +Validates generated PrintConfigDef code against the original PrintConfig.cpp. + +Compares setting keys, types, defaults, enum values/labels, and metadata +to ensure the codegen output is a faithful reproduction. +""" + +import re +import json +import sys +from pathlib import Path +from collections import OrderedDict + + +def parse_original_settings(cpp_path): + """Parse the original init_fff_params() into a dict of key -> properties.""" + with open(cpp_path, 'r', encoding='utf-8') as f: + text = f.read() + + m = re.search(r'void PrintConfigDef::init_fff_params\(\)(.*?)^\}', text, re.DOTALL | re.MULTILINE) + if not m: + print("ERROR: Could not find init_fff_params()") + sys.exit(1) + body = m.group(1) + + settings = OrderedDict() + current_key = None + + for line in body.split('\n'): + stripped = line.strip() + + # Detect this->add("key", coType) + add_match = re.search(r'this->add(?:_nullable)?\s*\(\s*"([^"]+)"\s*,\s*(co\w+)\s*\)', stripped) + if add_match: + current_key = add_match.group(1) + co_type = add_match.group(2) + # Last definition wins (handles duplicates) + settings[current_key] = { + 'co_type': co_type, + 'has_default': False, + 'enum_values': 0, + 'enum_labels': 0, + 'has_enum_map': False, + } + continue + + if current_key and current_key in settings: + s = settings[current_key] + if 'set_default_value' in stripped: + s['has_default'] = True + if re.search(r'enum_values\.(?:push_back|emplace_back)', stripped): + s['enum_values'] += 1 + if re.search(r'enum_labels\.(?:push_back|emplace_back)', stripped): + s['enum_labels'] += 1 + if 'enum_keys_map' in stripped and '=' in stripped: + s['has_enum_map'] = True + + return settings + + +def parse_generated_settings(gen_path): + """Parse the generated PrintConfigDef code into a dict of key -> properties.""" + with open(gen_path, 'r', encoding='utf-8') as f: + text = f.read() + + settings = OrderedDict() + current_key = None + + for line in text.split('\n'): + stripped = line.strip() + + add_match = re.search(r'this->add(?:_nullable)?\s*\(\s*"([^"]+)"\s*,\s*(co\w+)\s*\)', stripped) + if add_match: + current_key = add_match.group(1) + co_type = add_match.group(2) + settings[current_key] = { + 'co_type': co_type, + 'has_default': False, + 'enum_values': 0, + 'enum_labels': 0, + 'has_enum_map': False, + } + continue + + if current_key and current_key in settings: + s = settings[current_key] + if 'set_default_value' in stripped: + s['has_default'] = True + if 'enum_values.push_back' in stripped: + s['enum_values'] += 1 + if 'enum_labels.push_back' in stripped: + s['enum_labels'] += 1 + if 'enum_keys_map' in stripped and '=' in stripped: + s['has_enum_map'] = True + + return settings + + +def main(): + orig_path = Path("src/libslic3r/PrintConfig.cpp") + gen_path = Path("codegen/generated/PrintConfigDef_generated.cpp") + + if not orig_path.exists() or not gen_path.exists(): + print("ERROR: Required files not found") + sys.exit(1) + + print("Parsing original...") + orig = parse_original_settings(orig_path) + print(f" {len(orig)} settings") + + print("Parsing generated...") + gen = parse_generated_settings(gen_path) + print(f" {len(gen)} settings") + + # Known exceptions: settings that exist in original but are commented out + # or have runtime-generated enums + known_exceptions = { + 'adaptive_layer_height', # Commented out in original + 'spaghetti_detector', # Commented out in original + } + # Settings with runtime-generated enum values (loop over MaterialType::all()) + runtime_enum_keys = { + 'filament_type', # enum_values from runtime loop + } + + # Compare + errors = [] + warnings = [] + + # Missing keys + orig_keys = set(orig.keys()) + gen_keys = set(gen.keys()) + + missing = orig_keys - gen_keys + extra = gen_keys - orig_keys + + if missing: + real_missing = missing - known_exceptions + if real_missing: + errors.append(f"MISSING from generated ({len(real_missing)}): {sorted(real_missing)}") + noted = missing & known_exceptions + if noted: + warnings.append(f"Known exceptions (commented out in original): {sorted(noted)}") + if extra: + warnings.append(f"EXTRA in generated ({len(extra)}): {sorted(extra)}") + + # Compare shared keys + shared = orig_keys & gen_keys + type_mismatches = [] + default_mismatches = [] + enum_val_mismatches = [] + enum_lbl_mismatches = [] + enum_map_mismatches = [] + + for key in sorted(shared): + o = orig[key] + g = gen[key] + + if o['co_type'] != g['co_type']: + type_mismatches.append(f" {key}: orig={o['co_type']} gen={g['co_type']}") + + if o['has_default'] != g['has_default'] and key not in known_exceptions: + default_mismatches.append(f" {key}: orig={o['has_default']} gen={g['has_default']}") + + if o['enum_values'] != g['enum_values'] and key not in runtime_enum_keys: + enum_val_mismatches.append(f" {key}: orig={o['enum_values']} gen={g['enum_values']}") + + if o['enum_labels'] != g['enum_labels']: + enum_lbl_mismatches.append(f" {key}: orig={o['enum_labels']} gen={g['enum_labels']}") + + if o['has_enum_map'] != g['has_enum_map']: + enum_map_mismatches.append(f" {key}: orig={o['has_enum_map']} gen={g['has_enum_map']}") + + # Report + print("\n=== VALIDATION RESULTS ===\n") + + if type_mismatches: + errors.append(f"TYPE MISMATCHES ({len(type_mismatches)}):\n" + "\n".join(type_mismatches)) + + if default_mismatches: + errors.append(f"DEFAULT MISMATCHES ({len(default_mismatches)}):\n" + "\n".join(default_mismatches)) + + if enum_val_mismatches: + warnings.append(f"ENUM VALUE COUNT MISMATCHES ({len(enum_val_mismatches)}):\n" + "\n".join(enum_val_mismatches)) + + if enum_lbl_mismatches: + warnings.append(f"ENUM LABEL COUNT MISMATCHES ({len(enum_lbl_mismatches)}):\n" + "\n".join(enum_lbl_mismatches)) + + if enum_map_mismatches: + warnings.append(f"ENUM MAP MISMATCHES ({len(enum_map_mismatches)}):\n" + "\n".join(enum_map_mismatches)) + + if warnings: + print("WARNINGS:") + for w in warnings: + print(f" {w}") + print() + + if errors: + print("ERRORS:") + for e in errors: + print(f" {e}") + print(f"\nValidation FAILED with {len(errors)} error(s)") + sys.exit(1) + else: + print(f"All {len(shared)} shared settings validated successfully") + if extra: + print(f" ({len(extra)} extra settings from axis expansion)") + print("\nValidation PASSED") + + +if __name__ == "__main__": + main()