diff --git a/localization/i18n/OrcaSlicer.pot b/localization/i18n/OrcaSlicer.pot index db62fb4443..92b25f26c0 100644 --- a/localization/i18n/OrcaSlicer.pot +++ b/localization/i18n/OrcaSlicer.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -4571,6 +4571,9 @@ msgid "" "No - Cancel enabling spiral mode" msgstr "" +msgid "N/A" +msgstr "" + msgid "Printing" msgstr "" @@ -5019,9 +5022,6 @@ msgstr "" msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "" -msgid "N/A" -msgstr "" - msgid "System agents" msgstr "" @@ -5951,6 +5951,12 @@ msgstr "" msgid "Save current project as" msgstr "" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "" @@ -7696,6 +7702,12 @@ msgstr "" msgid "Customized Preset" msgstr "" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "" @@ -8074,6 +8086,17 @@ msgstr "" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "" +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "" @@ -9124,9 +9147,6 @@ msgstr "" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "" -msgid "Publish" -msgstr "" - msgid "Publish was canceled" msgstr "" @@ -9142,6 +9162,64 @@ msgstr "" msgid "Jump to webpage" msgstr "" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, possible-c-format, possible-boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, possible-c-format, possible-boost-format msgid "Save %s as" msgstr "" @@ -19337,6 +19415,15 @@ msgstr "" msgid "Skipping objects." msgstr "" +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "" diff --git a/localization/i18n/ca/OrcaSlicer_ca.po b/localization/i18n/ca/OrcaSlicer_ca.po index 2b42f300ac..91ba0e2f2a 100644 --- a/localization/i18n/ca/OrcaSlicer_ca.po +++ b/localization/i18n/ca/OrcaSlicer_ca.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2025-03-15 10:55+0100\n" "Last-Translator: \n" "Language-Team: \n" @@ -4995,6 +4995,9 @@ msgstr "" "Sí: canviar aquesta configuració i activar el mode d'espiral automàticament\n" "No - Renunciar a utilitzar el mode espiral aquesta vegada" +msgid "N/A" +msgstr "N/D" + msgid "Printing" msgstr "Imprimint" @@ -5456,9 +5459,6 @@ msgstr "Patró no vàlid. Utilitzeu N, N#K, o una llista separada per comes amb msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Format no vàlid. Format vectorial esperat: \"%1%\"" -msgid "N/A" -msgstr "N/D" - # AI Translated msgid "System agents" msgstr "Agents del sistema" @@ -6421,6 +6421,12 @@ msgstr "Desa el projecte com a" msgid "Save current project as" msgstr "Desar el projecte actual com" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importar 3MF STL/STEP/SVG/OBJ/AMF" @@ -8275,6 +8281,12 @@ msgstr "Confirmeu que els Codis-G d'aquests perfils són segurs per evitar danys msgid "Customized Preset" msgstr "Perfil personalitzat" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + # AI Translated msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Els noms dels components dins del fitxer STEP no tenen format UTF-8!" @@ -8687,6 +8699,17 @@ msgstr "Desa el fitxer Laminat com a:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "El fitxer %s s'ha enviat a l'emmagatzematge de la impressora i es pot visualitzar a la impressora." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Publicar" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "El tipus de broquet no està establert. Establiu el broquet i torneu-ho a provar." @@ -9880,9 +9903,6 @@ msgstr "Anar a la pàgina web de publicació de models" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Nota: La preparació pot trigar uns quants minuts. Si us plau, sigui pacient." -msgid "Publish" -msgstr "Publicar" - msgid "Publish was canceled" msgstr "La publicació ha estat cancel·lada" @@ -9898,6 +9918,64 @@ msgstr "Carregant dades" msgid "Jump to webpage" msgstr "Anar a la pàgina web" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Desar %s com a" @@ -21387,6 +21465,15 @@ msgstr "Aquesta acció no es pot desfer. Continuar?" msgid "Skipping objects." msgstr "Ometent objectes." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Seleccioneu filament" diff --git a/localization/i18n/cs/OrcaSlicer_cs.po b/localization/i18n/cs/OrcaSlicer_cs.po index d2ef18cd2a..9884888723 100644 --- a/localization/i18n/cs/OrcaSlicer_cs.po +++ b/localization/i18n/cs/OrcaSlicer_cs.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: \n" "Last-Translator: Jakub Hencl\n" "Language-Team: \n" @@ -4953,6 +4953,10 @@ msgstr "" "Ano – změnit tato nastavení a automaticky povolit spirálový režim\n" "Ne – tentokrát nepoužít spirálový režim" +# AI Translated +msgid "N/A" +msgstr "N/A" + msgid "Printing" msgstr "Tisk" @@ -5412,10 +5416,6 @@ msgstr "Neplatný vzor. Použijte N, N#K nebo seznam oddělený čárkami s voli msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Neplatný formát. Očekávaný vektorový formát: \"%1%\"" -# AI Translated -msgid "N/A" -msgstr "N/A" - # AI Translated msgid "System agents" msgstr "Systémoví agenti" @@ -6382,6 +6382,12 @@ msgstr "Uložit projekt jako" msgid "Save current project as" msgstr "Uložit aktuální projekt jako" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importovat 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8237,6 +8243,12 @@ msgstr "Potvrďte prosím, že je G-code v těchto předvolbách bezpečný, aby msgid "Customized Preset" msgstr "Přizpůsobená předvolba" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Názvy komponent v souboru STEP nejsou ve formátu UTF-8!" @@ -8647,6 +8659,17 @@ msgstr "Uložit rozřezaný soubor jako:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Soubor %s byl odeslán do úložiště tiskárny a lze jej zobrazit na tiskárně." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Publikovat" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Typ trysky není nastaven. Nastavte prosím trysku a zkuste to znovu." @@ -9828,9 +9851,6 @@ msgstr "Přejít na webovou stránku publikace modelu" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Poznámka: Příprava může trvat několik minut. Buďte prosím trpěliví." -msgid "Publish" -msgstr "Publikovat" - msgid "Publish was canceled" msgstr "Publikování bylo zrušeno" @@ -9846,6 +9866,64 @@ msgstr "Nahrávání dat" msgid "Jump to webpage" msgstr "Přejít na webovou stránku" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Uložit %s jako" @@ -21331,6 +21409,15 @@ msgstr "Tuto akci nelze vrátit zpět. Pokračovat?" msgid "Skipping objects." msgstr "Přeskakování objektů." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + # AI Translated msgid "Select Filament" msgstr "Vybrat filament" diff --git a/localization/i18n/de/OrcaSlicer_de.po b/localization/i18n/de/OrcaSlicer_de.po index 4ff61fb6ea..bb096c0a23 100644 --- a/localization/i18n/de/OrcaSlicer_de.po +++ b/localization/i18n/de/OrcaSlicer_de.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: \n" "Last-Translator: Heiko Liebscher \n" "Language-Team: \n" @@ -4859,6 +4859,9 @@ msgstr "" "Ja - Diese Einstellungen ändern und den Spiralmodus automatisch aktivieren\n" "Nein - Spiralmodus nicht aktivieren" +msgid "N/A" +msgstr "Nicht verfügbar" + msgid "Printing" msgstr "Drucken" @@ -5318,9 +5321,6 @@ msgstr "Ungültiges Muster. Verwenden Sie N, N#K oder eine durch Kommas getrennt msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Ungültiges Format. Erwartetes Vektorformat: \"%1%\"" -msgid "N/A" -msgstr "Nicht verfügbar" - # AI Translated msgid "System agents" msgstr "Systemagenten" @@ -6274,6 +6274,12 @@ msgstr "Projekt speichern als" msgid "Save current project as" msgstr "Aktuelles Projekt speichern als" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importiere 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8107,6 +8113,12 @@ msgstr "Bitte bestätigen Sie, dass die G-Codes innerhalb dieser Profile sicher msgid "Customized Preset" msgstr "Benutzerdefinierte Profile" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Der Name der Komponenten in der Step-Datei ist nicht im UTF-8-Format!" @@ -8519,6 +8531,17 @@ msgstr "Geslicte Datei speichern unter:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Die Datei %s wurde an den Speicher des Druckers gesendet und kann auf dem Drucker angezeigt werden." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Veröffentlichen" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Die Düsenart ist nicht eingestellt. Bitte stellen Sie die Düse ein und versuchen Sie es erneut." @@ -9662,9 +9685,6 @@ msgstr "Zur Modellveröffentlichungs-Webseite springen" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Hinweis: Die Vorbereitung kann einige Minuten dauern. Bitte haben Sie Geduld." -msgid "Publish" -msgstr "Veröffentlichen" - msgid "Publish was canceled" msgstr "Veröffentlichung wurde abgebrochen" @@ -9680,6 +9700,64 @@ msgstr "Daten werden hochgeladen" msgid "Jump to webpage" msgstr "Zu einer Website springen" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "%s speichern als" @@ -20816,6 +20894,15 @@ msgstr "Diese Aktion kann nicht rückgängig gemacht werden. Fortsetzen?" msgid "Skipping objects." msgstr "Objekte werden übersprungen." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Filament auswählen" diff --git a/localization/i18n/en/OrcaSlicer_en.po b/localization/i18n/en/OrcaSlicer_en.po index 67d4d196d4..b949aea730 100644 --- a/localization/i18n/en/OrcaSlicer_en.po +++ b/localization/i18n/en/OrcaSlicer_en.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2026-06-17 15:44-0300\n" "Last-Translator: Alexandre Folle de Menezes\n" "Language-Team: \n" @@ -4567,6 +4567,9 @@ msgid "" "No - Cancel enabling spiral mode" msgstr "" +msgid "N/A" +msgstr "" + msgid "Printing" msgstr "" @@ -5015,9 +5018,6 @@ msgstr "" msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "" -msgid "N/A" -msgstr "" - msgid "System agents" msgstr "" @@ -5947,6 +5947,12 @@ msgstr "" msgid "Save current project as" msgstr "" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "" @@ -7692,6 +7698,12 @@ msgstr "" msgid "Customized Preset" msgstr "" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "" @@ -8070,6 +8082,17 @@ msgstr "" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "" +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "" @@ -9120,9 +9143,6 @@ msgstr "" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "" -msgid "Publish" -msgstr "" - msgid "Publish was canceled" msgstr "" @@ -9138,6 +9158,64 @@ msgstr "" msgid "Jump to webpage" msgstr "" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "" @@ -19333,6 +19411,15 @@ msgstr "" msgid "Skipping objects." msgstr "" +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "" diff --git a/localization/i18n/es/OrcaSlicer_es.po b/localization/i18n/es/OrcaSlicer_es.po index bc8e1e9f79..e1d86c2fe8 100644 --- a/localization/i18n/es/OrcaSlicer_es.po +++ b/localization/i18n/es/OrcaSlicer_es.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: \n" "Last-Translator: Ian A. Bassi <>\n" "Language-Team: \n" @@ -4729,6 +4729,9 @@ msgstr "" "Sí - Cambiar estos ajustes y activar el modo espiral automáticamente\n" "No - Dejar de usar el modo espiral esta vez" +msgid "N/A" +msgstr "N/A" + msgid "Printing" msgstr "Imprimiendo" @@ -5182,9 +5185,6 @@ msgstr "Patrón inválido. Use N, N#K, o una lista separada por comas con #K opc msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Formato inválido. Formato de vector esperado: \"%1%\"" -msgid "N/A" -msgstr "N/A" - msgid "System agents" msgstr "Agentes del sistema" @@ -6129,6 +6129,12 @@ msgstr "Guardar proyecto como" msgid "Save current project as" msgstr "Guardar el proyecto actual como" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importar 3MF/STL/STEP/SVG/OBJ/AMF" @@ -7910,6 +7916,12 @@ msgstr "¡Por favor, confirme que el G-Code dentro de los perfiles son seguros p msgid "Customized Preset" msgstr "Perfil Personalizado" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "¡El nombre de los componentes dentro del archivo de pasos no tiene formato UTF-8!" @@ -8305,6 +8317,17 @@ msgstr "Guardar el archivo laminado como:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "El archivo %s ha sido mandado al almacenamiento de la impresora y puede ser visualizado en la impresora." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Publicar" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "El tipo de boquilla no está establecido. Configure la boquilla e inténtelo de nuevo." @@ -9419,9 +9442,6 @@ msgstr "Ir a la página web de publicación de modelos" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Nota: La preparación puede llevar varios minutos. Por favor, sea paciente." -msgid "Publish" -msgstr "Publicar" - msgid "Publish was canceled" msgstr "La publicación fue cancelada" @@ -9437,6 +9457,64 @@ msgstr "Cargando datos" msgid "Jump to webpage" msgstr "Ir a la página web" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Guardar %s como" @@ -20409,6 +20487,15 @@ msgstr "Esta acción no se puede deshacer. ¿Continuar?" msgid "Skipping objects." msgstr "Omitiendo objetos." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Seleccionar Filamento" diff --git a/localization/i18n/eu/OrcaSlicer_eu.po b/localization/i18n/eu/OrcaSlicer_eu.po index 9ee2e6451d..a475d76ab2 100644 --- a/localization/i18n/eu/OrcaSlicer_eu.po +++ b/localization/i18n/eu/OrcaSlicer_eu.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2026-07-20 13:33+0200\n" "Last-Translator: Manu Goiogana \n" "Language-Team: \n" @@ -4771,6 +4771,9 @@ msgstr "" "Bai - Aldatu ezarpen hauek eta gaitu espiral/loreontzi modua automatikoki\n" "Ez - Utzi bertan behera espiral modua gaitzea" +msgid "N/A" +msgstr "N/A" + msgid "Printing" msgstr "Inprimatzen" @@ -5231,9 +5234,6 @@ msgstr "Patroi baliogabea. Erabili N, N#K edo komaz bereizitako zerrenda bat, sa msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Formatuak ez du balio. Espero den formatu bektoriala: \"%1%\"" -msgid "N/A" -msgstr "N/A" - msgid "System agents" msgstr "Sistema-agenteak" @@ -6175,6 +6175,12 @@ msgstr "Gorde proiektua honela" msgid "Save current project as" msgstr "Gorde uneko proiektua honela" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Inportatu 3MF/STL/STEP/SVG/OBJ/AMF" @@ -7988,6 +7994,12 @@ msgstr "Berretsi aurrezarpen hauetako G-codea segurua dela, makinari kalterik ez msgid "Customized Preset" msgstr "Aurrezarpen pertsonalizatua" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "STEP fitxategiko osagai-izena(k) ez dago/daude UTF-8 formatuan!" @@ -8388,6 +8400,17 @@ msgstr "Gorde xerratutako fitxategia honela:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "%s fitxategia inprimagailuaren biltegiratze-eremura bidali da eta inprimagailuan ikus daiteke." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Argitaratu" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Pita mota ez dago ezarrita. Ezarri pita eta saiatu berriro." @@ -9505,9 +9528,6 @@ msgstr "Joan modeloa argitaratzeko web-orrira" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Oharra: prestaketak minutu batzuk iraun ditzake. Izan pazientzia." -msgid "Publish" -msgstr "Argitaratu" - msgid "Publish was canceled" msgstr "Argitaratzea bertan behera utzi da" @@ -9523,6 +9543,64 @@ msgstr "Datuak igotzen" msgid "Jump to webpage" msgstr "Joan web-orrira" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Gorde %s honela" @@ -20596,6 +20674,15 @@ msgstr "Ekintza hau ezin da desegin. Jarraitu?" msgid "Skipping objects." msgstr "Objektuak saltatzen." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Hautatu filamentua" diff --git a/localization/i18n/fr/OrcaSlicer_fr.po b/localization/i18n/fr/OrcaSlicer_fr.po index e56aee63a7..de356edd7d 100644 --- a/localization/i18n/fr/OrcaSlicer_fr.po +++ b/localization/i18n/fr/OrcaSlicer_fr.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: Guislain Cyril, Thomas Lété\n" @@ -4809,6 +4809,9 @@ msgstr "" "Oui - Modifiez ces paramètres et activez automatiquement le mode spirale/vase\n" "Non - Annuler l'activation du mode spirale" +msgid "N/A" +msgstr "N/A" + msgid "Printing" msgstr "Impression" @@ -5268,9 +5271,6 @@ msgstr "Motif invalide. Utilisez N, N#K, ou une liste séparée par des virgules msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Format invalide. Format vectoriel attendu : \"%1%\"" -msgid "N/A" -msgstr "N/A" - # AI Translated msgid "System agents" msgstr "Agents système" @@ -6223,6 +6223,12 @@ msgstr "Enregistrer le projet sous" msgid "Save current project as" msgstr "Enregistrer le projet actuel sous" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importer des fichiers 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8044,6 +8050,12 @@ msgstr "Veuillez vous assurer que les G-codes de ces préréglages sont sûrs af msgid "Customized Preset" msgstr "Préréglage personnalisé" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Le nom des composants dans le fichier STEP n'est pas au format UTF-8 !" @@ -8445,6 +8457,17 @@ msgstr "Enregistrer le fichier découpé sous :" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Le fichier %s a été envoyé vers l'espace de stockage de l'imprimante et peut être visualisé sur l'imprimante." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Publier" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Le type de buse n'est pas défini. Veuillez définir la buse et réessayer." @@ -9576,9 +9599,6 @@ msgstr "Accéder à la page internet de publication des modèles" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Remarque : La préparation peut prendre plusieurs minutes. Veuillez patienter." -msgid "Publish" -msgstr "Publier" - msgid "Publish was canceled" msgstr "La publication a été annulée" @@ -9594,6 +9614,64 @@ msgstr "Téléversement des données" msgid "Jump to webpage" msgstr "Ouvrir la page internet" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Enregistrer %s sous" @@ -20708,6 +20786,15 @@ msgstr "Cette action ne peut pas être annulée. Continuer ?" msgid "Skipping objects." msgstr "Ignorer des objets." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Sélectionner le filament" diff --git a/localization/i18n/hu/OrcaSlicer_hu.po b/localization/i18n/hu/OrcaSlicer_hu.po index a15d471073..2c00949e15 100644 --- a/localization/i18n/hu/OrcaSlicer_hu.po +++ b/localization/i18n/hu/OrcaSlicer_hu.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "Language: hu\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -4905,6 +4905,10 @@ msgstr "" "Igen - Módosítsd ezeket a beállításokat, és automatikusan kapcsold be a spirál módot\n" "Nem - Most ne kapcsold be a spirál módot" +# AI Translated +msgid "N/A" +msgstr "N/A" + msgid "Printing" msgstr "Nyomtatás" @@ -5364,10 +5368,6 @@ msgstr "Érvénytelen minta. Használj N, N#K formátumot, vagy vesszővel elvá msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Érvénytelen formátum. Elvárt vektor formátum: \"%1%\"" -# AI Translated -msgid "N/A" -msgstr "N/A" - # AI Translated msgid "System agents" msgstr "Rendszerügynökök" @@ -6325,6 +6325,12 @@ msgstr "Projekt mentése másként" msgid "Save current project as" msgstr "Jelenlegi projekt mentése másként" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importálás 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8159,6 +8165,12 @@ msgstr "Kérlek, győződj meg arról, hogy a beállításokban található G-k msgid "Customized Preset" msgstr "Egyedi beállítás" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "A STEP fájlon belüli komponens neve nem UTF-8 formátumban van!" @@ -8571,6 +8583,17 @@ msgstr "Szeletelt fájl mentése mint:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "A(z) %s fájlt elküldtük a nyomtató tárhelyére. A fájl a nyomtatón tekinthető meg." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Közzététel" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "A fúvókatípus nincs beállítva. Állítsd be a fúvókát, majd próbáld újra." @@ -9736,9 +9759,6 @@ msgstr "Ugrás a modell közzététele weboldalra" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Megjegyzés: Az előkészítés több percig is eltarthat. Kérlek, várj." -msgid "Publish" -msgstr "Közzététel" - msgid "Publish was canceled" msgstr "A közzététel törlésre került" @@ -9754,6 +9774,64 @@ msgstr "Adatok feltöltése" msgid "Jump to webpage" msgstr "Ugrás a weboldalra" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "%s mentése" @@ -21110,6 +21188,15 @@ msgstr "Ez a művelet nem vonható vissza. Folytatod?" msgid "Skipping objects." msgstr "Objektumok kihagyása." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Filament kiválasztása" diff --git a/localization/i18n/it/OrcaSlicer_it.po b/localization/i18n/it/OrcaSlicer_it.po index 9af642f536..1aa2f15701 100644 --- a/localization/i18n/it/OrcaSlicer_it.po +++ b/localization/i18n/it/OrcaSlicer_it.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: \n" @@ -4907,6 +4907,9 @@ msgstr "" "Sì - Modifica queste impostazioni ed abilita la modalità spirale automaticamente\n" "No - Annulla l'attivazione della modalità a spirale" +msgid "N/A" +msgstr "N/D" + msgid "Printing" msgstr "Stampa" @@ -5366,9 +5369,6 @@ msgstr "Schema non valido. Utilizzare N, N#K o un elenco separato da virgole con msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Formato non valido. Formato vettoriale previsto: \"%1%\"" -msgid "N/A" -msgstr "N/D" - # AI Translated msgid "System agents" msgstr "Agenti di sistema" @@ -6327,6 +6327,12 @@ msgstr "Salva progetto con nome" msgid "Save current project as" msgstr "Salva progetto corrente con nome" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importa 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8163,6 +8169,12 @@ msgstr "Si prega di confermare che i G-code all'interno di questi profili sono s msgid "Customized Preset" msgstr "Profilo personalizzato" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Il nome dei componenti all'interno del file STEP non è in formato UTF-8!" @@ -8570,6 +8582,17 @@ msgstr "Salva file elaborato con nome:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Il file %s è stato inviato alla memoria della stampante e può essere visualizzato da lì." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Pubblica" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Il tipo di ugello non è impostato. Impostare l'ugello e riprovare." @@ -9754,9 +9777,6 @@ msgstr "Vai alla pagina web di pubblicazione del modello" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Nota: la preparazione può richiedere alcuni minuti. Si prega di avere pazienza." -msgid "Publish" -msgstr "Pubblica" - msgid "Publish was canceled" msgstr "La pubblicazione è stata annullata" @@ -9772,6 +9792,64 @@ msgstr "Caricamento dati" msgid "Jump to webpage" msgstr "Vai alla pagina web" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Salva %s con nome" @@ -21134,6 +21212,15 @@ msgstr "Questa azione non può essere annullata. Continuare?" msgid "Skipping objects." msgstr "Salto degli oggetti." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Seleziona filamento" diff --git a/localization/i18n/ja/OrcaSlicer_ja.po b/localization/i18n/ja/OrcaSlicer_ja.po index 8ee8813f1d..9067928361 100644 --- a/localization/i18n/ja/OrcaSlicer_ja.po +++ b/localization/i18n/ja/OrcaSlicer_ja.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: \n" @@ -4920,6 +4920,10 @@ msgstr "" "はい - 変更して、スパイラルモードを有効にします\n" "いいえ - 変更せず、スパイラルモードを有効しません" +# AI Translated +msgid "N/A" +msgstr "N/A" + msgid "Printing" msgstr "造形中" @@ -5378,10 +5382,6 @@ msgstr "無効なパターンです。N、N#K、またはオプション#K付き msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "無効なフォーマット、%1%であるはずです。" -# AI Translated -msgid "N/A" -msgstr "N/A" - # AI Translated msgid "System agents" msgstr "システムエージェント" @@ -6336,6 +6336,12 @@ msgstr "プロジェクトを名前を付けて保存" msgid "Save current project as" msgstr "プロジェクトを名前を付けて保存" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "3MF/STL/STEP/SVG/OBJ/AMFをインポート" @@ -8177,6 +8183,12 @@ msgstr "これらのプリセット内のG-codeがマシンに損傷を与えな msgid "Customized Preset" msgstr "カスタマイズされたプリセット" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "ファイルのエンコーディング方式はUTF-8形式ではありません" @@ -8589,6 +8601,17 @@ msgstr "名前を付けて保存:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "%sを送信しました、プリンターにて確認できます" +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "公開する" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "ノズルタイプが設定されていません。ノズルを設定して再試行してください。" @@ -9776,9 +9799,6 @@ msgstr "モデル公開ページに移動" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "注意: 準備するには数分かかる場合があります、暫くお待ち下さい。" -msgid "Publish" -msgstr "公開する" - msgid "Publish was canceled" msgstr "公開は取り消しました" @@ -9794,6 +9814,64 @@ msgstr "データをアップロード中" msgid "Jump to webpage" msgstr "ウェブページに移動" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "%sを名前つけて保存" @@ -21678,6 +21756,15 @@ msgstr "この操作は元に戻せません。続行しますか?" msgid "Skipping objects." msgstr "オブジェクトをスキップ中。" +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "フィラメントを選択" diff --git a/localization/i18n/ko/OrcaSlicer_ko.po b/localization/i18n/ko/OrcaSlicer_ko.po index c415c90195..fef0a09398 100644 --- a/localization/i18n/ko/OrcaSlicer_ko.po +++ b/localization/i18n/ko/OrcaSlicer_ko.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2025-06-02 17:12+0900\n" "Last-Translator: crwusiz \n" "Language-Team: \n" @@ -4931,6 +4931,10 @@ msgstr "" "예 - 이 설정을 변경하고 나선 모드를 자동으로 활성화합니다\n" "아니오 - 이번에는 나선 모드 사용을 포기합니다" +# AI Translated +msgid "N/A" +msgstr "해당 없음" + msgid "Printing" msgstr "출력 중" @@ -5390,10 +5394,6 @@ msgstr "잘못된 패턴입니다. N, N#K 또는 항목당 선택적 #K가 있 msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "잘못된 형식입니다. 필요한 벡터 형식: \"%1%\"" -# AI Translated -msgid "N/A" -msgstr "해당 없음" - # AI Translated msgid "System agents" msgstr "시스템 에이전트" @@ -6350,6 +6350,12 @@ msgstr "프로젝트 다른 이름으로 저장" msgid "Save current project as" msgstr "현재 프로젝트 다른 이름으로 저장" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "3MF/STL/STEP/SVG/OBJ/AMF 가져오기" @@ -8195,6 +8201,12 @@ msgstr "이러한 사전 설정 내의 Gcode가 기계 손상을 방지할 수 msgid "Customized Preset" msgstr "사용자 정의 프리셋" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + # AI Translated msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "STEP 파일 내부의 구성 요소 이름이 UTF-8 형식이 아닙니다!" @@ -8622,6 +8634,17 @@ msgstr "슬라이스 파일을 다음으로 저장:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "%s 파일이 프린터의 저장 공간으로 전송되었으며 프린터에서 볼 수 있습니다." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "게시" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "노즐 유형이 설정되지 않았습니다. 노즐을 설정하고 다시 시도하세요." @@ -9865,9 +9888,6 @@ msgstr "모델 게시 웹 페이지로 이동" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "참고: 준비하는 데 몇 분 정도 걸릴 수 있습니다. 조금만 기다려 주십시오." -msgid "Publish" -msgstr "게시" - msgid "Publish was canceled" msgstr "게시가 취소되었습니다" @@ -9884,6 +9904,64 @@ msgstr "데이터 업로드 중" msgid "Jump to webpage" msgstr "웹 페이지로 이동" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "%s을(를) 다음으로 저장" @@ -21536,6 +21614,15 @@ msgstr "이 작업은 취소할 수 없습니다. 계속하시겠습니까?" msgid "Skipping objects." msgstr "물체 건너뛰기" +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "필라멘트 선택" diff --git a/localization/i18n/list.txt b/localization/i18n/list.txt index 5174679abf..a36ecd5cab 100644 --- a/localization/i18n/list.txt +++ b/localization/i18n/list.txt @@ -136,6 +136,7 @@ src/slic3r/GUI/BackgroundSlicingProcess.cpp src/slic3r/GUI/BedShapeDialog.cpp src/slic3r/GUI/BedShapeDialog.hpp src/slic3r/GUI/ConfigManipulation.cpp +src/slic3r/GUI/ConfigValueFormatter.cpp src/slic3r/GUI/DeviceManager.cpp src/slic3r/GUI/DeviceErrorDialog.cpp src/slic3r/GUI/ExtraRenderers.cpp @@ -175,6 +176,7 @@ src/slic3r/GUI/ProgressStatusBar.cpp src/slic3r/GUI/PlateSettingsDialog.cpp src/slic3r/GUI/PrivacyUpdateDialog.cpp src/slic3r/GUI/PublishDialog.cpp +src/slic3r/GUI/PublishSettingsDialog.cpp src/slic3r/GUI/SavePresetDialog.cpp src/slic3r/GUI/Search.cpp src/slic3r/GUI/Selection.cpp diff --git a/localization/i18n/lt/OrcaSlicer_lt.po b/localization/i18n/lt/OrcaSlicer_lt.po index b3458ec0e4..80273427f0 100644 --- a/localization/i18n/lt/OrcaSlicer_lt.po +++ b/localization/i18n/lt/OrcaSlicer_lt.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2026-07-02 14:13+0300\n" "Last-Translator: Gintaras Kučinskas \n" "Language-Team: \n" @@ -4893,6 +4893,9 @@ msgstr "" "Taip – pakeisti šiuos nustatymus ir automatiškai įjungti spiralinį režimą\n" "Ne – nenaudoti spiralinio režimo" +msgid "N/A" +msgstr "N/A" + msgid "Printing" msgstr "Spausdinimas" @@ -5352,9 +5355,6 @@ msgstr "Neteisingas šablonas. Naudokite N, N#K arba kableliais atskirtą sąra msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Netinkamas formatas. Tinkamas vektorinis formatas: \"%1%\"" -msgid "N/A" -msgstr "N/A" - # AI Translated msgid "System agents" msgstr "Sisteminiai agentai" @@ -6312,6 +6312,12 @@ msgstr "Įrašyti projektą kaip" msgid "Save current project as" msgstr "Įrašyti dabartinį projektą kaip" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importuoti 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8153,6 +8159,12 @@ msgstr "Patvirtinkite, kad šiuose profiliuose esantis G-kodas yra saugus, kad i msgid "Customized Preset" msgstr "Pritaikytas profilis" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Komponentų pavadinimai STEP faile nėra UTF-8 formato!" @@ -8564,6 +8576,17 @@ msgstr "Išsaugoti susluoksniuotą failą kaip:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Failas %s išsiųstas į spausdintuvo laikmeną ir gali būti peržiūrimas spausdintuve." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Talpinti" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Purkštuko tipas nenustatytas. Nustatykite purkštuką ir bandykite dar kartą." @@ -9693,9 +9716,6 @@ msgstr "Pereiti į modelio talpinimo interneto puslapį" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Pastaba: paruošimas gali užtrukti kelias minutes. Būkite kantrūs." -msgid "Publish" -msgstr "Talpinti" - msgid "Publish was canceled" msgstr "Publikavimas buvo atšauktas" @@ -9711,6 +9731,64 @@ msgstr "Įkeliami duomenys" msgid "Jump to webpage" msgstr "Pereiti į interneto puslapį" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Išsaugoti %s kaip" @@ -20865,6 +20943,15 @@ msgstr "Šio veiksmo atšaukti nebus galima. Tęsti?" msgid "Skipping objects." msgstr "Praleidžiami objektai." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Pasirinkti giją" diff --git a/localization/i18n/nl/OrcaSlicer_nl.po b/localization/i18n/nl/OrcaSlicer_nl.po index 4fd3601c0e..720f4ba6f5 100644 --- a/localization/i18n/nl/OrcaSlicer_nl.po +++ b/localization/i18n/nl/OrcaSlicer_nl.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: \n" @@ -5325,6 +5325,9 @@ msgstr "" "Ja - Pas de instellingen aan en zet de vaas modus automatisch aan\n" "Nee - Pas de vaas modus deze keer niet toe" +msgid "N/A" +msgstr "N/B" + msgid "Printing" msgstr "Printen" @@ -5861,9 +5864,6 @@ msgstr "Ongeldig patroon. Gebruik N, N#K of een door komma's gescheiden lijst me msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Onjuist formaat. Het Vector formaat wordt verwacht: \"%1%\"" -msgid "N/A" -msgstr "N/B" - # AI Translated msgid "System agents" msgstr "Systeemagenten" @@ -6888,6 +6888,12 @@ msgstr "Bewaar project als" msgid "Save current project as" msgstr "Bewaar huidig project als" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "3MF/STL/STEP/SVG/OBJ/AMF importeren" @@ -8904,6 +8910,12 @@ msgstr "Controleer of de G-codes in deze presets veilig zijn om schade aan de ma msgid "Customized Preset" msgstr "Aangepaste voorinstelling" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Naam van componenten in step-bestand is niet UTF-8-formaat!" @@ -9339,6 +9351,17 @@ msgstr "Bewaar het geslicede bestand als:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Het bestand %s is naar de opslagruimte van de printer gestuurd en kan op de printer worden bekeken." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Publiceren" + # AI Translated msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Het mondstuktype is niet ingesteld. Stel het mondstuk in en probeer het opnieuw." @@ -10626,9 +10649,6 @@ msgstr "Ga naar de website om het model te publiceren" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Notitie: het voorbereiden kan enkele minuten duren. Even geduld alstublieft." -msgid "Publish" -msgstr "Publiceren" - msgid "Publish was canceled" msgstr "Het publiceren is geannuleerd" @@ -10645,6 +10665,64 @@ msgstr "Gegevens uploaden" msgid "Jump to webpage" msgstr "Ga naar de website" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Bewaar %s als" @@ -23245,6 +23323,15 @@ msgstr "Deze actie kan niet ongedaan worden gemaakt. Doorgaan?" msgid "Skipping objects." msgstr "Objecten worden overgeslagen." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Selecteer filament" diff --git a/localization/i18n/pl/OrcaSlicer_pl.po b/localization/i18n/pl/OrcaSlicer_pl.po index 0f6fa10e3b..3d7fb083ef 100644 --- a/localization/i18n/pl/OrcaSlicer_pl.po +++ b/localization/i18n/pl/OrcaSlicer_pl.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: OrcaSlicer 2.3.0-rc\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: \n" "Last-Translator: Krzysztof Morga <>\n" "Language-Team: \n" @@ -5013,6 +5013,10 @@ msgstr "" "Tak - Zmień te ustawienia automatycznie i włącz tryb Wazy\n" "Nie - Zrezygnuj tym razem z używania trybu Wazy" +# AI Translated +msgid "N/A" +msgstr "Nie dotyczy" + msgid "Printing" msgstr "Drukowanie" @@ -5480,10 +5484,6 @@ msgstr "Nieprawidłowy wzorzec. Użyj N, N#K lub listy rozdzielonej przecinkami msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Nieprawidłowy format. Oczekiwano formatu wektorowego: „%1%”" -# AI Translated -msgid "N/A" -msgstr "Nie dotyczy" - # AI Translated msgid "System agents" msgstr "Agenci systemowi" @@ -6469,6 +6469,12 @@ msgstr "Zapisz projekt jako" msgid "Save current project as" msgstr "Zapisz bieżący projekt jako" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importuj 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8353,6 +8359,12 @@ msgstr "Proszę potwierdź, że G-code w tych profilach jest bezpieczny, aby zap msgid "Customized Preset" msgstr "Dostosowany profil" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Nazwa komponentów w pliku step nie jest w formacie UTF-8!" @@ -8779,6 +8791,17 @@ msgstr "Zapisz plik po wykonaniu cięcia jako:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Plik %s został wysłany do pamięci drukarki i można go obejrzeć na urządzeniu." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Opublikuj" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Nie ustawiono typu dyszy Wprowadź ustawienia dyszy i spróbuj ponownie." @@ -10021,9 +10044,6 @@ msgstr "Przejdź do strony publikacji modelu" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Uwaga: Przygotowanie może zająć kilka minut. Proszę o cierpliwość." -msgid "Publish" -msgstr "Opublikuj" - msgid "Publish was canceled" msgstr "Publikacja została anulowana" @@ -10040,6 +10060,64 @@ msgstr "Przesyłanie danych" msgid "Jump to webpage" msgstr "Przejdź na stronę" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Zapisz %s jako" @@ -21723,6 +21801,15 @@ msgstr "Tego działania nie będzie można cofnąć. Kontynuować?" msgid "Skipping objects." msgstr "Pomijanie obiektów." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Wybierz filament" diff --git a/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po b/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po index 0d9698d746..066b8b310b 100644 --- a/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po +++ b/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2026-07-26 11:14-0300\n" "Last-Translator: Alexandre Folle de Menezes\n" "Language-Team: Portuguese, Brazilian\n" @@ -4743,6 +4743,9 @@ msgstr "" "Sim - Alterar essas configurações e ativar o modo espiral/vaso automaticamente\n" "Não - Cancelar ativação do modo espiral" +msgid "N/A" +msgstr "N/D" + msgid "Printing" msgstr "Imprimindo" @@ -5196,9 +5199,6 @@ msgstr "Padrão inválido. Use N, N#K, ou uma lista separa por vírgulas com #K msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Formato inválido. Formato de vetor esperado: \"%1%\"" -msgid "N/A" -msgstr "N/D" - msgid "System agents" msgstr "Agentes do sistema" @@ -6145,6 +6145,12 @@ msgstr "Salvar projeto como" msgid "Save current project as" msgstr "Salvar o projeto atual como" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importar 3MF/STL/STEP/SVG/OBJ/AMF" @@ -7941,6 +7947,12 @@ msgstr "Por favor, confirme se o G-code dentro dessas predefinições é seguro msgid "Customized Preset" msgstr "Predefinição Personalizada" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Os nomes dos componentes dentro do arquivo STEP não estão no formato UTF-8!" @@ -8345,6 +8357,17 @@ msgstr "Salvar arquivo fatiado como:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "O arquivo %s foi enviado para o espaço de armazenamento da impressora e pode ser visualizado na impressora." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Publicar" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "O tipo de bico não está configurado. Configure o bico e tente novamente." @@ -9478,9 +9501,6 @@ msgstr "Ir para a página web de publicação de modelos" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Nota: A preparação pode levar vários minutos. Por favor, seja paciente." -msgid "Publish" -msgstr "Publicar" - msgid "Publish was canceled" msgstr "Publicação cancelada" @@ -9496,6 +9516,64 @@ msgstr "Enviando dados" msgid "Jump to webpage" msgstr "Ir para a página web" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Salvar %s como" @@ -20522,6 +20600,15 @@ msgstr "Esta ação não pode ser desfeita. Continuar?" msgid "Skipping objects." msgstr "Ignorando objetos." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Selecionar Filamento" diff --git a/localization/i18n/ru/OrcaSlicer_ru.po b/localization/i18n/ru/OrcaSlicer_ru.po index 112fbc2834..b5e6baffe0 100644 --- a/localization/i18n/ru/OrcaSlicer_ru.po +++ b/localization/i18n/ru/OrcaSlicer_ru.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: OrcaSlicer V2.5.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2026-02-25 13:38+0300\n" "Last-Translator: Felix14_v2\n" "Language-Team: Felix14_v2 (ДС/ТГ: @felix14_v2, почта: aleks111001@list.ru), Andylg \n" @@ -4891,6 +4891,11 @@ msgid "" "No - Cancel enabling spiral mode" msgstr "Использовать эти настройки и режим вазы?" +# Не знаю, как и почему, но это, похоже, исправляет "вопросики" вместо +# символов +msgid "N/A" +msgstr "–" + msgid "Printing" msgstr "Печать" @@ -5360,11 +5365,6 @@ msgstr "Недопустимый шаблон. Используйте N, N#K и msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Недопустимый формат. Ожидаемый векторный формат: \"%1%\"" -# Не знаю, как и почему, но это, похоже, исправляет "вопросики" вместо -# символов -msgid "N/A" -msgstr "–" - msgid "System agents" msgstr "Системные агенты" @@ -6378,6 +6378,12 @@ msgstr "Сохранить проект как" msgid "Save current project as" msgstr "Сохранить текущий проект как" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Импорт 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8212,6 +8218,12 @@ msgstr "Во избежание повреждения принтера убед msgid "Customized Preset" msgstr "Пользовательский профиль" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Имена компонентов внутри файла STEP не в формате UTF-8." @@ -8624,6 +8636,17 @@ msgstr "Сохранить нарезанный файл как:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Файл %s отправлен в память принтера и может быть просмотрен на нём." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Опубликовать" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Не указан тип сопла. Укажите его и попробуйте ещё раз." @@ -9770,9 +9793,6 @@ msgstr "Перейти на веб-страницу публикации мод msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Примечание: подготовка может занять несколько минут. Пожалуйста, наберитесь терпения." -msgid "Publish" -msgstr "Опубликовать" - msgid "Publish was canceled" msgstr "Публикация была отменена" @@ -9788,6 +9808,64 @@ msgstr "Отправка данных" msgid "Jump to webpage" msgstr "Перейти на страницу" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Сохранить %s как" @@ -21541,6 +21619,15 @@ msgstr "Это действие необратимо. Продолжить?" msgid "Skipping objects." msgstr "Исключение объектов." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Выбрать материал" diff --git a/localization/i18n/sv/OrcaSlicer_sv.po b/localization/i18n/sv/OrcaSlicer_sv.po index 7ec20d136d..b4e3bf51bc 100644 --- a/localization/i18n/sv/OrcaSlicer_sv.po +++ b/localization/i18n/sv/OrcaSlicer_sv.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "Language: sv\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -5387,6 +5387,10 @@ msgstr "" "JA -Ändra dessa inställningar och möjliggör Spiral läge automatiskt\n" "NEJ -Avbryt Spiral läge denna gång" +# AI Translated +msgid "N/A" +msgstr "Ej tillämpligt" + msgid "Printing" msgstr "Utskrift pågår" @@ -5936,10 +5940,6 @@ msgstr "Ogiltigt mönster. Använd N, N#K eller en kommaseparerad lista med valf msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Ogiltligt format. Förväntat vector format: \"%1%\"" -# AI Translated -msgid "N/A" -msgstr "Ej tillämpligt" - # AI Translated msgid "System agents" msgstr "Systemagenter" @@ -6972,6 +6972,12 @@ msgstr "Spara Projekt som" msgid "Save current project as" msgstr "Spara nuvarande projekt som" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importera 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8996,6 +9002,12 @@ msgstr "Bekräfta att G-koderna i dessa inställningar är säkra för att förh msgid "Customized Preset" msgstr "Anpassad inställning" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Komponent namnet i STEP filen är inte UTF-8 format!" @@ -9432,6 +9444,17 @@ msgstr "Spara beredningen som:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Filen %s har skickats till skrivarens lagringsutrymme och kan visas på skrivaren." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Publicera" + # AI Translated msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Nozzeltypen är inte angiven. Ange nozzeln och försök igen." @@ -10740,9 +10763,6 @@ msgstr "Växla till modell publicerings hemsidan" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Notera: Förberedelserna kan ta flera minuter. Vänligen vänta." -msgid "Publish" -msgstr "Publicera" - msgid "Publish was canceled" msgstr "Publiceringen avbröts" @@ -10759,6 +10779,64 @@ msgstr "Laddar upp data" msgid "Jump to webpage" msgstr "Växla till hemsidan" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Spara %s som" @@ -23513,6 +23591,15 @@ msgstr "Åtgärden kan inte ångras. Vill du fortsätta?" msgid "Skipping objects." msgstr "Hoppar över objekt." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Välj filament" diff --git a/localization/i18n/th/OrcaSlicer_th.po b/localization/i18n/th/OrcaSlicer_th.po index 16dcb17ab1..935bb83a3b 100644 --- a/localization/i18n/th/OrcaSlicer_th.po +++ b/localization/i18n/th/OrcaSlicer_th.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2026-06-19 13:40+0700\n" "Last-Translator: Icezaza\n" "Language-Team: Thai\n" @@ -4887,6 +4887,9 @@ msgstr "" "ใช่ - เปลี่ยนการตั้งค่าเหล่านี้และเปิดใช้งานโหมดเกลียวโดยอัตโนมัติ\n" "ไม่ - เลิกใช้โหมดเกลียวในครั้งนี้" +msgid "N/A" +msgstr "ไม่มี" + msgid "Printing" msgstr "กำลังพิมพ์" @@ -5346,9 +5349,6 @@ msgstr "รูปแบบไม่ถูกต้อง ใช้ N, N#K หร msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "รูปแบบไม่ถูกต้อง รูปแบบเวกเตอร์ที่ต้องการ: \"%1%\"" -msgid "N/A" -msgstr "ไม่มี" - # AI Translated msgid "System agents" msgstr "เอเจนต์ระบบ" @@ -6304,6 +6304,12 @@ msgstr "บันทึกโปรเจกต์เป็น" msgid "Save current project as" msgstr "บันทึกโครงการปัจจุบันเป็น" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "นำเข้า 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8118,6 +8124,12 @@ msgstr "โปรดยืนยันว่ารหัส G ภายในค msgid "Customized Preset" msgstr "ค่าที่ตั้งไว้ล่วงหน้าที่กำหนดเอง" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "ชื่อของส่วนประกอบภายในไฟล์ STEP ไม่ใช่รูปแบบ UTF-8!" @@ -8525,6 +8537,17 @@ msgstr "บันทึกไฟล์ที่สไลซ์เป็น:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "ไฟล์ %s ถูกส่งไปยังพื้นที่เก็บข้อมูลของเครื่องพิมพ์แล้ว และสามารถดูได้บนเครื่องพิมพ์" +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "เผยแพร่" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "ไม่ได้ตั้งค่าประเภทหัวฉีด โปรดตั้งหัวฉีดแล้วลองอีกครั้ง" @@ -9667,9 +9690,6 @@ msgstr "ข้ามไปที่โมเดลเผยแพร่หน้ msgid "Note: The preparation may take several minutes. Please be patient." msgstr "หมายเหตุ: การเตรียมการอาจใช้เวลาหลายนาที กรุณาอดทน." -msgid "Publish" -msgstr "เผยแพร่" - msgid "Publish was canceled" msgstr "การเผยแพร่ถูกยกเลิก" @@ -9685,6 +9705,64 @@ msgstr "กำลังอัพโหลดข้อมูล" msgid "Jump to webpage" msgstr "ข้ามไปที่หน้าเว็บ" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "บันทึก %s เป็น" @@ -20891,6 +20969,15 @@ msgstr "การทำงานนี้ไม่สามารถย้อน msgid "Skipping objects." msgstr "ข้ามวัตถุ" +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "เลือกเส้นพลาสติก" diff --git a/localization/i18n/tr/OrcaSlicer_tr.po b/localization/i18n/tr/OrcaSlicer_tr.po index 0e51b7ca9c..02d54aa39f 100644 --- a/localization/i18n/tr/OrcaSlicer_tr.po +++ b/localization/i18n/tr/OrcaSlicer_tr.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2026-08-21 23:18+0300\n" "Last-Translator: GlauTech\n" "Language-Team: \n" @@ -4949,6 +4949,10 @@ msgstr "" "Evet - Bu ayarları değiştirin ve spiral modunu otomatik olarak etkinleştirin\n" "Hayır - Bu sefer spiral modunu kullanmaktan vazgeçin" +# AI Translated +msgid "N/A" +msgstr "Yok" + msgid "Printing" msgstr "Baskı" @@ -5408,10 +5412,6 @@ msgstr "Geçersiz kalıp. N, N#K veya giriş başına isteğe bağlı #K ile vir msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Geçersiz format. Beklenen vektör formatı: \"%1%\"" -# AI Translated -msgid "N/A" -msgstr "Yok" - # AI Translated msgid "System agents" msgstr "Sistem aracıları" @@ -6372,6 +6372,12 @@ msgstr "Projeyi farklı kaydet" msgid "Save current project as" msgstr "Mevcut projeyi farklı kaydet" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "3MF/STL/STEP/SVG/OBJ/AMF'yi içe aktar" @@ -8215,6 +8221,12 @@ msgstr "Lütfen bu ön ayarlar içindeki G-code'larının makineye herhangi bir msgid "Customized Preset" msgstr "Özel Ayar" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Step dosyasındaki bileşenlerin adı UTF-8 formatında değil!" @@ -8624,6 +8636,17 @@ msgstr "Dilimlenmiş dosyayı şu şekilde kaydedin:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "%s dosyası yazıcının depolama alanına gönderildi ve yazıcıda görüntülenebiliyor." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Yayınla" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Nozul tipi ayarlanmamış. Lütfen nozulu ayarlayın ve tekrar deneyin." @@ -9811,9 +9834,6 @@ msgstr "Model yayınlama web sayfasına git" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Not: Hazırlık birkaç dakika sürebilir. Lütfen sabırlı olun." -msgid "Publish" -msgstr "Yayınla" - msgid "Publish was canceled" msgstr "Yayınlama iptal edildi" @@ -9829,6 +9849,64 @@ msgstr "Veriler yükleniyor" msgid "Jump to webpage" msgstr "Web sayfasına atla" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "%s'yi farklı kaydet" @@ -21318,6 +21396,15 @@ msgstr "Bu eylem geri alınamaz. Devam etmek?" msgid "Skipping objects." msgstr "Nesneleri atlama." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Filament Seçin" diff --git a/localization/i18n/uk/OrcaSlicer_uk.po b/localization/i18n/uk/OrcaSlicer_uk.po index 3d23f7bbb1..d72a1f9792 100644 --- a/localization/i18n/uk/OrcaSlicer_uk.po +++ b/localization/i18n/uk/OrcaSlicer_uk.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: orcaslicerua\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2026-07-17 16:25+0300\n" "Last-Translator: Andrij Mizyk \n" "Language-Team: Ukrainian\n" @@ -4887,6 +4887,9 @@ msgstr "" "Так – змінити ці налаштування та автоматично включити режим спіральна ваза\n" "Ні - цього разу відмовитися від використання режиму спіральна ваза" +msgid "N/A" +msgstr "Н/Д" + msgid "Printing" msgstr "Друк" @@ -5358,9 +5361,6 @@ msgstr "Некоректний шаблон. Використовуйте N, N#K msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Невірний формат. Очікуваний векторний формат: \"%1%\"" -msgid "N/A" -msgstr "Н/Д" - # AI Translated msgid "System agents" msgstr "Системні агенти" @@ -6341,6 +6341,12 @@ msgstr "Зберегти проєкт як" msgid "Save current project as" msgstr "Зберегти поточний проєкт як" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Імпорт 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8223,6 +8229,12 @@ msgstr "Будь ласка, підтвердьте, що G-коди в цих msgid "Customized Preset" msgstr "Пристосований пресет" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + # AI Translated msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Назви компонентів усередині файлу STEP не у форматі UTF-8!" @@ -8635,6 +8647,17 @@ msgstr "Зберегти нарізаний файл як:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Файл %s надіслано до памʼяті принтера та доступний для перегляду на принтері." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Публікувати" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Тип сопла не встановлений. Будь ласка, оберіть сопло та спробуйте ще раз." @@ -9814,9 +9837,6 @@ msgstr "Перейти на веб-сторінку публікації мод msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Примітка. Підготовка може тривати кілька хвилин. Будь ласка, будьте терплячі." -msgid "Publish" -msgstr "Публікувати" - msgid "Publish was canceled" msgstr "Публікація скасована" @@ -9832,6 +9852,64 @@ msgstr "Відвантаження даних" msgid "Jump to webpage" msgstr "Перейти на вебсторінку" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Зберегти %s як" @@ -21495,6 +21573,15 @@ msgstr "Дію не можливо завершити. Продовжити?" msgid "Skipping objects." msgstr "Пропускання обʼєктів." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Вибрати філамент" diff --git a/localization/i18n/vi/OrcaSlicer_vi.po b/localization/i18n/vi/OrcaSlicer_vi.po index 8ec3447568..a0758d51ad 100644 --- a/localization/i18n/vi/OrcaSlicer_vi.po +++ b/localization/i18n/vi/OrcaSlicer_vi.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2025-10-02 17:43+0700\n" "Last-Translator: \n" "Language-Team: hainguyen.ts13@gmail.com\n" @@ -5143,6 +5143,10 @@ msgstr "" "Yes - Thay đổi các cài đặt này và bật chế độ spiral tự động\n" "No - Từ bỏ dùng chế độ spiral lần này" +# AI Translated +msgid "N/A" +msgstr "N/A" + msgid "Printing" msgstr "Đang in" @@ -5661,10 +5665,6 @@ msgstr "Mẫu không hợp lệ. Dùng N, N#K, hoặc danh sách phân cách d msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Định dạng không hợp lệ. Mong đợi định dạng vector: \"%1%\"" -# AI Translated -msgid "N/A" -msgstr "N/A" - # AI Translated msgid "System agents" msgstr "Tác nhân hệ thống" @@ -6689,6 +6689,12 @@ msgstr "Lưu dự án thành" msgid "Save current project as" msgstr "Lưu dự án hiện tại thành" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Nhập 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8629,6 +8635,12 @@ msgstr "Vui lòng xác nhận G-code trong các preset này an toàn để ngăn msgid "Customized Preset" msgstr "Preset tùy chỉnh" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Tên của các thành phần bên trong file STEP không phải định dạng UTF-8!" @@ -9056,6 +9068,17 @@ msgstr "Lưu file đã slice dưới dạng:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "File %s đã được gửi đến không gian lưu trữ của máy in và có thể được xem trên máy in." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Xuất bản" + # AI Translated msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Chưa đặt loại đầu phun. Vui lòng đặt đầu phun rồi thử lại." @@ -10326,9 +10349,6 @@ msgstr "Chuyển đến trang web xuất bản model" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Lưu ý: Chuẩn bị có thể mất vài phút. Vui lòng kiên nhẫn." -msgid "Publish" -msgstr "Xuất bản" - msgid "Publish was canceled" msgstr "Xuất bản đã bị hủy" @@ -10345,6 +10365,64 @@ msgstr "Đang tải dữ liệu lên" msgid "Jump to webpage" msgstr "Chuyển đến trang web" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Lưu %s dưới dạng" @@ -22165,6 +22243,15 @@ msgstr "Thao tác này không thể hoàn tác. Tiếp tục?" msgid "Skipping objects." msgstr "Đang bỏ qua các vật thể." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + # AI Translated msgid "Select Filament" msgstr "Chọn filament" diff --git a/localization/i18n/zh_CN/OrcaSlicer_zh_CN.po b/localization/i18n/zh_CN/OrcaSlicer_zh_CN.po index 81cd5a402b..7c474031c9 100644 --- a/localization/i18n/zh_CN/OrcaSlicer_zh_CN.po +++ b/localization/i18n/zh_CN/OrcaSlicer_zh_CN.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Slic3rPE\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2026-06-11 12:37-0300\n" "Last-Translator: Handle \n" "Language-Team: \n" @@ -4743,6 +4743,9 @@ msgstr "" "是 - 自动调整这些设置并开启旋转模式\n" "否 - 暂不使用旋转模式" +msgid "N/A" +msgstr "不适用" + msgid "Printing" msgstr "打印中" @@ -5202,9 +5205,6 @@ msgstr "无效的模式。请使用 N、N#K 或逗号分隔的列表(每个条 msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "无效格式,应该是\"%1%\"这种数组格式" -msgid "N/A" -msgstr "不适用" - # AI Translated msgid "System agents" msgstr "系统代理" @@ -6159,6 +6159,12 @@ msgstr "项目另存为" msgid "Save current project as" msgstr "项目另存为" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "导入 3MF/STL/STEP/SVG/OBJ/AMF" @@ -7949,6 +7955,12 @@ msgstr "请确认这些预设中的G-codes是否安全,以防止对机器造 msgid "Customized Preset" msgstr "自定义的预设" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "STEP 文件中的部件名称不是 UTF-8 格式!" @@ -8354,6 +8366,17 @@ msgstr "切片文件另存为:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "文件%s已经发送到打印机的存储空间,可以在打印机上浏览。" +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "发布" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "未设置喷嘴类型。请设置喷嘴并重试。" @@ -9489,9 +9512,6 @@ msgstr "跳转到发布页面" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "提示:发布前需要一些准备时间,请耐心等待。" -msgid "Publish" -msgstr "发布" - msgid "Publish was canceled" msgstr "发布已取消" @@ -9507,6 +9527,64 @@ msgstr "正在上传数据" msgid "Jump to webpage" msgstr "跳转到网页" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "另存%s为" @@ -20639,6 +20717,15 @@ msgstr "此操作无法撤消。继续?" msgid "Skipping objects." msgstr "跳过对象。" +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "选择耗材" diff --git a/localization/i18n/zh_TW/OrcaSlicer_zh_TW.po b/localization/i18n/zh_TW/OrcaSlicer_zh_TW.po index ded90c8950..83b22ca029 100644 --- a/localization/i18n/zh_TW/OrcaSlicer_zh_TW.po +++ b/localization/i18n/zh_TW/OrcaSlicer_zh_TW.po @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2025-11-28 13:48-0600\n" "Last-Translator: tntchn <15895303+tntchn@users.noreply.github.com>\n" "Language-Team: \n" @@ -4872,6 +4872,9 @@ msgstr "" "是 - 自動調整這些設定並啟用花瓶模式\n" "否 - 不使用花瓶模式" +msgid "N/A" +msgstr "不適用" + msgid "Printing" msgstr "列印中" @@ -5331,9 +5334,6 @@ msgstr "無效的格式。請使用 N、N#K 或逗號分隔的清單,每個項 msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "無效格式,應該是「%1%」這種格式" -msgid "N/A" -msgstr "不適用" - # AI Translated msgid "System agents" msgstr "系統代理程式" @@ -6289,6 +6289,12 @@ msgstr "另存專案為" msgid "Save current project as" msgstr "將目前專案另存為" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "匯入 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8110,6 +8116,12 @@ msgstr "請確認這些預設中的 G-code 是安全的,以防止對列印裝 msgid "Customized Preset" msgstr "自訂預設" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "STEP 檔案內部元件的名稱不是 UTF-8 格式!" @@ -8520,6 +8532,17 @@ msgstr "切片檔案另存為:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "檔案 %s 已經傳送到列印裝置的儲存空間,可以在列印裝置上瀏覽。" +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "發布" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "噴嘴類型尚未設定。請設定噴嘴後再試一次。" @@ -9662,9 +9685,6 @@ msgstr "發布頁面" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "提示:發布前需要一些準備時間,請耐心等待。" -msgid "Publish" -msgstr "發布" - msgid "Publish was canceled" msgstr "發布已取消" @@ -9680,6 +9700,64 @@ msgstr "正在上傳資料" msgid "Jump to webpage" msgstr "跳至網頁" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "另存 %s 為" @@ -20832,6 +20910,15 @@ msgstr "此操作無法撤消。繼續?" msgid "Skipping objects." msgstr "跳過物件。" +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "選擇線材" diff --git a/resources/images/menu_publish.svg b/resources/images/menu_publish.svg new file mode 100644 index 0000000000..e444623e33 --- /dev/null +++ b/resources/images/menu_publish.svg @@ -0,0 +1 @@ + diff --git a/resources/web/guide/21/common.css b/resources/web/guide/21/common.css index e7cd57a32f..992c55d7a1 100644 --- a/resources/web/guide/21/common.css +++ b/resources/web/guide/21/common.css @@ -415,7 +415,9 @@ img.ModelThumbnail { border-width: 1px; border-style: solid; border-radius: 4px; - background-color: inherit; + border-color: var(--border-color); + background-color: var(--bg-color); + color: var(--fg-color-text); position: absolute; left: 50%; top: 200px; @@ -423,13 +425,17 @@ img.ModelThumbnail { } #NoticeBar { - background-color:#00f0d8; + background-color: var(--main-color); height: 40px; line-height: 40px; color: #fff; text-align: center; } +#NoticeBar.notice-error { + background-color: var(--button-bg-alert); +} + #NoticeContent { padding: 4mm 10mm; } diff --git a/resources/web/guide/21/common.js b/resources/web/guide/21/common.js index f7e8b1d409..9a3c76c248 100644 --- a/resources/web/guide/21/common.js +++ b/resources/web/guide/21/common.js @@ -37,6 +37,34 @@ function HandleStudio( pVal ) { HandleModelList(pVal['response']); } + else if(strCmd=='check_new_printers_result') + { + let button = document.getElementById("CheckNewPrintersBtn"); + if (button) { + button.style.pointerEvents = "auto"; + button.style.opacity = "1"; + } + + let noticeBar = document.getElementById("NoticeBar"); + let noticeText = document.getElementById("NoticeText"); + let hasError = pVal.hasOwnProperty("error"); + noticeBar.classList.toggle("notice-error", hasError); + if (hasError) { + noticeBar.textContent = "Error"; + noticeText.textContent = pVal["error"]; + } else if (pVal["vendors"] && pVal["vendors"].length > 0) { + noticeBar.textContent = "New printers found"; + noticeText.textContent = "New printer vendors installed: " + pVal["vendors"].join(", "); + } else if (pVal["declined"]) { + noticeBar.textContent = "Information"; + noticeText.textContent = "New printer vendors were found, but installation was cancelled."; + } else { + noticeBar.textContent = "Information"; + noticeText.textContent = "No new printers found."; + } + + ShowNotice(1); + } } function HandleModelList( pVal ) @@ -78,9 +106,13 @@ function HandleModelList( pVal ) } //Update Nozzel Html Append + // ORCA: HandleModelList can now be called more than once per dialog (e.g. after a new vendor + // is installed via "check for new printers"). pModel always holds the full, current list, so + // clear each vendor's printer area before repopulating instead of appending on top of a + // previous render. for( let key in ModelHtml ) { - $(".OneVendorBlock[vendor='"+key+"'] .PrinterArea").append( ModelHtml[key] ); + $(".OneVendorBlock[vendor='"+key+"'] .PrinterArea").empty().append( ModelHtml[key] ); } //Update Checkbox @@ -343,6 +375,9 @@ function OnExit() let nTotal=ModelSelect.length; if( nTotal==0 ) { + let noticeBar = document.getElementById("NoticeBar"); + noticeBar.classList.add("notice-error"); + noticeBar.textContent = "Error"; ShowNotice(1); return 0; } diff --git a/resources/web/guide/24/24.js b/resources/web/guide/24/24.js index 74a03db7b9..19ccb892c6 100644 --- a/resources/web/guide/24/24.js +++ b/resources/web/guide/24/24.js @@ -36,6 +36,22 @@ function ConfirmSelect() } } +function CheckForNewPrinters() +{ + var tSend={}; + tSend['sequence_id']=Math.round(new Date() / 1000); + tSend['command']="check_for_new_printers"; + tSend['data']={}; + + var button = document.getElementById("CheckNewPrintersBtn"); + if (button) { + button.style.pointerEvents = "none"; + button.style.opacity = "0.6"; + } + + SendWXMessage( JSON.stringify(tSend) ); +} + function CreateNewPrinter() { var tSend={}; diff --git a/resources/web/guide/24/index.html b/resources/web/guide/24/index.html index efb6148e0d..5f0a2407cc 100644 --- a/resources/web/guide/24/index.html +++ b/resources/web/guide/24/index.html @@ -82,7 +82,8 @@
Create
-
Confirm
+
Check for new printers
+
Confirm
Cancel
diff --git a/resources/web/homepage/css/home.css b/resources/web/homepage/css/home.css index 2cf5742747..950e330b86 100644 --- a/resources/web/homepage/css/home.css +++ b/resources/web/homepage/css/home.css @@ -716,7 +716,8 @@ body background-color: #E4E4E4; border-radius: 8px; width: 184px; - height: 184px; + height: 184px; + position: relative; } .FileItem img @@ -728,6 +729,19 @@ body object-fit: cover; } +.FileImg .FileLogoBadge +{ + position: absolute; + top: 8px; + left: 8px; + width: 26px; + height: 26px; + padding: 2px; + box-sizing: border-box; + background-color: rgba(255,255,255,0.85); + border-radius: 50%; +} + .FileName { white-space: nowrap; @@ -736,6 +750,35 @@ body word-break: break-all; } +.FileNamePack +{ + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; +} + +.FileNamePack .FileName +{ + flex: 1 1 auto; + min-width: 0; +} + +.FilePublishedBadge +{ + flex: 0 0 auto; + margin-right: 4px; + padding: 0 4px; + height: 16px; + line-height: 16px; + font-size: 10px; + font-weight: 600; + color: #FFFFFF; + background-color: #009688; + border-radius: 2px; + white-space: nowrap; +} + .FileDate { color: #A8A8A8; diff --git a/resources/web/homepage/js/home.js b/resources/web/homepage/js/home.js index f07c6f10f5..aee0d8cd37 100644 --- a/resources/web/homepage/js/home.js +++ b/resources/web/homepage/js/home.js @@ -305,15 +305,19 @@ function ShowRecentFileList( pList ) let sImg=OneFile["image"] || sImages[sPath]; let sTime=OneFile['time']; let sName=OneFile['project_name']; + let sPublished=OneFile['published'] == '1'; sImages[sPath] = sImg; //let index=sPath.lastIndexOf('\\')>0?sPath.lastIndexOf('\\'):sPath.lastIndexOf('\/'); //let sShortName=sPath.substring(index+1,sPath.length); + let sBadge=sPublished? 'PUB':''; + let sLogoBadge=sPublished? '':''; + let TmpHtml='
'+ ''+ - '
No Image
'+ - '
'+sName+'
'+ + '
No Image'+sLogoBadge+'
'+ + '
'+sBadge+'
'+sName+'
'+ '
'+sTime+'
'+ '
'; diff --git a/src/dev-utils/BaseException.h b/src/dev-utils/BaseException.h index 2cb65d945e..20b6fb0c89 100644 --- a/src/dev-utils/BaseException.h +++ b/src/dev-utils/BaseException.h @@ -1,7 +1,7 @@ #pragma once #include #include -#include "stackwalker.h" +#include "StackWalker.h" #include class CBaseException : public CStackWalker diff --git a/src/dev-utils/StackWalker.cpp b/src/dev-utils/StackWalker.cpp index 6038196cb0..3ef983cd86 100644 --- a/src/dev-utils/StackWalker.cpp +++ b/src/dev-utils/StackWalker.cpp @@ -425,7 +425,7 @@ LPSTACKINFO CStackWalker::StackWalker(HANDLE hThread, const CONTEXT* context) else c = *context; - STACKFRAME64 sf = {0}; + STACKFRAME64 sf = {}; DWORD imageType; //intel X86 diff --git a/src/libslic3r/AppConfig.cpp b/src/libslic3r/AppConfig.cpp index d26296cd69..4c6eefa470 100644 --- a/src/libslic3r/AppConfig.cpp +++ b/src/libslic3r/AppConfig.cpp @@ -42,6 +42,9 @@ namespace Slic3r { static const std::string VERSION_CHECK_URL = "https://check-version.orcaslicer.com/latest"; static const std::string PROFILE_UPDATE_URL = "https://check-version.orcaslicer.com/profile"; + +constexpr const char* CONFIG_ORCA_UPDATER_URL = "orca_updater_url"; + static const std::string MODELS_STR = "models"; const std::string AppConfig::SECTION_FILAMENTS = "filaments"; @@ -635,6 +638,11 @@ void AppConfig::set_defaults() set_bool("use_printer_agents", false); } + if (get("enable_ota").empty()) + { + set_bool("enable_ota", false); + } + // Remove legacy window positions/sizes erase("app", "main_frame_maximized"); erase("app", "main_frame_pos"); @@ -1815,7 +1823,10 @@ std::string AppConfig::version_check_url() const std::string AppConfig::profile_update_url() const { - return PROFILE_UPDATE_URL; + std::string orca_updater_url = get(CONFIG_ORCA_UPDATER_URL); + if (orca_updater_url.empty()) + return PROFILE_UPDATE_URL; + return orca_updater_url; } bool AppConfig::exists() diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index 23b6decb2c..e860f50280 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -372,6 +372,8 @@ set(lisbslic3r_sources Preset.hpp PrincipalComponents2D.cpp PrincipalComponents2D.hpp + PublishSettings.cpp + PublishSettings.hpp PrintApply.cpp PrintBase.cpp PrintBase.hpp diff --git a/src/libslic3r/Fill/Fill.cpp b/src/libslic3r/Fill/Fill.cpp index dc772580ca..f5386b085c 100644 --- a/src/libslic3r/Fill/Fill.cpp +++ b/src/libslic3r/Fill/Fill.cpp @@ -11,7 +11,7 @@ #include "AABBTreeLines.hpp" #include "ExtrusionEntity.hpp" -#include "FillBase.hpp" +#include "Fill.hpp" #include "FillRectilinear.hpp" #include "FillLightning.hpp" #include "FillConcentricInternal.hpp" @@ -1234,6 +1234,33 @@ std::vector group_fills(const Layer &layer, LockRegionParam &lock_p return surface_fills; } +// Orca: Anchors and printed infill must share the same body origin. Keep the choice +// here so per-model surface centering and separated sparse infill cannot drift apart. +static BoundingBox infill_bounding_box(const Layer &layer, const SurfaceFill &fill, const ExPolygon &expoly, BoundingBox bbox) +{ + const auto ¶ms = fill.params; + const auto &config = layer.regions()[fill.region_id]->region().config(); + const bool external = params.extrusion_role == erTopSolidInfill || params.extrusion_role == erBottomSurface; + const bool per_model = external && params.center_of_surface_pattern == CenterOfSurfacePattern::Each_Model && + (params.pattern == ipArchimedeanChords || params.pattern == ipOctagramSpiral); + const bool separate = !external && params.separated_infills && + (is_separable_infill_pattern(params.pattern) || !config.solid_infill_rotate_template.value.empty() || + !config.sparse_infill_rotate_template.value.empty()); + if (per_model || separate) { + double best_overlap = 0.; + for (size_t i = 0; i < layer.lslices.size() && i < layer.lslices_separated_component_bboxes.size(); ++i) { + const double overlap = area(intersection_ex(layer.lslices[i], expoly)); + if (overlap > best_overlap) { + best_overlap = overlap; + const Point center = layer.lslices_separated_component_bboxes[i].center(); + bbox = layer.object()->bounding_box(); + bbox.translate(center.x(), center.y()); + } + } + } + return bbox; +} + #ifdef SLIC3R_DEBUG_SLICE_PROCESSING void export_group_fills_to_svg(const char *path, const std::vector &fills) { @@ -1353,19 +1380,9 @@ void Layer::make_fills(FillAdaptive::Octree* adaptive_fill_octree, FillAdaptive: // Orca: Checking the filling of a centered surface by drawing for each model parts bool is_top_or_bottom = params.extrusion_role == erTopSolidInfill || params.extrusion_role == erBottomSurface; - bool is_centered_infill = surface_fill.params.pattern == ipArchimedeanChords || surface_fill.params.pattern == ipOctagramSpiral; if (is_top_or_bottom) { params.center_of_surface_pattern = surface_fill.params.center_of_surface_pattern; // Orca: center of surface pattern } - // Orca: Each_Model centers the pattern on each model part's bbox; Each_Surface / Each_Assembly - // fall through to the default (whole-object) bounding box below. - bool is_per_model_center = is_top_or_bottom && params.center_of_surface_pattern == CenterOfSurfacePattern::Each_Model && is_centered_infill; - bool is_separate_infill = !is_top_or_bottom && surface_fill.params.separated_infills && - ( - is_separable_infill_pattern(surface_fill.params.pattern) || - params.config->solid_infill_rotate_template != "" || - params.config->sparse_infill_rotate_template != "" ); - if( surface_fill.params.pattern == ipLockedZag ) { params.locked_zag = true; params.infill_lock_depth = surface_fill.params.infill_lock_depth; @@ -1389,34 +1406,8 @@ void Layer::make_fills(FillAdaptive::Octree* adaptive_fill_octree, FillAdaptive: params.can_reverse = false; for (ExPolygon& expoly : surface_fill.expolygons) { - // Orca: separate infill / per-model pattern centering. - // - // Center the pattern on each connected body of the object independently, so every piece - // is filled exactly as if it were sliced on its own: touching/overlapping parts merge - // into one body sharing a center, while separate parts and disconnected islands (even - // interleaved-but-not-touching ones, e.g. chain links) each get their own. The body each - // island belongs to, and its full bounding box, were resolved in 3D by PrintObject:: - // infill() (lslices_separated_component_bboxes, aligned with this layer's lslices). We - // match this fill region to the island it overlaps most, then re-use the whole-object - // bounding box (origin-centered — identical extent to the default, so coverage and cost - // are unchanged) re-centered on that body. - if (is_per_model_center || is_separate_infill) { - double best_overlap = 0.; - BoundingBox best_component; - for (size_t r = 0; r < this->lslices.size() && r < this->lslices_separated_component_bboxes.size(); ++ r) { - const double overlap = area(intersection_ex(this->lslices[r], expoly)); - if (overlap > best_overlap) { - best_overlap = overlap; - best_component = this->lslices_separated_component_bboxes[r]; - } - } - if (best_component.defined) { - const Point c = best_component.center(); - BoundingBox part_bbox = bbox; // origin-centered, whole-object extent (from above) - part_bbox.translate(c.x(), c.y()); // re-center on this body - f->set_bounding_box(part_bbox); - } - } // - End: separate infill / per-model pattern centering + // Orca: Reuse the body origin used for bridge anchoring, resetting it for each surface. + f->set_bounding_box(infill_bounding_box(*this, surface_fill, expoly, bbox)); f->no_overlap_expolygons = intersection_ex(surface_fill.no_overlap_expolygons, ExPolygons() = {expoly}, ApplySafetyOffset::Yes); if (params.symmetric_infill_y_axis) { @@ -1583,8 +1574,14 @@ Polylines Layer::generate_sparse_infill_polylines_for_anchoring(FillAdaptive::Oc params.multiline = surface_fill.params.multiline; params.gyroid_optimized = surface_fill.params.gyroid_optimized; params.smooth_factor = surface_fill.params.smooth_factor; + // Orca: Match make_fills() when choosing the origin of plane-path patterns. + // Without the sparse extrusion role, the filler uses each surface's bounds + // instead of the object's bounds, so bridge anchors shift away from printed infill. + params.extrusion_role = surface_fill.params.extrusion_role; for (ExPolygon &expoly : surface_fill.expolygons) { + // Orca: Match the per-body origin of make_fills() before generating physical anchors. + f->set_bounding_box(infill_bounding_box(*this, surface_fill, expoly, bbox)); // Spacing is modified by the filler to indicate adjustments. Reset it for each expolygon. f->spacing = surface_fill.params.spacing; surface_fill.surface.expolygon = std::move(expoly); diff --git a/src/libslic3r/Fill/Fill.hpp b/src/libslic3r/Fill/Fill.hpp index e92ab2dee5..b183cf0253 100644 --- a/src/libslic3r/Fill/Fill.hpp +++ b/src/libslic3r/Fill/Fill.hpp @@ -14,6 +14,12 @@ namespace Slic3r { class ExtrusionEntityCollection; class LayerRegion; +class PrintObject; + +// Orca: Share the layer rotation calculation between infill generation and internal +// bridge angle selection so both interpret rotation templates in the same way. +double calculate_infill_rotation_angle(const PrintObject *object, size_t layer_id, + const double &fixed_infill_angle, const std::string &template_string); // An interface class to Perl, aggregating an instance of a Fill and a FillData. class Filler diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index b4f6dc1d45..e2091da1db 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -642,6 +642,11 @@ bool bbs_is_valid_object_type(const std::string& type) namespace Slic3r { +bool is_published_3mf_flag(const std::string &value) +{ + return value == "1"; +} + void PlateData::parse_filament_info(GCodeProcessorResult *result) { if (!result) return; @@ -1178,6 +1183,20 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) // add backup & restore logic bool _load_model_from_file(std::string filename, Model& model, PlateDataPtrs& plate_data_list, std::vector& project_presets, DynamicPrintConfig& config, ConfigSubstitutionContext& config_substitutions, Import3mfProgressFn proFn = nullptr, BBLProject* project = nullptr, int plate_id = 0); + + // A minimal published 3MF carries no slicer tags (any tag would make old receivers show + // a baked-in, wrong "old version" popup on their geometry-only fallback), so it + // classifies as From_Other. It is still a fully structured OrcaSlicer file though: + // identified by its own metadata, it keeps BBS-grade geometry handling (no instance + // splitting, no transform baking, no renaming) in this build. Old receivers without the + // publish feature don't know the metadata and take their third-party geometry path. + // Reads the parse-time metadata: the model XML carries it before its resources, while + // m_model->model_info is only filled in after the whole XML has been parsed. + bool _is_published_3mf() const { + const auto it = this->model_info.metadata_items.find(ORCA_PUBLISHED_TAG); + return it != this->model_info.metadata_items.end() && is_published_3mf_flag(it->second); + } + bool _is_svg_shape_file(const std::string &filename) const; bool _extract_from_archive(mz_zip_archive& archive, std::string const & path, std::function, bool restore = false); bool _extract_xml_from_archive(mz_zip_archive& archive, std::string const & path, XML_StartElementHandler start_handler, XML_EndElementHandler end_handler); @@ -2002,7 +2021,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) lock.close(); - if (!m_is_bbl_3mf) { + if (!m_is_bbl_3mf && !_is_published_3mf()) { // if the 3mf was not produced by OrcaSlicer and there is more than one instance, // split the object in as many objects as instances BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ":" << __LINE__ << boost::format(", found 3mf from other vendor, split as instance"); @@ -3566,7 +3585,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) m_index_paths.insert({ object.first.second, object.first.first}); } - if (!m_is_bbl_3mf) { + if (!m_is_bbl_3mf && !_is_published_3mf()) { // if the 3mf was not produced by OrcaSlicer and there is only one object, // set the object name to match the filename if (m_model->objects.size() == 1) @@ -5289,7 +5308,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) TriangleMesh triangle_mesh(std::move(its), volume_data.mesh_stats); - if (!m_is_bbl_3mf) { + if (!m_is_bbl_3mf && !_is_published_3mf()) { // if the 3mf was not produced by OrcaSlicer and there is only one instance, // bake the transformation into the geometry to allow the reload from disk command // to work properly @@ -5935,6 +5954,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) bool m_save_gcode { false }; // whether to save gcode for normal save bool m_skip_model { false }; // skip model when exporting .gcode.3mf bool m_skip_auxiliary { false }; // skip normal axuiliary files + bool m_minimal_published { false }; // published 3MF: omit the project config, the embedded preset files and the slicer tags bool m_use_loaded_id { false }; // whether to use loaded id for identify_id bool m_share_mesh { false }; // whether to share mesh between objects std::string m_thumbnail_middle = PRINTER_THUMBNAIL_MIDDLE_FILE; @@ -6034,6 +6054,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) m_skip_auxiliary = store_params.strategy & SaveStrategy::SkipAuxiliary; m_share_mesh = store_params.strategy & SaveStrategy::ShareMesh; m_from_backup_save = store_params.strategy & SaveStrategy::Backup; + m_minimal_published = store_params.strategy & SaveStrategy::MinimalPublished; m_use_loaded_id = store_params.strategy & SaveStrategy::UseLoadedId; @@ -6443,7 +6464,10 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) // Adds slic3r print config file ("Metadata/Slic3r_PE.config"). // This file contains the content of FullPrintConfig / SLAFullPrintConfig. - if (config != nullptr) { + // Omitted for minimal published 3MF: OrcaSlicer versions without the publish feature + // then fall back to importing the geometry only, and new versions read the published + // payload from the model metadata instead. + if (config != nullptr && !m_minimal_published) { // BBS: change to json format // if (!_add_print_config_file_to_archive(archive, *config)) { if (!_add_project_config_file_to_archive(archive, *config, model)) { return false; } @@ -6456,8 +6480,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) if (cb_cancel) return false; } - // BBS: add project config - if (project_presets.size() > 0) { + // BBS: add project config (omitted for minimal published 3MF) + if (!m_minimal_published && project_presets.size() > 0) { // BBS: add project embedded preset files _add_project_embedded_presets_to_archive(archive, model, project_presets); @@ -6929,10 +6953,31 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) // Orca: PRIVACY: do not store creation & modification date in 3mf metadata_item_map[BBL_CREATION_DATE_TAG] = ""; metadata_item_map[BBL_MODIFICATION_TAG] = ""; - // Orca: Write the BambuStudio compatibility version string using SLIC3R_VERSION - metadata_item_map[BBL_APPLICATION_TAG] = (boost::format("%1%-%2%") % "BambuStudio" % SLIC3R_VERSION).str(); + // Orca: Write the BambuStudio compatibility version string using SLIC3R_VERSION. + // A minimal published 3MF writes no slicer tags at all: any tag would route old + // receivers onto a geometry-only fallback whose baked-in popup misreports the + // file ("old OrcaSlicer version" / "BambuStudio"), while tag-less files classify + // as From_Other and import the geometry silently. + if (m_minimal_published) { + // metadata_item_map is seeded from the input file's metadata_items above, so a + // project opened from a regular Orca/BBS 3MF still carries the slicer-identifying + // tags it came with. Erase every one of them - not just the two most common - + // so a published 3MF is fully tag-less: old receivers classify it as From_Other + // and import the geometry silently instead of showing a baked-in "old version" + // popup, and no version marker survives to seed a later re-save. + metadata_item_map.erase(BBL_APPLICATION_TAG); + metadata_item_map.erase(ORCASLICER_TAG); + metadata_item_map.erase(BBS_3MF_VERSION); + metadata_item_map.erase(BBS_3MF_VERSION1); + } else { + metadata_item_map[BBL_APPLICATION_TAG] = (boost::format("%1%-%2%") % "BambuStudio" % SLIC3R_VERSION).str(); + } } - metadata_item_map[BBS_3MF_VERSION] = std::to_string(VERSION_BBS_3MF); + // The Bambu 3MF version marker is part of the slicer identity: omit it for a minimal + // published file along with the tags erased above (skipping the overwrite alone would + // leave the value the source file seeded into metadata_item_map). + if (!m_minimal_published) + metadata_item_map[BBS_3MF_VERSION] = std::to_string(VERSION_BBS_3MF); if (!model.mk_name.empty()) { metadata_item_map[BBL_MAKERLAB_TAG] = xml_escape(model.mk_name); @@ -6955,7 +7000,11 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) BOOST_LOG_TRIVIAL(info) << "bbs_3mf: save key= " << item.first << ", value = " << item.second; stream << " <" << METADATA_TAG << " name=\"" << item.first << "\">" << xml_escape(item.second) << "\n"; - if (item.first == BBL_APPLICATION_TAG) { + if (item.first == BBL_APPLICATION_TAG && !m_minimal_published) { + // The OrcaSlicer tag is only written for files that carry the Application + // tag, which a minimal published 3MF erases (see the map assignment above): + // the explicit !m_minimal_published guard keeps the tag-less guarantee from + // depending on that erase happening to run first. stream << " <" << METADATA_TAG << " name=\"" << ORCASLICER_TAG << "\">" << xml_escape(SoftFever_VERSION) << "\n"; } @@ -8795,7 +8844,7 @@ public: auto model = object.get_model(); auto o = m_temp_model.add_object(object); int backup_id = model->get_object_backup_id(object); - push_task({ AddObject, (size_t) backup_id, object.get_model()->get_backup_path(), o, 1 }); + push_task({ AddObject, (size_t) backup_id, object.get_model()->get_backup_path(), o, { 1 } }); } void remove_object_mesh(ModelObject& object) { @@ -8805,7 +8854,7 @@ public: void backup_soon() { boost::lock_guard lock(m_mutex); m_other_changes_backup = true; - m_tasks.push_back({ Backup, 0, std::string(), nullptr, ++m_task_seq }); + m_tasks.push_back({ Backup, 0, std::string(), nullptr, { ++m_task_seq } }); m_cond.notify_all(); } @@ -8823,7 +8872,7 @@ public: m_ui_tasks.clear(); m_tasks.clear(); } - m_tasks.push_back({ RemoveBackup, model.id().id, model.get_backup_path(), nullptr, removeAll }); + m_tasks.push_back({ RemoveBackup, model.id().id, model.get_backup_path(), nullptr, { removeAll } }); ++m_task_seq; if (model.is_need_backup()) { m_other_changes = false; @@ -9038,7 +9087,7 @@ public: else m_cond.wait(lock); if (m_interval > 0 && boost::get_system_time() > m_next_backup) { - m_tasks.push_back({ Backup, 0, std::string(), nullptr, ++m_task_seq }); + m_tasks.push_back({ Backup, 0, std::string(), nullptr, { ++m_task_seq } }); m_next_backup += boost::posix_time::seconds(m_interval); // Maybe wakeup from power sleep if (m_next_backup < boost::get_system_time()) @@ -9134,6 +9183,126 @@ std::string bbs_3mf_get_thumbnail(const char *path) return data; } +namespace { + +// Parses just the model-file elements, mirroring the importer's +// _handle_start_metadata/_handle_end_metadata (attribute-order independent, entity-unescaped, +// whitespace tolerant). Stops the parser as soon as the published flag node is read so the +// geometry/resources that follow are skipped, which keeps the per-file cost small. +struct PublishedXmlProbe +{ + XML_Parser parser{nullptr}; + bool in_metadata{false}; + bool found{false}; + bool published{false}; + std::string curr_name; + std::string curr_value; + + static std::string attribute(const char** attrs, const char* key) + { + if (attrs == nullptr) + return std::string(); + // expat hands the attrs as a NULL-terminated {name, value, ...} array. + for (unsigned int a = 0; attrs[a] != nullptr; a += 2) + if (::strcmp(attrs[a], key) == 0 && attrs[a + 1] != nullptr) + return attrs[a + 1]; + return std::string(); + } + + static void XMLCALL start(void* user_data, const char* name, const char** attrs) + { + auto* self = static_cast(user_data); + if (::strcmp(name, METADATA_TAG) == 0) { + self->in_metadata = true; + self->curr_name = attribute(attrs, NAME_ATTR); + self->curr_value.clear(); + } else { + self->in_metadata = false; + } + } + + static void XMLCALL characters(void* user_data, const XML_Char* s, int len) + { + auto* self = static_cast(user_data); + if (self->in_metadata) + self->curr_value.append(s, len); + } + + static void XMLCALL end(void* user_data, const char* name) + { + auto* self = static_cast(user_data); + if (!self->in_metadata || ::strcmp(name, METADATA_TAG) != 0) + return; + self->in_metadata = false; + if (self->curr_name == ORCA_PUBLISHED_TAG) { + self->published = is_published_3mf_flag(xml_unescape(self->curr_value)); + self->found = true; + if (self->parser != nullptr) + XML_StopParser(self->parser, false); + } + } +}; + +} // namespace + +bool bbs_3mf_is_published(const std::string &path) +{ + mz_zip_archive archive; + mz_zip_zero_struct(&archive); + + struct close_lock + { + mz_zip_archive *archive; + void close() + { + if (archive) { + close_zip_reader(archive); + archive = nullptr; + } + } + ~close_lock() { close(); } + } lock{&archive}; + + if (!open_zip_reader(&archive, path)) + return false; + + // Read just the model XML (the metadata node sits before the resources, so the probe below + // stops early) rather than by a raw substring match; no geometry parsing. + int index = mz_zip_reader_locate_file(&archive, MODEL_FILE.c_str(), nullptr, 0); + if (index < 0) + return false; + mz_zip_archive_file_stat stat; + if (!mz_zip_reader_file_stat(&archive, index, &stat)) + return false; + std::string xml(stat.m_uncomp_size, '\0'); + if (!mz_zip_reader_extract_to_mem(&archive, index, xml.data(), xml.size(), 0)) + return false; + + XML_Parser parser = XML_ParserCreate(nullptr); + if (parser == nullptr) + return false; + + PublishedXmlProbe probe; + probe.parser = parser; + XML_SetUserData(parser, &probe); + XML_SetElementHandler(parser, PublishedXmlProbe::start, PublishedXmlProbe::end); + XML_SetCharacterDataHandler(parser, PublishedXmlProbe::characters); + // Never resolve external entities from a file we are only probing. + XML_SetExternalEntityRefHandler(parser, nullptr); + XML_SetEntityDeclHandler(parser, nullptr); + + const XML_Status status = XML_Parse(parser, xml.data(), static_cast(xml.size()), 1); + // XML_StopParser(parser, false) from the end handler makes XML_Parse return + // XML_STATUS_ERROR with XML_ERROR_ABORTED - treat that as success (we stopped on the flag). + const bool parse_ok = (status == XML_STATUS_OK) || + (XML_GetErrorCode(parser) == XML_ERROR_ABORTED && probe.found); + XML_ParserFree(parser); + + if (!parse_ok) + return false; + return probe.published; +} + bool load_gcode_3mf_from_stream(std::istream &data, DynamicPrintConfig *config, Model *model, PlateDataPtrs *plate_data_list, Semver *file_version) { CNumericLocalesSetter locales_setter; diff --git a/src/libslic3r/Format/bbs_3mf.hpp b/src/libslic3r/Format/bbs_3mf.hpp index 7f5bb8c78d..768ae7bcfe 100644 --- a/src/libslic3r/Format/bbs_3mf.hpp +++ b/src/libslic3r/Format/bbs_3mf.hpp @@ -159,12 +159,28 @@ enum class SaveStrategy SkipAuxiliary = 1 << 9, UseLoadedId = 1 << 10, ShareMesh = 1 << 11, + // Keep this separate from SplitModel, which uses the 0x1000 bit as part of its + // production-extension value. + MinimalPublished = 1 << 13, SplitModel = 0x1000 | ProductionExt, Encrypted = SecureContentExt | SplitModel, Backup = 0x10000 | WithGcode | Silence | SkipStatic | SplitModel, }; +// Model metadata keys of a "published" 3MF (see MinimalPublished): the flag marks a minimal, +// tag-less publish export, the others carry the author-selected settings payload. Namespaced +// with the "orca_published" prefix because metadata_items round-trips verbatim through other +// slicers, where a bare "published" key could collide. +inline constexpr const char *ORCA_PUBLISHED_TAG = "orca_published"; +inline constexpr const char *ORCA_PUBLISHED_KEYS_TAG = "orca_published_keys"; +inline constexpr const char *ORCA_PUBLISHED_MATERIAL_TAG = "orca_published_material_keys"; +inline constexpr const char *ORCA_PUBLISHED_CONFIG_TAG = "orca_published_config"; + +// Published files are produced with "1". The importer and the GUI loader both gate on this +// exact value, so a "0"/"false"/unknown value is rejected consistently. +bool is_published_3mf_flag(const std::string &value); + inline SaveStrategy operator | (SaveStrategy lhs, SaveStrategy rhs) { using T = std::underlying_type_t ; @@ -277,6 +293,9 @@ extern bool load_bbs_3mf(const char* path, DynamicPrintConfig* config, ConfigSub extern std::string bbs_3mf_get_thumbnail(const char * path); +// Lightweight check: does this 3mf carry the "published" (orca_published == "1") marker? Only reads the 3D/3dmodel.model metadata node +extern bool bbs_3mf_is_published(const std::string &path); + extern bool load_gcode_3mf_from_stream(std::istream & data, DynamicPrintConfig* config, Model* model, PlateDataPtrs* plate_data_list, Semver* file_version); diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp index 8f1dd8926c..714d45ad9b 100644 --- a/src/libslic3r/Model.cpp +++ b/src/libslic3r/Model.cpp @@ -3598,6 +3598,15 @@ void FacetsAnnotation::shift_states_above(const ModelVolume &mv, EnforcerBlocker this->set(selector); } +void FacetsAnnotation::remap_states(const ModelVolume &mv, const EnforcerBlockerStateMap &state_map) +{ + if (empty()) return; + TriangleSelector selector(mv.mesh()); + selector.deserialize(m_data, false); + selector.remap_triangle_state(state_map); + this->set(selector); +} + void FacetsAnnotation::set_enforcer_block_type_limit(const ModelVolume &mv, EnforcerBlockerType max_type, EnforcerBlockerType to_delete_filament, @@ -3862,6 +3871,43 @@ bool model_has_advanced_features(const Model &model) return false; } +void remap_model_filament_slots(Model &model, const std::map &slot_relocations) +{ + if (slot_relocations.empty()) + return; + + // Paint states and the object/volume "extruder" configs store one-based slot numbers + // (see Sidebar::on_action_add_filament's insertion remap for the same encoding). + std::map one_based_slots; + for (const auto &[from, to] : slot_relocations) + one_based_slots.emplace(from + 1, to + 1); + + EnforcerBlockerStateMap paint_state_map; + for (size_t state = 0; state < paint_state_map.size(); ++state) + paint_state_map[state] = EnforcerBlockerType(state); + for (const auto &[one_based_from, one_based_to] : one_based_slots) { + assert(one_based_from >= 0 && size_t(one_based_from) < paint_state_map.size()); + assert(one_based_to > 0 && size_t(one_based_to) < paint_state_map.size()); + paint_state_map[size_t(one_based_from)] = EnforcerBlockerType(one_based_to); + } + + auto remap_extruder_config = [&one_based_slots](ModelConfig &config) -> bool { + const auto it = config.has("extruder") ? one_based_slots.find(config.extruder()) : one_based_slots.end(); + if (it == one_based_slots.end()) + return false; + config.set("extruder", it->second); + return true; + }; + + for (ModelObject *object : model.objects) { + remap_extruder_config(object->config); + for (ModelVolume *volume : object->volumes) { + remap_extruder_config(volume->config); + volume->mmu_segmentation_facets.remap_states(*volume, paint_state_map); + } + } +} + #ifndef NDEBUG // Verify whether the IDs of Model / ModelObject / ModelVolume / ModelInstance / ModelMaterial are valid and unique. void check_model_ids_validity(const Model &model) diff --git a/src/libslic3r/Model.hpp b/src/libslic3r/Model.hpp index 6834c7a59b..8da1340fa9 100644 --- a/src/libslic3r/Model.hpp +++ b/src/libslic3r/Model.hpp @@ -745,6 +745,10 @@ public: // Shift painted filament indices >= threshold by delta. Used when a physical filament is // inserted ahead of existing slots (mixed-color slots are kept at the end of the list). void shift_states_above(const ModelVolume &mv, EnforcerBlockerType threshold, int delta); + // Relabel painted filament indices according to state_map (old state value -> new state + // value; untouched states keep their identity). Used when published-3MF import relocates + // mixed-filament definitions onto new slot numbers. + void remap_states(const ModelVolume &mv, const EnforcerBlockerStateMap &state_map); indexed_triangle_set get_facets_strict(const ModelVolume& mv, EnforcerBlockerType type) const; bool has_facets(const ModelVolume& mv, EnforcerBlockerType type) const; bool empty() const { return m_data.triangles_to_split.empty(); } @@ -1790,6 +1794,13 @@ bool model_has_multi_part_objects(const Model &model); // If the model has advanced features, then it cannot be processed in simple mode. bool model_has_advanced_features(const Model &model); +// Remap the model's filament-slot references after a published-3MF import relocated +// mixed-filament definitions onto new slot numbers: object/volume "extruder" configs and +// multi-material color-painting states (paint state stores the one-based slot number). +// slot_relocations maps the author's zero-based slot number to its final zero-based slot; +// entries are applied simultaneously (no chained lookups), untouched slots keep everything. +void remap_model_filament_slots(Model &model, const std::map &slot_relocations); + #ifndef NDEBUG // Verify whether the IDs of Model / ModelObject / ModelVolume / ModelInstance / ModelMaterial are valid and unique. void check_model_ids_validity(const Model &model); diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 279ad49d92..3cf85e8054 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -3062,6 +3062,75 @@ void PresetCollection::save_current_preset(const std::string &new_name, bool det this->get_selected_preset().save(nullptr); } +// A detached standalone preset for the Full Publish receiver: create a user preset holding +// the full resolved filament config (no inheritance, no vendor/alias links), parentless. +// Note: universal printer compatibility is not enforced here - callers apply +// make_publish_universal() to the config before handing it over when they need it. +// Mirrors save_current_preset(detach=true)'s creation branch but does not force-select or +// diff against a parent; the caller decides whether to select it. +// The published entry's filament_id is forwarded so user bases keep their stable +// material grouping (get_filament_presets() groups user bases by filament_id). +// The copy is a project-embedded preset: it lives inside the loaded project only +// (serialized into the saved .3mf, restored by load_project_embedded_presets) and +// never touches the user's library directory; Preset::save() early-returns for +// embedded presets, so persistence is skipped here too. +// Returns the final (uniquified) name; on collision "" -> " (Published)" -> +// " (Published 2)" ... +std::string PresetCollection::add_detached_preset(const std::string &name_base, DynamicPrintConfig config, + const std::string &filament_id) +{ + if (name_base.empty()) + return std::string(); + Preset stored(m_type, name_base); + stored.config = std::move(config); + stored.filament_id = filament_id; + + // Uniquify verbatim; only on collision append " (Published)" then " (Published 2)". + const std::string base_name = name_base; + std::string final_name = base_name; + auto exists = [this](const std::string &candidate) -> bool { + const auto it = this->find_preset_internal(candidate); + return it != m_presets.end() && it->name == candidate; + }; + if (exists(final_name)) { + final_name = base_name + " (Published)"; + for (int i = 2; exists(final_name); ++i) + final_name = base_name + " (Published " + std::to_string(i) + ")"; + } + + // Creation branch of save_current_preset(detach=true), without its selection side + // effects or project-embedded path. + lock(); + const auto it = this->find_preset_internal(final_name); + if (m_presets.begin() + m_idx_selected >= it) + ++m_idx_selected; + Preset &preset = *m_presets.insert(it, stored); + preset.name = final_name; + preset.vendor = nullptr; + preset.alias.clear(); + preset.renamed_from.clear(); + preset.m_excluded_from.clear(); + preset.setting_id.clear(); + preset.inherits().clear(); + preset.version = Semver::parse(SoftFever_VERSION).value_or(Semver()); + preset.is_default = false; + preset.is_system = false; + preset.is_external = false; + preset.bundle_id.clear(); + preset.file = this->path_for_preset(preset); + preset.is_visible = true; + preset.is_project_embedded = true; + if (m_type == Preset::TYPE_PRINT) + preset.config.option("print_settings_id", true)->value = final_name; + else if (m_type == Preset::TYPE_FILAMENT) + preset.config.option("filament_settings_id", true)->values[0] = final_name; + else if (m_type == Preset::TYPE_PRINTER) + preset.config.option("printer_settings_id", true)->value = final_name; + unlock(); + + return final_name; +} + bool PresetCollection::delete_current_preset() { Preset &selected = this->get_selected_preset(); diff --git a/src/libslic3r/Preset.hpp b/src/libslic3r/Preset.hpp index 6a7871d07d..2653628ead 100644 --- a/src/libslic3r/Preset.hpp +++ b/src/libslic3r/Preset.hpp @@ -631,6 +631,22 @@ public: // All presets are marked as not modified and the new preset is activated. //BBS: add project embedded preset logic void save_current_preset(const std::string &new_name, bool detach = false, bool save_to_project = false, Preset* _curr_preset = nullptr); + // Insert a standalone user preset holding the full resolved config (no inheritance, + // no vendor links): the libslic3r equivalent of "Detach from parent". Takes a + // resolved config, clears parent/vendor/alias metadata, stamps filament_settings_id. + // Unlike save_current_preset it does not force-select or diff against a parent. + // Used by the published-3MF Full Publish path. The optional filament_id seeds the + // preset's stable material grouping (get_filament_presets groups user bases by + // filament_id); the published entry's filament_id is forwarded so the copy keeps + // the author's grouping. + // The copy is a project-embedded preset ("Preset Inside Project"): it lives inside + // the loaded project only, is serialized into the saved .3mf via + // get_current_project_embedded_presets(), and is never written to the user's + // library directory. + // Returns the final (uniquified) name; on collision the suffix rule is: + // "" -> " (Published)" -> " (Published 2)" ... + std::string add_detached_preset(const std::string &name_base, DynamicPrintConfig config, + const std::string &filament_id = std::string()); // Delete the current preset, activate the first visible preset. // returns true if the preset was deleted successfully. diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index f17fd5c768..c90ebc756b 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -7,16 +7,18 @@ #include "PresetCacheFormat.hpp" #include "PrintConfig.hpp" +#include "PublishSettings.hpp" #include "FilamentMixer.hpp" #include "libslic3r.h" #include "I18N.hpp" #include "Utils.hpp" #include "LocalesUtils.hpp" #include "Model.hpp" +#include "TriangleSelector.hpp" #include "libslic3r_version.h" #include -#include +#include #include #include #include @@ -45,6 +47,10 @@ namespace Slic3r { +// Project-level options imported from a loaded 3MF into project_config. s_project_options_published +// below is the reduced subset that crosses over in "published" 3MF mode; keep both in sync. +// s_project_options_published additionally carries wipe_tower_rotation_angle, which normal +// loads do not import (it is not listed here): published-only plate geometry. static std::vector s_project_options { "flush_volumes_vector", "flush_volumes_matrix", @@ -85,6 +91,21 @@ static std::vector s_project_options { "filament_mixed_gradient_per_part" }; +// Project options applied when loading a "published" 3MF project: s_project_options minus the +// filament/purge keys, plus wipe_tower_rotation_angle (plate geometry that only published +// loads import today). A published file must not port the author's filament data +// (colors, colour types, filament/map/AMS slot state, purge/prime/flush volumes, nozzle +// volume types, filament switcher state) to the receiver's project_config, which feeds the +// scene colors, AMS slot colors and purge data. Only the plate/bed geometry keys cross over; +// normal (non-published) 3MF loads keep importing the author's flush data via s_project_options. +// +// KEEP IN SYNC with s_project_options above: when a new project option is added there, decide +// here whether it is plate/bed geometry (add it to this list) or filament/purge/mapping/device +// state (it must NOT be added). curr_bed_type is deliberately NOT in this list: the receiver +// keeps its own bed type when loading a published project. The published-mode project_config +// assertions in tests/libslic3r/test_preset_bundle_loading.cpp guard both directions. +static std::vector s_project_options_published{"wipe_tower_x", "wipe_tower_y", "wipe_tower_rotation_angle"}; + //Orca: add custom as default const char *PresetBundle::ORCA_DEFAULT_BUNDLE = "Custom"; const char *PresetBundle::ORCA_DEFAULT_PRINTER_MODEL = "MyKlipper 0.4 nozzle"; @@ -4940,6 +4961,142 @@ static std::map> filament_preset {"Bambu ASA @BBL H2D 0.6 nozzle", "Bambu ASA @BBL H2D 0.8 nozzle"}}} }; +// Relocate the per-slot cells of one mixed-filament project vector inside the imported +// config, applying every authored-slot -> destination move at once. Each move reads its +// source cell from a frozen snapshot of the config's current cells, so relocations never +// cross-contaminate when an earlier destination overlaps a later source (adjacent published +// tail mixes relocate onto consecutive slots). Cells the snapshot lacks degrade to +// empty/false defaults - the missing-data handling in the material pass reports them +// downstream. Left-behind source cells stay as-is: nothing else consumes the imported config +// at those indices in published mode. +static void apply_mixed_config_relocations(DynamicPrintConfig& config, + const std::string& key, + const std::vector>& moves) +{ + ConfigOption* opt = config.optptr(key); + if (opt == nullptr || moves.empty()) + return; + std::unique_ptr snapshot(opt->clone()); + switch (opt->type()) { + case coBools: { + auto* live = static_cast(opt); + const auto* frozen = static_cast(snapshot.get()); + for (const auto [from, to] : moves) { + const unsigned char cell = from < frozen->values.size() ? frozen->values[from] : 0; + if (live->values.size() <= to) + live->values.resize(to + 1, 0); + live->values[to] = cell; + } + break; + } + case coStrings: { + auto* live = static_cast(opt); + const auto* frozen = static_cast(snapshot.get()); + for (const auto [from, to] : moves) { + const std::string cell = from < frozen->values.size() ? frozen->values[from] : std::string(); + if (live->values.size() <= to) + live->values.resize(to + 1, std::string{}); + live->values[to] = cell; + } + break; + } + default: break; + } +} + +// Relocate the per-slot cells of the receiver's OWN mixed-filament definitions (the ones that +// pre-existed in project_config) onto fresh tail slots, clearing each vacated source cell so an +// incoming published real filament can claim it. Unlike apply_mixed_config_relocations - which +// moves the incoming file's config and leaves sources alone - a displaced receiver mix must not +// keep its mixed flag in the physical region: the source slot becomes a physical slot, so its +// mixed flag and definition are reset, while its swatch colour and mapping travel with the +// definition to the tail slot. Reads come from a frozen snapshot so an earlier move's +// destination never overwrites a later move's still-unread source (sources sit in the physical +// region and destinations past it, so they cannot overlap, but the snapshot keeps the helper +// safe for any future reordering). +static void apply_receiver_mix_relocations(DynamicPrintConfig& config, + std::vector>& ams_multi_color_filment, + const std::vector>& moves) +{ + if (moves.empty()) + return; + + auto move_bools = [&](const char* key, bool clear_source) { + ConfigOption* opt = config.optptr(key); + if (opt == nullptr) + return; + auto* live = static_cast(opt); + std::unique_ptr snapshot(opt->clone()); + const auto* frozen = static_cast(snapshot.get()); + for (const auto [from, to] : moves) { + const bool cell = from < frozen->values.size() ? frozen->values[from] : false; + if (live->values.size() <= to) + live->values.resize(to + 1, false); + live->values[to] = cell; + if (clear_source && from < live->values.size()) + live->values[from] = false; + } + }; + auto move_strings = [&](const char* key, bool clear_source) { + ConfigOption* opt = config.optptr(key); + if (opt == nullptr) + return; + auto* live = static_cast(opt); + std::unique_ptr snapshot(opt->clone()); + const auto* frozen = static_cast(snapshot.get()); + for (const auto [from, to] : moves) { + const std::string cell = from < frozen->values.size() ? frozen->values[from] : std::string(); + if (live->values.size() <= to) + live->values.resize(to + 1, std::string{}); + live->values[to] = cell; + if (clear_source && from < live->values.size()) + live->values[from] = std::string{}; + } + }; + auto move_ints = [&](const char* key) { + ConfigOption* opt = config.optptr(key); + if (opt == nullptr) + return; + auto* live = static_cast(opt); + std::unique_ptr snapshot(opt->clone()); + const auto* frozen = static_cast(snapshot.get()); + for (const auto [from, to] : moves) { + const int cell = from < frozen->values.size() ? frozen->values[from] : 0; + if (live->values.size() <= to) + live->values.resize(to + 1, 0); + live->values[to] = cell; + } + }; + + // Mixed-definition cells: move to the tail and clear the source - the vacated physical slot + // no longer holds a mix. + move_bools("filament_is_mixed", true); + move_strings("filament_mixed_components", true); + move_strings("filament_mixed_sublayer_ratios", true); + move_bools("filament_mixed_gradient", true); + move_strings("filament_mixed_gradient_range", true); + move_strings("filament_mixed_gradient_curve", true); + move_bools("filament_mixed_gradient_per_part", true); + // Swatch colour and mapping travel with the definition; the source colour is left for the + // incoming real's publish_color (or the slot's resolved preset) to fill in. + move_strings("filament_colour", false); + move_strings("filament_multi_colour", false); + move_strings("filament_colour_type", false); + move_ints("filament_map"); + move_ints("filament_nozzle_map"); + move_ints("filament_volume_map"); + { + const std::vector> frozen = ams_multi_color_filment; + for (const auto [from, to] : moves) { + const std::vector cell = from < frozen.size() ? frozen[from] : std::vector(); + if (ams_multi_color_filment.size() <= to) + ams_multi_color_filment.resize(to + 1, std::vector{}); + ams_multi_color_filment[to] = cell; + } + } +} + + //convert the old filament preset to new one after split static void convert_filament_preset_name(std::string& machine_name, std::string& filament_name) { @@ -4956,10 +5113,14 @@ static void convert_filament_preset_name(std::string& machine_name, std::string& } // Load a config file from a boost property_tree. This is a private method called from load_config_file. // is_external == false on if called from ConfigWizard -void PresetBundle::load_config_file_config(const std::string &name_or_path, bool is_external, DynamicPrintConfig &&config, Semver file_version, bool selected) +void PresetBundle::load_config_file_config(const std::string &name_or_path, bool is_external, DynamicPrintConfig &&config, Semver file_version, bool selected, PublishedConfig *published_config) { PrinterTechnology printer_technology = Preset::printer_technology(config); + // A "published" 3MF project keeps the user's currently-selected presets and overlays only + // the author-selected published keys onto the edited presets. + const bool is_published = published_config != nullptr && published_config->published; + auto clear_compatible_printers = [](DynamicPrintConfig& config){ ConfigOption *opt_compatible = config.optptr("compatible_printers"); if (opt_compatible != nullptr) { @@ -5100,127 +5261,60 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool switch (Preset::printer_technology(config)) { case ptFFF: { - //BBS: add different settings logic - BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": load print preset from print_settings_id"); - std::vector print_different_keys_vector; - std::string print_different_settings = different_values[0]; - Slic3r::unescape_strings_cstyle(print_different_settings, print_different_keys_vector); - std::set print_different_keys_set(print_different_keys_vector.begin(), print_different_keys_vector.end()); - //if (!has_different_settings_to_system) { - // print_different_keys_set.clear(); - //} - //else - print_different_keys_set.insert(ignore_settings_list.begin(), ignore_settings_list.end()); - if (!print_compatible_printers.empty()) { - ConfigOptionStrings* compatible_printers = config.option("compatible_printers", true); - compatible_printers->values = print_compatible_printers; - } - - load_preset(this->prints, 0, "print_settings_id", print_different_keys_set, std::string()); - - //clear compatible printers - clear_compatible_printers(config); - - std::vector printer_different_keys_vector; - std::string printer_different_settings = different_values[num_filaments + 1]; - Slic3r::unescape_strings_cstyle(printer_different_settings, printer_different_keys_vector); - std::set printer_different_keys_set(printer_different_keys_vector.begin(), printer_different_keys_vector.end()); - //if (!has_different_settings_to_system) { - // printer_different_keys_set.clear(); - //} - //else - printer_different_keys_set.insert(ignore_settings_list.begin(), ignore_settings_list.end()); - //BBS: add config related logs - BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": load printer preset from printer_settings_id"); - load_preset(this->printers, num_filaments + 1, "printer_settings_id", printer_different_keys_set, std::string()); - - // 3) Now load the filaments. If there are multiple filament presets, split them and load them. - auto old_filament_profile_names = config.option("filament_settings_id", true); - old_filament_profile_names->values.resize(num_filaments, std::string()); - - auto old_machine_profile_name = config.option("printer_settings_id", true); - - if (num_filaments <= 1) { - // Split the "compatible_printers_condition" and "inherits" values from the cummulative vectors to separate filament presets. - inherits = inherits_values[1]; - compatible_printers_condition = compatible_printers_condition_values[1]; - compatible_prints_condition = compatible_prints_condition_values.front(); - Preset *loaded = nullptr; - + // A "published" 3MF project keeps the user's currently-selected presets, so the + // print / printer / filament presets are NOT loaded from the file. Only the + // project config values and the published keys are applied below. + if (!is_published) { //BBS: add different settings logic - std::vector filament_different_keys_vector; - std::string filament_different_settings = different_values[1]; - Slic3r::unescape_strings_cstyle(filament_different_settings, filament_different_keys_vector); - std::set filament_different_keys_set(filament_different_keys_vector.begin(), filament_different_keys_vector.end()); + BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": load print preset from print_settings_id"); + std::vector print_different_keys_vector; + std::string print_different_settings = different_values[0]; + Slic3r::unescape_strings_cstyle(print_different_settings, print_different_keys_vector); + std::set print_different_keys_set(print_different_keys_vector.begin(), print_different_keys_vector.end()); //if (!has_different_settings_to_system) { - // filament_different_keys_set.clear(); + // print_different_keys_set.clear(); //} //else - filament_different_keys_set.insert(ignore_settings_list.begin(), ignore_settings_list.end()); + print_different_keys_set.insert(ignore_settings_list.begin(), ignore_settings_list.end()); + if (!print_compatible_printers.empty()) { + ConfigOptionStrings* compatible_printers = config.option("compatible_printers", true); + compatible_printers->values = print_compatible_printers; + } - std::string filament_id = filament_ids[0]; + load_preset(this->prints, 0, "print_settings_id", print_different_keys_set, std::string()); + + //clear compatible printers + clear_compatible_printers(config); + + std::vector printer_different_keys_vector; + std::string printer_different_settings = different_values[num_filaments + 1]; + Slic3r::unescape_strings_cstyle(printer_different_settings, printer_different_keys_vector); + std::set printer_different_keys_set(printer_different_keys_vector.begin(), printer_different_keys_vector.end()); + //if (!has_different_settings_to_system) { + // printer_different_keys_set.clear(); + //} + //else + printer_different_keys_set.insert(ignore_settings_list.begin(), ignore_settings_list.end()); //BBS: add config related logs - BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": load single filament preset from filament_settings_id"); - if (is_external) { - if (inherits.empty()) - convert_filament_preset_name(old_machine_profile_name->value, old_filament_profile_names->values.front()); - else - convert_filament_preset_name(old_machine_profile_name->value, inherits); - loaded = this->filaments.load_external_preset(name_or_path, name, old_filament_profile_names->values.front(), config, filament_different_keys_set, PresetCollection::LoadAndSelect::Always, file_version, filament_id).first; - } - else { - // called from Config Wizard. - loaded= &this->filaments.load_preset(this->filaments.path_from_name(name, inherits.empty()), name, config, true, file_version); - loaded->save(nullptr); - } - this->filament_presets.clear(); - this->filament_presets.emplace_back(loaded->name); - } else { - assert(is_external); - // Split the filament presets, load each of them separately. - std::vector configs(num_filaments, this->filaments.default_preset().config); - // loop through options and scatter them into configs. - for (const t_config_option_key &key : this->filaments.default_preset().config.keys()) { - ConfigOption *other_opt = config.option(key); - if (other_opt == nullptr) - continue; - if (other_opt->is_scalar()) { - for (size_t i = 0; i < configs.size(); ++ i) - configs[i].option(key, false)->set(other_opt); - } - else if (key != "compatible_printers" && key != "compatible_prints") { - for (size_t i = 0; i < configs.size(); ++i) { - if (process_multi_extruder && (filament_options_with_variant.find(key) != filament_options_with_variant.end())) { - ConfigOptionVectorBase* other_opt_vec = static_cast(other_opt); - if (other_opt_vec->size() != extruder_variant_count) { - other_opt_vec->resize(extruder_variant_count); - } - size_t next_index = (i < (configs.size() - 1)) ? filament_variant_index[i + 1] : extruder_variant_count; - static_cast(configs[i].option(key, false))->set(other_opt, filament_variant_index[i], next_index - filament_variant_index[i]); - } - else - static_cast(configs[i].option(key, false))->set_at(other_opt, 0, i); - } - } - } - // Load the configs into this->filaments and make them active. - this->filament_presets = std::vector(configs.size()); - // To avoid incorrect selection of the first filament preset (means a value of Preset->m_idx_selected) - // in a case when next added preset take a place of previosly selected preset, - // we should add presets from last to first - bool any_modified = false; - //BBS: add config related logs - BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": load multiple filament preset from filament_settings_id"); - for (int i = (int)configs.size()-1; i >= 0; i--) { - DynamicPrintConfig &cfg = configs[i]; - // Split the "compatible_printers_condition" and "inherits" from the cummulative vectors to separate filament presets. - cfg.opt_string("compatible_printers_condition", true) = compatible_printers_condition_values[i + 1]; - cfg.opt_string("compatible_prints_condition", true) = compatible_prints_condition_values[i]; - cfg.opt_string("inherits", true) = inherits_values[i + 1]; + BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": load printer preset from printer_settings_id"); + load_preset(this->printers, num_filaments + 1, "printer_settings_id", printer_different_keys_set, std::string()); + + // 3) Now load the filaments. If there are multiple filament presets, split them and load them. + auto old_filament_profile_names = config.option("filament_settings_id", true); + old_filament_profile_names->values.resize(num_filaments, std::string()); + + auto old_machine_profile_name = config.option("printer_settings_id", true); + + if (num_filaments <= 1) { + // Split the "compatible_printers_condition" and "inherits" values from the cummulative vectors to separate filament presets. + inherits = inherits_values[1]; + compatible_printers_condition = compatible_printers_condition_values[1]; + compatible_prints_condition = compatible_prints_condition_values.front(); + Preset *loaded = nullptr; //BBS: add different settings logic std::vector filament_different_keys_vector; - std::string filament_different_settings = different_values[i+1]; + std::string filament_different_settings = different_values[1]; Slic3r::unescape_strings_cstyle(filament_different_settings, filament_different_keys_vector); std::set filament_different_keys_set(filament_different_keys_vector.begin(), filament_different_keys_vector.end()); //if (!has_different_settings_to_system) { @@ -5229,32 +5323,105 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool //else filament_different_keys_set.insert(ignore_settings_list.begin(), ignore_settings_list.end()); - std::string filament_id = filament_ids[i]; + std::string filament_id = filament_ids[0]; + //BBS: add config related logs + BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": load single filament preset from filament_settings_id"); + if (is_external) { + if (inherits.empty()) + convert_filament_preset_name(old_machine_profile_name->value, old_filament_profile_names->values.front()); + else + convert_filament_preset_name(old_machine_profile_name->value, inherits); + loaded = this->filaments.load_external_preset(name_or_path, name, old_filament_profile_names->values.front(), config, filament_different_keys_set, PresetCollection::LoadAndSelect::Always, file_version, filament_id).first; + } + else { + // called from Config Wizard. + loaded= &this->filaments.load_preset(this->filaments.path_from_name(name, inherits.empty()), name, config, true, file_version); + loaded->save(nullptr); + } + this->filament_presets.clear(); + this->filament_presets.emplace_back(loaded->name); + } else { + assert(is_external); + // Split the filament presets, load each of them separately. + std::vector configs(num_filaments, this->filaments.default_preset().config); + // loop through options and scatter them into configs. + for (const t_config_option_key &key : this->filaments.default_preset().config.keys()) { + ConfigOption *other_opt = config.option(key); + if (other_opt == nullptr) + continue; + if (other_opt->is_scalar()) { + for (size_t i = 0; i < configs.size(); ++ i) + configs[i].option(key, false)->set(other_opt); + } + else if (key != "compatible_printers" && key != "compatible_prints") { + for (size_t i = 0; i < configs.size(); ++i) { + if (process_multi_extruder && (filament_options_with_variant.find(key) != filament_options_with_variant.end())) { + ConfigOptionVectorBase* other_opt_vec = static_cast(other_opt); + if (other_opt_vec->size() != extruder_variant_count) { + other_opt_vec->resize(extruder_variant_count); + } + size_t next_index = (i < (configs.size() - 1)) ? filament_variant_index[i + 1] : extruder_variant_count; + static_cast(configs[i].option(key, false))->set(other_opt, filament_variant_index[i], next_index - filament_variant_index[i]); + } + else + static_cast(configs[i].option(key, false))->set_at(other_opt, 0, i); + } + } + } + // Load the configs into this->filaments and make them active. + this->filament_presets = std::vector(configs.size()); + // To avoid incorrect selection of the first filament preset (means a value of Preset->m_idx_selected) + // in a case when next added preset take a place of previosly selected preset, + // we should add presets from last to first + bool any_modified = false; + //BBS: add config related logs + BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": load multiple filament preset from filament_settings_id"); + for (int i = (int)configs.size()-1; i >= 0; i--) { + DynamicPrintConfig &cfg = configs[i]; + // Split the "compatible_printers_condition" and "inherits" from the cummulative vectors to separate filament presets. + cfg.opt_string("compatible_printers_condition", true) = compatible_printers_condition_values[i + 1]; + cfg.opt_string("compatible_prints_condition", true) = compatible_prints_condition_values[i]; + cfg.opt_string("inherits", true) = inherits_values[i + 1]; - // Load all filament presets, but only select the first one in the preset dialog. - std::string& filament_inherit = cfg.opt_string("inherits", true); - if (filament_inherit.empty() && (i < int(old_filament_profile_names->values.size()))) - convert_filament_preset_name(old_machine_profile_name->value, old_filament_profile_names->values[i]); - else - convert_filament_preset_name(old_machine_profile_name->value, filament_inherit); - auto [loaded, modified] = this->filaments.load_external_preset(name_or_path, name, - (i < int(old_filament_profile_names->values.size())) ? old_filament_profile_names->values[i] : "", - std::move(cfg), - filament_different_keys_set, - i == 0 ? - PresetCollection::LoadAndSelect::Always : - any_modified ? - PresetCollection::LoadAndSelect::Never : - PresetCollection::LoadAndSelect::OnlyIfModified, - file_version, - filament_id); - any_modified |= modified; - this->filament_presets[i] = loaded->name; + //BBS: add different settings logic + std::vector filament_different_keys_vector; + std::string filament_different_settings = different_values[i+1]; + Slic3r::unescape_strings_cstyle(filament_different_settings, filament_different_keys_vector); + std::set filament_different_keys_set(filament_different_keys_vector.begin(), filament_different_keys_vector.end()); + //if (!has_different_settings_to_system) { + // filament_different_keys_set.clear(); + //} + //else + filament_different_keys_set.insert(ignore_settings_list.begin(), ignore_settings_list.end()); + + std::string filament_id = filament_ids[i]; + + // Load all filament presets, but only select the first one in the preset dialog. + std::string& filament_inherit = cfg.opt_string("inherits", true); + if (filament_inherit.empty() && (i < int(old_filament_profile_names->values.size()))) + convert_filament_preset_name(old_machine_profile_name->value, old_filament_profile_names->values[i]); + else + convert_filament_preset_name(old_machine_profile_name->value, filament_inherit); + auto [loaded, modified] = this->filaments.load_external_preset(name_or_path, name, + (i < int(old_filament_profile_names->values.size())) ? old_filament_profile_names->values[i] : "", + std::move(cfg), + filament_different_keys_set, + i == 0 ? + PresetCollection::LoadAndSelect::Always : + any_modified ? + PresetCollection::LoadAndSelect::Never : + PresetCollection::LoadAndSelect::OnlyIfModified, + file_version, + filament_id); + any_modified |= modified; + this->filament_presets[i] = loaded->name; + } } - } + } // !is_published - // 4) Load the project config values (the per extruder wipe matrix etc). - this->project_config.apply_only(config, s_project_options); + // Load the project config values. In published mode only the plate/bed geometry keys + // cross over (the receiver must not inherit the author's filament/purge data). + this->project_config.apply_only(config, is_published ? s_project_options_published : s_project_options); break; } @@ -5273,18 +5440,1143 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool this->update_compatible(PresetSelectCompatibleType::Never); this->update_multi_material_filament_presets(); + // A "published" 3MF project overlays the author-selected published keys onto the user's + // currently-selected (edited) process preset. Keys that cannot be applied are collected for + // notification; filament-class keys in published_keys (which only the material pass knows + // how to apply) fall through into skipped_keys. + if (is_published) { + std::vector skipped_keys; + std::set applied_keys; + // Only re-select the edited filament preset when the material overlay changed + // something: re-selecting unconditionally would discard the user's unsaved in-memory + // filament edits when the published file touches nothing. + bool material_applied = false; + // Structural keys are never applied (they would rewrite the user's preset + // inheritance/structure). Defense-in-depth: a hand-crafted 3MF could list them despite + // the dialog, so skip them here too. + const std::set& structural_keys = publish_structural_keys(); + // The printer overlay is restricted to the publishable retraction/z-hop allowlist; + // printer-class keys outside it are contract-excluded (never applied, never reported). + const std::set& printer_allowlist = publishable_printer_keys(); + const std::vector& printer_options = Preset::printer_options(); + const std::set printer_option_set(printer_options.begin(), printer_options.end()); + std::set contract_excluded_keys; + auto apply_published = [&](DynamicPrintConfig& target, const std::set* allowlist) { + // Single-extruder receivers collapse a base key's per-extruder "#N" variants onto + // their single slot: only the first serialized variant of a base key is applied + // (the author's "left or right" whichever came first), the rest are reported as + // skipped. Per-target, so the process and printer passes each track their own bases. + std::set collapsed_bases; + for (const std::string& key : published_config->published_keys) { + if (applied_keys.count(key) != 0) + continue; // already applied + // A '#' suffix denotes a variant key; resolve the base key. + const std::string base_key = publish_base_key(key); + // Structural keys are never applied (not "skipped due to mismatch"), so bail + // out before the applied/skipped bookkeeping. + if (structural_keys.count(base_key) != 0) + continue; + if (allowlist != nullptr && printer_option_set.count(base_key) != 0 && allowlist->count(base_key) == 0) { + // Printer-class key outside the publishable allowlist: contract-excluded. + contract_excluded_keys.insert(base_key); + continue; + } + const ConfigOption* src_opt = config.option(base_key); + if (src_opt == nullptr) + continue; // key not present in the loaded config; record later + if (src_opt->is_vector()) { + ConfigOption* dst_opt = target.option(base_key); + if (dst_opt == nullptr || !dst_opt->is_vector()) + continue; // cannot apply; will be reported as skipped + // Type mismatch: ConfigOptionVector::set() throws ConfigurationError on a + // mismatch, which would abort the whole project load; report the key as + // skipped instead (the material pass guards the same way). Note that the + // nullable variants report the same type() as their non-nullable base, so + // this catches genuinely different option kinds (e.g. a string where a float + // vector is expected), not nullable-ness. + if (dst_opt->type() != src_opt->type()) + continue; + // A '#N' variant key (e.g. per-extruder retraction_length#2) applies one + // element, so the index only needs to be in range on the author's side - the + // receiver may have a different extruder count than the author. Out-of-range + // indices are skipped (set_at would otherwise resize the receiver's vector). + if (key.size() > base_key.size()) { + // Strict numeric suffix parse: a malformed variant ("#abc", "#1x") + // must be reported as skipped, not silently applied as element 0. + const std::string suffix = key.substr(base_key.size() + 1); + size_t idx = 0; + bool valid = !suffix.empty(); + for (const char c : suffix) { + if (c < '0' || c > '9') { + valid = false; + break; + } + idx = idx * 10 + size_t(c - '0'); + if (idx > 1000000) { // overflow guard; real vector sizes are tiny + valid = false; + break; + } + } + const size_t src_size = static_cast(src_opt)->size(); + if (!valid || idx >= src_size) + continue; // malformed or out-of-range on the author's side: cannot apply; reported as skipped + const size_t dst_size = static_cast(dst_opt)->size(); + if (dst_size == 1) { + // Single-extruder receiver: collapse the author's per-extruder slots + // onto the receiver's single slot. Only the first serialized variant + // of a base key is applied (the author's "left or right" whichever was + // published first); later variants of the same base are reported as + // skipped, mirroring the receiver's single extruder. + if (collapsed_bases.count(base_key) != 0) + continue; + collapsed_bases.insert(base_key); + static_cast(dst_opt)->set_at(src_opt, 0, idx); + } else { + if (idx >= dst_size) + continue; // out-of-range variant: cannot apply; reported as skipped + target.apply_only(config, {key}, true); + } + } else if (static_cast(src_opt)->size() != + static_cast(dst_opt)->size()) { + // Whole-vector base key: the receiver must have a matching vector size, + // otherwise applying would overwrite a different number of elements. + continue; // cannot apply; will be reported as skipped + } else { + target.apply_only(config, {key}, true); + } + applied_keys.insert(key); + } else { + // A scalar key cannot carry a '#N' suffix; a hand-crafted file listing one + // is reported as skipped instead of being silently marked applied. + if (key.find('#') != std::string::npos) + continue; + // Scalar key: apply only if present on the user's machine. + const ConfigOption* dst_opt = target.option(base_key); + if (dst_opt == nullptr) + continue; // not present on the edited preset; will be reported as skipped + // Type mismatch: skipped, never thrown (see the vector branch above). + if (dst_opt->type() != src_opt->type()) + continue; + target.apply_only(config, {key}, true); + applied_keys.insert(key); + } + } + }; + apply_published(this->prints.get_edited_preset().config, nullptr); + apply_published(this->printers.get_edited_preset().config, &printer_allowlist); + + // Material pass: positional per-slot entries. The author published, per slot, either the + // entire filament (full) or specific keys plus optionally a curated type and/or colour. + // - full: lands on a freshly created standalone detached copy (no library preset used + // or mutated); + // - partial: a type requirement gates application; on mismatch the slot is replaced + // with the best visible candidate by published identity (exact name, setting_id, + // filament_id, vendor+type, type); with no replacement the receiver's material is + // kept and the keys are reported as skipped; + // - colour: applied regardless of the type gate. + // - capacity: past the printer's physical nozzles the entry becomes an empty + // mixed-filament placeholder; on a single-physical-slot receiver it is dropped. + // Applied partial values land on the collection's edited layer when the slot references + // it and that layer survives the load, otherwise on the stored preset in place. Slots + // aliasing a shared preset are re-pointed at distinct presets before the values apply. + { + // Grow the receiver's slots only as far as the highest published slot (never + // shrink, never pull filler materials for unpublished slots). + bool has_published_entries = false; + // Physical filament capacity of the receiver's printer: a non-SEMM tool-changer + // feeds filament N from nozzle N, so the nozzle count is the hard limit; a SEMM + // printer (single_extruder_multi_material) sizes its slot list by hand, so only + // the global slot limit applies (same condition as GUI_App::load_current_presets). + // Published entries that would need a NEW physical slot past this capacity are + // appended as empty mixed-filament placeholders instead of growing the list. + size_t physical_capacity = size_t(EnforcerBlockerType::ExtruderMax); + { + const Preset& receiver_printer = this->printers.get_edited_preset(); + if (receiver_printer.printer_technology() == ptFFF && + !receiver_printer.config.opt_bool("single_extruder_multi_material")) { + if (const auto* nozzle_diameter = receiver_printer.config.option("nozzle_diameter"); + nozzle_diameter != nullptr && !nozzle_diameter->values.empty()) + physical_capacity = nozzle_diameter->values.size(); + } + } + const std::set& mixed_definitions = publish_mixed_keys(); + auto is_mixed_definition = [&mixed_definitions](const PublishedMaterialEntry& entry) { + return std::any_of(entry.keys.begin(), entry.keys.end(), [&](const std::string& key) { + return mixed_definitions.count(publish_base_key(key)) != 0; + }); + }; + size_t grow_target = 0; + for (const PublishedMaterialEntry& entry : published_config->material_keys) { + has_published_entries = true; + if (entry.slot < 0) + continue; + // A physical entry past the capacity becomes a tail placeholder below; its + // growth is covered by the append counter, not the positional target. + if (!is_mixed_definition(entry) && size_t(entry.slot) >= physical_capacity) + continue; + grow_target = std::max(grow_target, size_t(entry.slot) + 1); + } + // Mixed-filament definitions live in project-level virtual slots, so applying one + // positionally onto a receiver slot holding a real filament would convert hardware + // state into a virtual mix. Compute each entry's destination before anything + // consumes entry.slot: a definition on a slot that already holds a mixed definition + // keeps its place (like-for-like); everything else follows one monotone append + // counter preserving author order (dest = max(authored, next_free)). Appends past + // the extruder limit are dropped and reported. Physical entries past capacity join + // the same counter as mixed_placeholder empties the GUI flags for assignment. + // + // The finished project keeps the physical-first invariant (see the sidebar's + // add_custom_filament: mixed slots always sit at the tail, physical slots packed + // first). That invariant must survive an import, so every receiver mixed slot that + // would end up inside (or ahead of) the incoming physical region is displaced to a + // fresh tail slot, and the tail allocator starts at the physical boundary rather + // than the receiver's slot count - otherwise a relocated mix can collide with a + // keep-placed new real slot. + size_t physical_boundary = this->num_physical_filaments(); + for (const PublishedMaterialEntry& entry : published_config->material_keys) + // Mirror the payload-real keep-place decision below: a real keeps its authored + // slot when that slot already exists on the receiver, or lies within the + // printer's physical capacity. Both end up as physical slots at index + // entry.slot, so they bound the physical region even past the capacity. + if (entry.slot >= 0 && !is_mixed_definition(entry) && + (size_t(entry.slot) < this->filament_presets.size() || size_t(entry.slot) < physical_capacity)) + physical_boundary = std::max(physical_boundary, size_t(entry.slot) + 1); + size_t next_free_slot = std::max(physical_boundary, this->filament_presets.size()); + bool any_mixed_relocated = false; + // All authored-slot -> destination moves decided by this pass, applied to the + // incoming config in one batched snapshot step below (an earlier move's + // destination can overlap a later move's source: adjacent tail mixes relocate + // onto consecutive slots, so incremental in-place shifts would overwrite a + // definition that has not been moved yet). + std::vector> mixed_moves; + // Receiver-mix relocations land in project_config, not the incoming config, so they + // get their own move list, applied below after the arrays grow. The swept set lets + // the payload loop below treat a displaced receiver mix as the physical slot it will + // become (a payload mix like-for-like overriding such a slot must itself relocate). + std::vector> receiver_mix_moves; + std::set swept_mix_slots; + for (size_t slot = 0; slot < this->filament_presets.size(); ++slot) { + if (!this->is_mixed_filament(slot)) + continue; + // Slot 0 is the receiver's base filament and is never a virtual mix; the + // sidebar's physical-first layout guarantees it, so never displace it. + if (slot == 0) + continue; + if (slot >= physical_boundary) + continue; // already lives in the tail region + const size_t dest = next_free_slot++; + swept_mix_slots.insert(slot); + receiver_mix_moves.emplace_back(slot, dest); + any_mixed_relocated = true; + // A payload mix authored at the same slot (a mix inside the physical region) is + // processed after this sweep and overrides its own destination below. + published_config->mixed_slot_relocations.insert_or_assign(int(slot), int(dest)); + published_config->material_replacements.emplace_back("slot " + std::to_string(slot) + " -> slot " + + std::to_string(dest) + ": mixed filament"); + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": published 3MF relocated receiver mixed filament slot " << slot + << " -> " << dest << " (physical-first rebalance)"; + } + for (auto entry_it = published_config->material_keys.begin(); entry_it != published_config->material_keys.end();) { + PublishedMaterialEntry& entry = *entry_it; + if (entry.slot < 0) { + ++entry_it; + continue; + } + const bool is_payload_mix = is_mixed_definition(entry); + // Does this entry need a tail slot at all? A payload mixed definition does, + // except when it like-for-like overrides a receiver slot that is already a + // mix (bounds-checked). A physical entry only becomes a placeholder when it + // would need a NEW physical slot: an authored position the receiver's list + // already covers is applied positionally as before, even when that list sits + // above the printer's capacity (pre-existing state is never shrunk). + bool keep_place = false; + if (is_payload_mix) + keep_place = this->is_mixed_filament(size_t(entry.slot)) && swept_mix_slots.count(size_t(entry.slot)) == 0; + else + keep_place = size_t(entry.slot) < this->filament_presets.size() || + size_t(entry.slot) < physical_capacity; + if (keep_place) { + ++entry_it; + continue; + } + const std::string material_label = !entry.filament_id.empty() ? entry.filament_id : + !entry.publish_type_value.empty() ? entry.publish_type_value : + entry.filament_type; + if (std::max(size_t(entry.slot), next_free_slot) >= size_t(EnforcerBlockerType::ExtruderMax)) { + // No free virtual slot left: report instead of destroying a real filament. + // The local skipped_keys is published wholesale at the end of the pass; + // writing published_config->skipped_keys here would be clobbered by it. + skipped_keys.emplace_back("material:" + material_label + (is_payload_mix ? + " (mixed filament definition: filament slot limit reached)" : + " (filament slot limit reached)")); + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": published 3MF " << (is_payload_mix ? "mixed filament definition" : "material") + << " from slot " << entry.slot << " could not be placed: all " << next_free_slot + << " slots exhausted"; + entry_it = published_config->material_keys.erase(entry_it); + continue; + } + if (!is_payload_mix && physical_capacity < 2) { + // A single physical slot can never host a mixed-filament editor (the + // sidebar's mixed section needs two physical filaments to mix), so a + // placeholder would be invisible and unfixable: drop the entry and + // report it like any other unappliable input. + skipped_keys.emplace_back("material:" + material_label + " (printer supports only " + + std::to_string(physical_capacity) + " filament)"); + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": published 3MF material from slot " << entry.slot + << " dropped: printer supports only " << physical_capacity << " filament"; + entry_it = published_config->material_keys.erase(entry_it); + continue; + } + const int authored_slot = entry.slot; + const int dest_slot = is_payload_mix ? int(std::max(size_t(authored_slot), next_free_slot)) : int(next_free_slot); + next_free_slot = size_t(dest_slot) + 1; + if (is_payload_mix) { + if (dest_slot == authored_slot) + // Uncontended fresh tail slot: the definition is already readable there. + ++entry_it; + else { + entry.slot = dest_slot; + any_mixed_relocated = true; + mixed_moves.emplace_back(size_t(authored_slot), size_t(entry.slot)); + published_config->mixed_slot_relocations.insert_or_assign(authored_slot, entry.slot); + published_config->material_replacements.emplace_back("slot " + std::to_string(authored_slot) + " -> slot " + + std::to_string(entry.slot) + ": mixed filament"); + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": published 3MF relocated mixed filament slot " << authored_slot + << " -> " << entry.slot; + ++entry_it; + } + } else { + // Surplus published material beyond the printer's capacity: become an + // empty mixed-filament placeholder at the next free tail slot. The flag + // routes the entry to the placeholder finalize below; the slot's + // definition stays empty until the user assigns components. + entry.mixed_placeholder = true; + any_mixed_relocated = true; + if (dest_slot != authored_slot) { + entry.slot = dest_slot; + mixed_moves.emplace_back(size_t(authored_slot), size_t(dest_slot)); + published_config->mixed_slot_relocations.insert_or_assign(authored_slot, dest_slot); + } + published_config->material_replacements.emplace_back( + (dest_slot != authored_slot ? + "slot " + std::to_string(authored_slot) + " -> slot " + std::to_string(dest_slot) : + "slot " + std::to_string(dest_slot)) + + ": unassigned mixed filament (printer supports only " + std::to_string(physical_capacity) + " filaments)"); + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": published 3MF material from slot " << authored_slot + << " placed as an unassigned mixed filament at slot " << dest_slot + << " (printer supports only " << physical_capacity << " filaments)"; + ++entry_it; + } + } + if (!mixed_moves.empty()) + for (const std::string& mixed_key : mixed_definitions) + apply_mixed_config_relocations(config, mixed_key, mixed_moves); + if (has_published_entries) { + // Defensive cap: growth never exceeds the file's own filament count. The + // receiver's current slot count is a floor: neither the preset list nor the + // project vectors are ever shrunk, even when the file carries fewer filaments + // than the receiver has slots. Relocated mixed entries and capacity + // placeholders legitimately land past the file's own slot count (virtual + // slots consume no nozzle or tray), so their append destinations lift the + // ceiling explicitly. + const size_t target_slots = std::max({this->filament_presets.size(), std::min(grow_target, num_filaments), + any_mixed_relocated ? next_free_slot : size_t(0)}); + // Slots carrying published content, steering the initial preset selection of + // newly grown slots. + std::set published_slots; + for (const PublishedMaterialEntry& entry : published_config->material_keys) + if (entry.slot >= 0) + published_slots.insert(entry.slot); + // Grown slots the receiver creates that carry no published content of their own + // (e.g. an unpublished mixed slot left as a gap by a published mix's authored + // position) at or beyond the receiver's physical capacity. They must not become + // physical filaments (that would overflow the nozzle count): finalized as empty + // mixed placeholders below, like the surplus-material placeholders. + std::set virtual_gap_slots; + // Exact-name resolution of each published slot's preset through the collection's + // own name machinery: find_preset2 follows renamed_from (vendor profile renames) + // and canonical bundle names, and auto-matches removed vendor-generic profiles + // to the Orca Filament Library. First entry per slot wins (mirrors the loops' + // break-on-first behavior); an empty value means "no resolution" and the string + // tiers (raw name / trimmed bare name / alias) below take over. + std::map exact_name_by_slot; + for (const PublishedMaterialEntry& entry : published_config->material_keys) + if (entry.slot >= 0 && !entry.preset_name.empty() && exact_name_by_slot.count(entry.slot) == 0) + if (const Preset* resolved = this->filaments.find_preset2(entry.preset_name, true)) + exact_name_by_slot.emplace(entry.slot, resolved->name); + auto resolved_name_for = [&exact_name_by_slot](int slot) -> std::string { + const auto it = exact_name_by_slot.find(slot); + return it == exact_name_by_slot.end() ? std::string() : it->second; + }; + // Mirror first_visible_idx()'s start index so suppressed default presets are + // never picked as a slot material. + const size_t first_candidate = this->filaments.is_default_suppressed() ? this->filaments.num_default_presets() : 0; + // Candidate preference for a published entry: exact preset name (raw author and + // collection-resolved), then trimmed bare form / alias, then exact setting_id + // (variant-level), then exact filament_id, then vendor+type, then type only. + auto candidate_score = [](const Preset& candidate, const PublishedMaterialEntry& entry, + const std::string& resolved_name) -> int { + // Exact preset name: raw and collection-resolved forms both outrank the + // fuzzy bare/alias tier by tier value, so a receiver preset literally named + // "Generic PLA" (tier 4) can never beat the author's exact + // "Generic PLA @Vendor" (tier 5). Within one tier the strict ">" + // comparison keeps the first candidate in collection order. + if (!entry.preset_name.empty() && candidate.name == entry.preset_name) + return 5; + if (!resolved_name.empty() && candidate.name == resolved_name) + return 5; + if (!entry.preset_name.empty()) { + std::string bare_name = entry.preset_name.substr(0, entry.preset_name.find('@')); + boost::trim_right(bare_name); + if (!bare_name.empty() && + (candidate.alias == entry.preset_name || candidate.name == bare_name || candidate.alias == bare_name)) + return 4; + } + if (!entry.setting_id.empty() && candidate.setting_id == entry.setting_id) + return 3; + const ConfigOptionStrings* types = candidate.config.opt("filament_type"); + const ConfigOptionStrings* vendors = candidate.config.opt("filament_vendor"); + const std::string type = (types != nullptr && !types->values.empty()) ? types->get_at(0) : std::string(); + const std::string vendor = (vendors != nullptr && !vendors->values.empty()) ? vendors->get_at(0) : std::string(); + if (!entry.filament_id.empty() && candidate.filament_id == entry.filament_id) + return 2; + // Tier 0/1 family gate: the explicit type requirement when published as + // such, otherwise the entry's own material family - so identity scoring + // also applies to entries published without a checked Type row. + const std::string required_type = !entry.publish_type_value.empty() ? entry.publish_type_value : + normalize_filament_type(entry.filament_type); + if (!required_type.empty() && normalize_filament_type(type) == required_type) { + if (!entry.filament_vendor.empty() && vendor == entry.filament_vendor) + return 1; + return 0; + } + return -1; + }; + while (this->filament_presets.size() < target_slots) { + const size_t new_slot_idx = this->filament_presets.size(); + std::string initial_preset; + if (new_slot_idx >= physical_capacity && published_slots.count(static_cast(new_slot_idx)) == 0) + // An unpublished grown slot past the printer's physical capacity cannot + // host a real filament: finalize it as a virtual placeholder below. + virtual_gap_slots.insert(static_cast(new_slot_idx)); + if (published_slots.count(static_cast(new_slot_idx)) != 0) { + // Grow the slot the way the sidebar "add filament" does: seed it with + // the receiver's last preset. + if (!this->filament_presets.empty()) + initial_preset = this->filament_presets.back(); + } + if (initial_preset.empty()) + // Unpublished filler slot, or every visible preset is already used: + // repeat the receiver's last preset ("Add one filament" behaviour). + initial_preset = this->filament_presets.empty() ? this->filaments.first_visible().name : + this->filament_presets.back(); + this->filament_presets.emplace_back(initial_preset); + } + // Published slots that alias another slot (multi-extruder with one filament) + // get re-pointed at distinct presets: the overlay writes onto the slot's + // effective preset in place, so a shared preset would leak one slot's published + // values into every aliased slot. Slot 0 (the receiver's own material) is never + // re-assigned; when no unused candidate exists the aliasing stays (unavoidable). + auto referenced_elsewhere = [&](const std::string& preset_name, size_t except_slot) { + for (size_t s = 0; s < this->filament_presets.size(); ++s) + if (s != except_slot && this->filament_presets[s] == preset_name) + return true; + return false; + }; + for (size_t slot = 1; slot < this->filament_presets.size(); ++slot) { + if (published_slots.count(static_cast(slot)) == 0 || !referenced_elsewhere(this->filament_presets[slot], slot)) + continue; + // A slot whose published entry writes nothing into a shared preset in place + // keeps its own preset: de-aliasing would needlessly swap the material. Only + // keys routed to the slot's preset can leak across an aliased slot (colour is + // slot-scoped via project_config; a type requirement is handled by the + // type-gate below; full detaches separately). Mixed-definition keys go to the + // per-slot project arrays, never the preset. + bool writes_preset_keys = false; + for (const PublishedMaterialEntry& entry : published_config->material_keys) { + if (entry.slot != static_cast(slot)) + continue; + for (const std::string& key : entry.keys) + if (mixed_definitions.count(publish_base_key(key)) == 0) { + writes_preset_keys = true; + break; + } + if (writes_preset_keys) + break; + } + if (!writes_preset_keys) + continue; + // Prefer the best distinct candidate for the slot's published material + // (exact id, then vendor+type, then type only), compatible presets first... + std::string replacement; + int best_score = -1; + for (const PublishedMaterialEntry& entry : published_config->material_keys) { + // Scored for every entry with an identity, not only when a Type + // requirement was checked (same as the growth seeding above). + if (entry.slot != static_cast(slot)) + continue; + const std::string resolved_name = resolved_name_for(entry.slot); + auto scan = [&](bool compatible_only) -> std::pair { + int best_score = -1; + std::string best_name; + for (size_t i = first_candidate; i < this->filaments.size(); ++i) { + const Preset& candidate = this->filaments.preset(i); + if (compatible_only && !candidate.is_compatible) + continue; + const int score = candidate_score(candidate, entry, resolved_name); + // Exact identity tiers (name / setting_id) may use a hidden preset; + // the referenced check stays: re-pointing exists to de-alias. + if (score < 3 && !candidate.is_visible) + continue; + if (referenced_elsewhere(candidate.name, size_t(-1))) + continue; + if (score > best_score) { + best_score = score; + best_name = candidate.name; + } + } + return {best_score, best_name}; + }; + const auto [compat_score, compat_name] = scan(true); + const auto [any_score, any_name] = scan(false); + if (compat_score >= 0) { + best_score = compat_score; + replacement = compat_name; + } else if (any_score >= 0) { + best_score = any_score; + replacement = any_name; + } + if (best_score >= 0) + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": published 3MF re-pointed slot " << slot << " to " << replacement + << " (score " << best_score << ", preset_name \"" << entry.preset_name << "\", type \"" + << entry.publish_type_value << "\")"; + break; + } + // ...otherwise any distinct visible preset not referenced by another slot, + // preferring the published material's own family when it is known. + if (replacement.empty()) { + std::string slot_family; + for (const PublishedMaterialEntry& entry : published_config->material_keys) + if (entry.slot == static_cast(slot)) { + slot_family = !entry.publish_type_value.empty() ? entry.publish_type_value : + normalize_filament_type(entry.filament_type); + break; + } + for (size_t i = first_candidate; i < this->filaments.size(); ++i) { + const Preset& candidate = this->filaments.preset(i); + if (!candidate.is_visible || referenced_elsewhere(candidate.name, size_t(-1))) + continue; + if (!slot_family.empty()) { + const ConfigOptionStrings* types = candidate.config.opt("filament_type"); + const std::string cand_type = (types != nullptr && !types->values.empty()) ? types->get_at(0) : + std::string(); + if (normalize_filament_type(cand_type) != slot_family) + continue; + } + replacement = candidate.name; + break; + } + } + if (replacement.empty()) + continue; // every visible preset is referenced: aliasing is unavoidable + const std::string aliased_name = this->filament_presets[slot]; + this->filament_presets[slot] = replacement; + material_applied = true; + // The re-point used to be silent; surface it like the other slot changes. + published_config->material_replacements.emplace_back("slot " + std::to_string(slot) + ": " + aliased_name + " -> " + + replacement); + } + // Grow the per-slot colour/type/map project vectors to the new slot count and + // seed the new entries so the slots render with colours instead of blank chips + // (mirrors set_num_filaments; existing values are left untouched). + ConfigOptionStrings* proj_colour = this->project_config.opt("filament_colour"); + ConfigOptionStrings* proj_multi_colour = this->project_config.opt("filament_multi_colour"); + ConfigOptionStrings* proj_colour_type = this->project_config.opt("filament_colour_type"); + ConfigOptionInts* proj_map = this->project_config.opt("filament_map"); + ConfigOptionInts* proj_nozzle_map = this->project_config.opt("filament_nozzle_map"); + ConfigOptionInts* proj_volume_map = this->project_config.opt("filament_volume_map"); + const size_t old_colour_count = (proj_colour != nullptr) ? proj_colour->values.size() : 0; + // Grow-only: an already-larger project vector is left untouched (the receiver's + // slot count never shrinks below its own setup). + if (proj_colour && proj_colour->values.size() < target_slots) + proj_colour->resize(target_slots); + if (proj_multi_colour && proj_multi_colour->values.size() < target_slots) + proj_multi_colour->values.resize(target_slots); + if (proj_colour_type && proj_colour_type->values.size() < target_slots) + proj_colour_type->values.resize(target_slots); + if (proj_map && proj_map->values.size() < target_slots) + proj_map->values.resize(target_slots, 1); + if (proj_nozzle_map && proj_nozzle_map->values.size() < target_slots) + proj_nozzle_map->values.resize(target_slots, 0); + if (proj_volume_map && proj_volume_map->values.size() < target_slots) + proj_volume_map->values.resize(target_slots, static_cast(NozzleVolumeType::nvtStandard)); + // The mixed-color project arrays are parallel per-slot like filament_colour; + // grow them in lockstep so a published mix slot has room for its definition. + // Defaults mirror set_num_filaments (false / empty string). + if (auto* opt = this->project_config.opt("filament_is_mixed")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, false); + if (auto* opt = this->project_config.opt("filament_mixed_components")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, std::string{}); + if (auto* opt = this->project_config.opt("filament_mixed_sublayer_ratios")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, std::string{}); + if (auto* opt = this->project_config.opt("filament_mixed_gradient")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, false); + if (auto* opt = this->project_config.opt("filament_mixed_gradient_range")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, std::string{}); + if (auto* opt = this->project_config.opt("filament_mixed_gradient_curve")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, std::string{}); + if (auto* opt = this->project_config.opt("filament_mixed_gradient_per_part")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, false); + if (this->ams_multi_color_filment.size() < target_slots) + this->ams_multi_color_filment.resize(target_slots); + for (size_t slot = old_colour_count; slot < target_slots; ++slot) { + std::string seed; + for (const PublishedMaterialEntry& entry : published_config->material_keys) + if (entry.slot == static_cast(slot) && entry.publish_color && !entry.color.empty()) { + seed = entry.color; + break; + } + if (seed.empty()) { + if (const Preset* preset = this->filaments.find_preset(this->filament_presets[slot], false)) { + const ConfigOptionStrings* colours = preset->config.opt("filament_colour"); + if (colours != nullptr && !colours->values.empty()) + seed = colours->values.front(); + } + if (seed.empty()) { + // Fall back to the option's registered default instead of a + // duplicated literal; if the lookup fails the chip stays blank. + if (const ConfigOptionDef* colour_def = print_config_def.get("filament_colour")) + if (const auto* default_colours = dynamic_cast(colour_def->default_value.get())) + if (!default_colours->values.empty()) + seed = default_colours->values.front(); + } + } + if (proj_colour && slot < proj_colour->values.size()) + proj_colour->values[slot] = seed; + if (proj_multi_colour && slot < proj_multi_colour->values.size()) + proj_multi_colour->values[slot] = seed; + if (proj_colour_type && slot < proj_colour_type->values.size()) + proj_colour_type->values[slot] = "1"; // default colour type + } + // Rebuild the flush volumes for the grown slot count (as set_num_filaments does). + this->update_multi_material_filament_presets(); + + auto apply_slot_keys = [&](DynamicPrintConfig& preset_config, const std::vector& slot_keys, int author_slot, + const std::string& material_label) { + for (const std::string& key : slot_keys) { + const std::string base_key = publish_base_key(key); + if (structural_keys.count(base_key) != 0) + continue; + const ConfigOption* src_opt = config.option(base_key); + if (src_opt == nullptr || !src_opt->is_vector() || author_slot < 0 || + author_slot >= static_cast(static_cast(src_opt)->size())) { + skipped_keys.emplace_back("material:" + material_label + " (" + key + ")"); + continue; + } + ConfigOption* dst_opt = preset_config.option(base_key); + if (dst_opt == nullptr || !dst_opt->is_vector() || static_cast(dst_opt)->empty() || + dst_opt->type() != src_opt->type()) { + skipped_keys.emplace_back("material:" + material_label + " (" + key + ")"); + continue; + } + // Per-slot scalar copy: the receiver preset holds one value per key + // (vector of size 1), the file holds the per-slot vector. + static_cast(dst_opt)->set_at(src_opt, 0, author_slot); + material_applied = true; + } + }; + + // The slot's values are applied onto the slot's effective preset (the + // collection's edited layer when the slot references it, otherwise the stored + // preset in place); the per-entry type gate below may re-point the slot first. + // + // The edited layer only survives the load when the final re-select below stays + // off, i.e. slot 0's preset still matches the edited preset. Otherwise the + // re-select re-snapshots the edited layer from a stored preset and silently + // discards everything written there: route the overlay to the stored presets + // instead so the values survive. (Residual edge: a slot-0 type replacement + // re-pointing slot 0 mid-loop can still invalidate the layer after earlier + // slots wrote to it; that compound case is not chased.) + const bool edited_survives_load = this->filament_presets.empty() || + this->filament_presets.front() == this->filaments.get_edited_preset().name; + // Displaced receiver mixes (see the physical-first rebalance above): move their + // per-slot cells onto the grown tail slots and reset the vacated physical slots, + // so the incoming real filaments can claim them. Runs before mixed_final_slots is + // built, so the rebalanced layout is what the mix validation sees. + apply_receiver_mix_relocations(this->project_config, this->ams_multi_color_filment, receiver_mix_moves); + // Final layout for mix-definition validation: every slot that will hold a + // mixed definition once this load completes - the receiver's own virtual + // slots, each published mixed entry's final (possibly relocated) slot, and + // each capacity placeholder (they become mixes in the finalize below, before + // this loop's is_mixed_filament scan would see them). Mix components are + // 1-based slot numbers, so a component is valid only when the slot it names + // exists and does not itself hold a mixed filament. + std::set mixed_final_slots; + for (const PublishedMaterialEntry& mix_entry : published_config->material_keys) + if (mix_entry.slot >= 0 && (is_mixed_definition(mix_entry) || mix_entry.mixed_placeholder)) + mixed_final_slots.insert(mix_entry.slot); + for (size_t i = 0; i < this->filament_presets.size(); ++i) + if (this->is_mixed_filament(i)) + mixed_final_slots.insert(int(i)); + // Unpublished gap slots past the capacity are virtual too: count them in the + // final layout so a published mix whose components collide with one is caught. + for (int gap_slot : virtual_gap_slots) + mixed_final_slots.insert(gap_slot); + const size_t mixed_final_slot_count = this->filament_presets.size(); + // Finalize a slot as an empty mixed-filament placeholder: mark it virtual with + // an intentionally empty definition, and add it to the final-layout set so a + // later entry's components validate against the new state. Used by the capacity + // placeholders and by any mixed definition that has to be rejected after its + // slot was already grown and seeded - without it such a slot would keep the + // seeded preset and masquerade as a real filament. No definition is written: + // the placeholder carries no material of its own (the GUI flags the empty mix + // via check_mixed_filament_integrity and blocks slicing until components are + // assigned). The slot's colour was already seeded by the growth pass above. + auto finalize_mixed_placeholder = [&](size_t slot_idx) { + if (ConfigOptionBools* is_mixed_opt = this->project_config.opt("filament_is_mixed"); + is_mixed_opt != nullptr && slot_idx < is_mixed_opt->values.size()) + is_mixed_opt->values[slot_idx] = true; + mixed_final_slots.insert(int(slot_idx)); + material_applied = true; + }; + // Full Publish within-load dedup: identical Full materials (same setting_id + // + preset_name identity) share one created instance, so an author who + // pointed two slots at one preset yields one standalone copy here. + std::map published_full_dedup; + for (const PublishedMaterialEntry& entry : published_config->material_keys) { + if (entry.slot < 0 || size_t(entry.slot) >= this->filament_presets.size()) + continue; // out of range: nothing to do for this slot + const size_t slot = size_t(entry.slot); + + // Surplus published material beyond the printer's capacity: finalize the + // tail placeholder - mark the slot virtual with an intentionally empty + // definition. The GUI flags the empty mix (check_mixed_filament_integrity) + // and blocks slicing until the user assigns components from their own + // filaments. The entry's keys are deliberately not applied and no preset + // is detached: the placeholder carries no material of its own. The colour + // was already seeded into the project arrays by the growth pass above. + if (entry.mixed_placeholder) { + finalize_mixed_placeholder(slot); + continue; + } + + // Resolve the stored preset itself (real=true), never the edited snapshot: + // find_preset would return &m_edited_preset for the selected slot. The + // overlay target below decides between the edited layer and this preset. + Preset* recv = this->filaments.find_preset(this->filament_presets[slot], false, true); + if (recv == nullptr) { + // Defensive: the slot exists (grown and seeded above) but its preset + // could not be resolved. A mixed definition left unapplied on such a + // slot would keep the seeded preset and look like a real filament: + // finalize it as an empty placeholder instead, like a rejected + // definition below. + if (is_mixed_definition(entry) && !this->is_mixed_filament(slot)) { + finalize_mixed_placeholder(slot); + published_config->material_replacements.emplace_back("slot " + std::to_string(slot) + + ": mixed filament definition could not be imported"); + } + continue; + } + + const std::string material_label = entry.filament_id.empty() ? + (entry.publish_type_value.empty() ? entry.filament_type : + entry.publish_type_value) : + entry.filament_id; + + // Import-side validation of a mixed-filament definition: the publish + // dialog cannot produce a definition whose components reference slots + // that do not exist or hold other mixed filaments, so a broken one here + // means the payload itself is broken (hand-crafted or corrupt file). + // Report it through the same channel as every other rejected input and + // skip the entry, instead of shipping a mix the GUI integrity check + // (check_mixed_filament_integrity) would only flag later. A payload + // that omits the mixed arrays entirely is not an error here: the key + // routing below reports those per key as usual. + if (is_mixed_definition(entry)) { + std::string mix_error; + if (const ConfigOptionStrings* comp_opt = config.opt("filament_mixed_components"); + comp_opt != nullptr && entry.slot < static_cast(comp_opt->values.size())) { + const std::vector comps = parse_mixed_components(comp_opt->values[entry.slot]); + if (comps.size() < 2) + // An empty definition cell counts as broken too: applying it + // would ship a mix the sidebar would only flag later. + mix_error = "needs at least two components"; + else + for (unsigned int comp : comps) + if (comp < 1 || size_t(comp) > mixed_final_slot_count || + mixed_final_slots.count(int(comp) - 1) != 0) { + mix_error = "components reference missing slots"; + break; + } + } + if (!mix_error.empty()) { + skipped_keys.emplace_back("material:" + material_label + " (mixed filament definition: " + mix_error + ")"); + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": published 3MF mixed filament from slot " << entry.slot + << " rejected: " << mix_error; + // Degradation, not silent corruption: the slot was already grown and + // seeded by the pass above, so skipping the definition alone would + // leave it holding the seeded preset and looking like a real + // filament that carries the mix identity but no mix. + // - a slot that was NOT already a receiver mix is finalized as an + // empty mixed placeholder (consistent with the capacity + // placeholders; the user assigns components there); + // - a like-for-like override of the receiver's own mix leaves that + // valid definition untouched: nothing was applied, so the + // receiver's cells are intact and only the author's definition + // is reported as skipped above. + if (!this->is_mixed_filament(slot)) { + finalize_mixed_placeholder(slot); + published_config->material_replacements.emplace_back("slot " + std::to_string(slot) + + ": mixed filament definition could not be imported (" + + mix_error + ")"); + } + continue; + } + } + + // Full Publish: always create a standalone detached copy (even on exact + // identity match) as a "Preset Inside Project" (project-embedded: lives + // in this project only, never written to the library), universally + // compatible, named after the author's preset with its variant tail + // stripped ("(Published)" uniquification on collision). No existing + // preset is ever mutated. + if (entry.full) { + std::string dedup_key = entry.setting_id + std::string("\x1f") + entry.preset_name; + // Identity-less hand-crafted files (empty setting_id+name) must not + // collide: fall back to slot-scoped key so each slot gets its own copy + // unless the dedup above is meaningful. + if (dedup_key == std::string("\x1f")) + dedup_key = dedup_key + std::to_string(slot); + else if (!entry.filament_id.empty()) + dedup_key += std::string("\x1f") + entry.filament_id; + std::string new_name; + const auto dedup_it = published_full_dedup.find(dedup_key); + if (dedup_it != published_full_dedup.end()) { + new_name = dedup_it->second; + } else { + // Baseline: clone the receiver slot's stored preset config (schema- + // complete across versions), then overlay the published full_keys. + DynamicPrintConfig new_cfg = recv != nullptr ? recv->config : this->filaments.default_preset().config; + for (const std::string& key : entry.full_keys) { + const std::string base_key = publish_base_key(key); + if (structural_keys.count(base_key) != 0) + continue; + const ConfigOption* src_opt = config.option(base_key); + if (src_opt == nullptr || !src_opt->is_vector() || entry.slot < 0 || + entry.slot >= static_cast(static_cast(src_opt)->size())) + continue; + ConfigOption* dst_opt = new_cfg.option(base_key); + if (dst_opt == nullptr || !dst_opt->is_vector() || + static_cast(dst_opt)->empty() || dst_opt->type() != src_opt->type()) + continue; + static_cast(dst_opt)->set_at(src_opt, 0, entry.slot); + } + // The published colour is authoritative even for Full (the payload's + // filament_colour plus the explicit publish_color field). + if (entry.publish_color && !entry.color.empty()) { + if (ConfigOptionStrings* col = new_cfg.opt("filament_colour", true)) { + if (col->values.empty()) + col->values.emplace_back(); + col->values[0] = entry.color; + } + } + make_publish_universal(new_cfg); + // Naming: stripped variant tail ("Generic PLA @System" -> "Generic + // PLA"), then identity fallbacks; collisions uniquify with + // "(Published)" / "(Published N)" inside add_detached_preset. + std::string base_name = entry.preset_name.empty() ? std::string() : + publish_material_base_name(entry.preset_name); + if (base_name.empty()) { + base_name = !entry.filament_id.empty() ? + entry.filament_id : + (!entry.publish_type_value.empty() ? entry.publish_type_value : entry.filament_type); + if (base_name.empty()) + base_name = "Published Filament"; + } + new_name = this->filaments.add_detached_preset(base_name, std::move(new_cfg), entry.filament_id); + published_full_dedup.emplace(dedup_key, new_name); + } + const std::string old_name = this->filament_presets[slot]; + this->filament_presets[slot] = new_name; + material_applied = true; + published_config->material_replacements.emplace_back("slot " + std::to_string(slot) + ": " + old_name + " -> " + + new_name); + // Colour is slot-scoped and project-visible: sync into project_config + // (the copy already baked it, this makes the chips render). + if (entry.publish_color && !entry.color.empty()) { + if (ConfigOptionStrings* proj_colour = this->project_config.opt("filament_colour")) { + if (slot < proj_colour->values.size()) + proj_colour->values[slot] = entry.color; + } + if (ConfigOptionStrings* proj_multi_colour = this->project_config.opt( + "filament_multi_colour")) { + if (slot < proj_multi_colour->values.size()) + proj_multi_colour->values[slot] = entry.color; + } + } else if (proj_colour && new_name != old_name) { + // Fall back to the copy's colour so the chip is never blank: try + // the newly created preset's filament_colour, then the option default. + std::string seed; + if (const Preset* created = this->filaments.find_preset(new_name, false, true)) { + if (const ConfigOptionStrings* cols = created->config.opt("filament_colour")) + if (!cols->values.empty()) + seed = cols->values.front(); + } + if (seed.empty()) { + if (const ConfigOptionDef* colour_def = print_config_def.get("filament_colour")) + if (const auto* defaults = dynamic_cast(colour_def->default_value.get())) + if (!defaults->values.empty()) + seed = defaults->values.front(); + } + if (!seed.empty()) { + if (proj_colour && slot < proj_colour->values.size()) + proj_colour->values[slot] = seed; + if (proj_multi_colour && slot < proj_multi_colour->values.size()) + proj_multi_colour->values[slot] = seed; + } + } + if (proj_colour_type && slot < proj_colour_type->values.size()) + proj_colour_type->values[slot] = "1"; + continue; + } + + bool apply_slot = true; + // The gate compares against the slot's effective material type: the edited + // layer when the slot references the collection's edited preset and that + // layer will survive the load (see edited_survives_load below), the stored + // preset otherwise. + const DynamicPrintConfig& effective_config = (edited_survives_load && + recv->name == this->filaments.get_edited_preset().name) ? + this->filaments.get_edited_preset().config : + recv->config; + if (entry.publish_type && !entry.publish_type_value.empty()) { + // Null-guard: a malformed receiver preset may lack filament_type. + const ConfigOptionStrings* recv_types = effective_config.opt("filament_type"); + const std::string recv_type = (recv_types != nullptr && !recv_types->values.empty()) ? recv_types->get_at(0) : + std::string(); + if (normalize_filament_type(recv_type) != entry.publish_type_value) { + // Type mismatch: replace the slot with the best matching preset, + // scored by the published identity (exact preset name, then exact + // setting_id, exact filament_id, then vendor+type, then type only). + // A preset no other slot references wins on equal scores; a shared + // exact-material preset is taken even though mutating it also + // affects the other slot. Compatible presets are searched first: an + // incompatible preset is only picked when no compatible candidate + // exists at all. + auto find_best = [&](bool compatible_only) -> std::pair { + auto scan = [&](bool unreferenced_only) -> std::pair { + int best_score = -1; + std::string best_name; + const std::string resolved_name = resolved_name_for(entry.slot); + for (size_t i = first_candidate; i < this->filaments.size(); ++i) { + const Preset& candidate = this->filaments.preset(i); + if (compatible_only && !candidate.is_compatible) + continue; + const int score = candidate_score(candidate, entry, resolved_name); + if (score <= best_score) + continue; + // Lower tiers (vendor+type, type only) need a visible preset; + // an exact identity match (name / setting_id) wins even when + // the preset is hidden in the library. + if (score < 3 && !candidate.is_visible) + continue; + if (unreferenced_only) { + bool used = false; + for (size_t s = 0; s < this->filament_presets.size(); ++s) + if (s != slot && this->filament_presets[s] == candidate.name) { + used = true; + break; + } + if (used) + continue; + } + best_score = score; + best_name = candidate.name; + } + return {best_score, best_name}; + }; + const auto [strict_score, strict_name] = scan(true); + const auto [relaxed_score, relaxed_name] = scan(false); + if (relaxed_score > strict_score) + return {relaxed_score, relaxed_name}; + return {strict_score, strict_name}; + }; + const auto [compat_score, compat_name] = find_best(true); + const auto [any_score, any_name] = find_best(false); + int score = any_score; + std::string replacement = any_name; + if (compat_score >= 0) { + score = compat_score; + replacement = compat_name; + } + if (!replacement.empty()) { + const std::string old_name = recv->name; + this->filament_presets[slot] = replacement; + recv = this->filaments.find_preset(replacement, false, true); + material_applied = true; + BOOST_LOG_TRIVIAL(info) + << __FUNCTION__ << ": published 3MF slot " << slot << " material " << old_name << " -> " << replacement + << " (score " << score << ", preset_name \"" << entry.preset_name << "\", filament_id \"" + << entry.filament_id << "\", setting_id \"" << entry.setting_id << "\", type \"" + << entry.publish_type_value << "\")"; + std::string replacement_line = "slot " + std::to_string(slot) + ": " + old_name + " -> " + replacement; + // A pick that is not the exact published material is a substitute; + // an entry without identity fields cannot be judged, so it stays plain. + if (score < 2 && (!entry.filament_id.empty() || !entry.filament_vendor.empty())) + replacement_line += " (substitute)"; + published_config->material_replacements.emplace_back(std::move(replacement_line)); + } else { + // Partial publish with no replacement: keep the receiver's + // material and report the slot's keys as skipped. (Full entries + // never reach this gate: they detach above.) + for (const std::string& key : entry.keys) + skipped_keys.emplace_back("material:" + material_label + " (" + key + ")"); + apply_slot = false; + // A mixed definition left unapplied here would keep the grown + // slot's seeded preset and masquerade as a real filament: + // finalize it as an empty placeholder like a rejected + // definition (a like-for-like override of the receiver's own + // mix is left untouched). + if (is_mixed_definition(entry) && !this->is_mixed_filament(slot)) { + finalize_mixed_placeholder(slot); + published_config->material_replacements.emplace_back("slot " + std::to_string(slot) + + ": mixed filament definition could not be imported"); + } + } + } + } + + // The values below are applied onto the slot's effective preset: the edited + // layer when the slot references the collection's edited preset and that + // layer will survive the load (visible as a modification and revertible, + // preserving the user's unsaved edits), otherwise the stored preset it ended + // up on (original or partial type replacement), in place. Full entries never + // get here - they detach above. + DynamicPrintConfig& write_config = (edited_survives_load && recv->name == this->filaments.get_edited_preset().name) ? + this->filaments.get_edited_preset().config : + recv->config; + + // Colour is slot-scoped and independent of the type gate; it is also synced + // into project_config for GUI rendering. + if (entry.publish_color && !entry.color.empty()) { + // A mixed-definition entry carries the mix's blended colour for the + // swatch only: never write it into the slot's (possibly shared) preset + // config, only into the project-level colour arrays. + const bool is_mixed_entry = is_mixed_definition(entry); + if (!is_mixed_entry && recv != nullptr) { + // Create the key when the target preset lacks it: the colour is a + // requirement, not an override. + if (ConfigOptionStrings* colour = write_config.opt("filament_colour", true)) { + if (colour->values.empty()) + colour->values.emplace_back(); + colour->values[0] = entry.color; + material_applied = true; + } + } + if (ConfigOptionStrings* proj_colour = this->project_config.opt("filament_colour")) { + if (slot < proj_colour->values.size()) + proj_colour->values[slot] = entry.color; + } + if (ConfigOptionStrings* proj_multi_colour = this->project_config.opt( + "filament_multi_colour")) { + if (slot < proj_multi_colour->values.size()) + proj_multi_colour->values[slot] = entry.color; + } + } + + if (apply_slot && recv != nullptr) { + // Mixed-color definition keys live in project_config (parallel per-slot + // arrays), not in a filament preset; route them there. Normal keys keep + // the per-slot preset path below. + const std::set& mixed_keys = publish_mixed_keys(); + std::vector preset_keys; + for (const std::string& key : entry.keys) { + const std::string base_key = publish_base_key(key); + if (mixed_keys.count(base_key) != 0) { + const ConfigOption* src_opt = config.option(base_key); + if (src_opt != nullptr && src_opt->is_vector() && entry.slot >= 0 && + entry.slot < static_cast(static_cast(src_opt)->size())) { + if (ConfigOption* dst_opt = this->project_config.option(base_key)) { + if (dst_opt->is_vector() && dst_opt->type() == src_opt->type() && + slot < static_cast(dst_opt)->size()) { + static_cast(dst_opt)->set_at(src_opt, slot, entry.slot); + material_applied = true; + continue; + } + } + } + // The slot (or its arrays) could not be written: report rather + // than drop the mix silently. + skipped_keys.emplace_back("material:" + material_label + " (" + key + ")"); + continue; + } + preset_keys.emplace_back(key); + } + apply_slot_keys(write_config, preset_keys, entry.slot, material_label); + } + } + // Unpublished gap slots past the printer's physical capacity: the receiver grew + // them only to reach a published definition, so they must not become physical + // filaments (that would overflow the nozzle count). Finalize each as an empty + // mixed placeholder, exactly like the surplus-material placeholders above. + for (int gap_slot : virtual_gap_slots) + if (!this->is_mixed_filament(size_t(gap_slot))) { + finalize_mixed_placeholder(size_t(gap_slot)); + published_config->material_replacements.emplace_back( + "slot " + std::to_string(gap_slot) + + ": unassigned mixed filament (printer supports only " + std::to_string(physical_capacity) + " filaments)"); + } + } + } + + for (const std::string& key : published_config->published_keys) { + if (applied_keys.count(key) != 0) + continue; + const std::string base_key = publish_base_key(key); + // Structural keys are silently ignored, never reported as skipped: a hand-crafted + // 3MF must not trigger the "could not be applied" warning for them. + if (structural_keys.count(base_key) != 0) + continue; + // Printer-class keys outside the publishable allowlist are contract-excluded too. + if (contract_excluded_keys.count(base_key) != 0) + continue; + skipped_keys.emplace_back(key); + } + published_config->skipped_keys = std::move(skipped_keys); + + // The overlay lands on the edited layer only when it survives the load (slot 0's preset + // still matches the edited preset; see edited_survives_load) and on the stored presets + // otherwise. The edited preset is a snapshot taken when the preset was last selected, so + // a slot-0 preset change (type replacement) still needs a re-select to surface; when + // slot 0 is unchanged the edited layer already carries the overlay, and re-selecting + // would discard both it and the user's unsaved edits. + if (material_applied && !this->filament_presets.empty() && + this->filament_presets.front() != this->filaments.get_edited_preset().name) + this->filaments.select_preset_by_name(this->filament_presets.front(), false); + } + + //BBS //const std::string &physical_printer = config.option("physical_printer_settings_id", true)->value; const std::string physical_printer; - if (this->printers.get_edited_preset().is_external || physical_printer.empty()) { - this->physical_printers.unselect_printer(); - } else { - // Activate the physical printer profile if possible. - PhysicalPrinter *pp = this->physical_printers.find_printer(physical_printer, true); - if (pp != nullptr && std::find(pp->preset_names.begin(), pp->preset_names.end(), this->printers.get_edited_preset().name) != pp->preset_names.end()) - this->physical_printers.select_printer(pp->name, this->printers.get_edited_preset().name); - else + if (!is_published) { + if (this->printers.get_edited_preset().is_external || physical_printer.empty()) { this->physical_printers.unselect_printer(); + } else { + // Activate the physical printer profile if possible. + PhysicalPrinter *pp = this->physical_printers.find_printer(physical_printer, true); + if (pp != nullptr && std::find(pp->preset_names.begin(), pp->preset_names.end(), this->printers.get_edited_preset().name) != pp->preset_names.end()) + this->physical_printers.select_printer(pp->name, this->printers.get_edited_preset().name); + else + this->physical_printers.unselect_printer(); + } } //BBS: add config related logs BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": finished"); diff --git a/src/libslic3r/PresetBundle.hpp b/src/libslic3r/PresetBundle.hpp index 07e5444906..353b6dc07d 100644 --- a/src/libslic3r/PresetBundle.hpp +++ b/src/libslic3r/PresetBundle.hpp @@ -4,9 +4,11 @@ #include "Preset.hpp" #include "PresetCacheFormat.hpp" #include "AppConfig.hpp" +#include "PublishSettings.hpp" #include "enum_bitmask.hpp" #include +#include #include #include #include @@ -168,6 +170,30 @@ struct PresetBundleMetadata } }; +// A "published" 3MF project: keeps the user's currently-selected presets and overlays only the +// author-selected published keys onto the edited presets. +struct PublishedConfig +{ + bool published = false; + std::vector published_keys; + // Per-slot published material keys, applied positionally (author slot N -> receiver slot N). + // Partial entries are gated by the author's optional type requirement and written onto the + // slot's stored preset in place; full entries instead detach (see PublishedMaterialEntry in + // PublishSettings.hpp). + std::vector material_keys; + // Keys that could not be applied (missing on the user's machine or vector size mismatch), + // filled in by load_config_file_config for notification purposes. + std::vector skipped_keys; + // Human-readable notices of the slot material replacements performed while loading a + // published project, for the load notification. + std::vector material_replacements; + // Mixed-filament entries that had to be moved off their authored slot on load (a real, + // physical filament occupied it): maps the author's zero-based slot number to its final + // zero-based slot. Consumers (e.g. model extruder/color-painting remapping) use this to + // keep geometry references pointing at the relocated definitions. + std::map mixed_slot_relocations; +}; + // Bundle of Print + Filament + Printer presets. class PresetBundle { @@ -464,8 +490,8 @@ public: // Load configuration that comes from a model file containing configuration, such as 3MF et al. // This method is called by the Plater. - void load_config_model(const std::string &name, DynamicPrintConfig config, Semver file_version = Semver()) - { this->load_config_file_config(name, true, std::move(config), file_version); } + void load_config_model(const std::string &name, DynamicPrintConfig config, Semver file_version = Semver(), PublishedConfig *published_config = nullptr) + { this->load_config_file_config(name, true, std::move(config), file_version, false, published_config); } // Load an external config file containing the print, filament and printer presets. // Instead of a config file, a G-code may be loaded containing the full set of parameters. @@ -646,7 +672,7 @@ private: // Load print, filament & printer presets from a config. If it is an external config, then the name is extracted from the external path. // and the external config is just referenced, not stored into user profile directory. // If it is not an external config, then the config will be stored into the user profile directory. - void load_config_file_config(const std::string &name_or_path, bool is_external, DynamicPrintConfig &&config, Semver file_version = Semver(), bool selected = false); + void load_config_file_config(const std::string &name_or_path, bool is_external, DynamicPrintConfig &&config, Semver file_version = Semver(), bool selected = false, PublishedConfig *published_config = nullptr); /*ConfigSubstitutions load_config_file_config_bundle( const std::string &path, const boost::property_tree::ptree &tree, ForwardCompatibilitySubstitutionRule compatibility_rule);*/ diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 1db476eede..4c27995ba0 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -5430,7 +5430,7 @@ void PrintConfigDef::init_fff_params() def->mode = comAdvanced; def->readonly = false; def->nullable = true; - def->set_default_value(new ConfigOptionFloatsNullable { {0.0} }); + def->set_default_value(new ConfigOptionFloatsNullable { 0.0 }); def = this->add("cooling_tube_retraction", coFloat); def->label = L("Cooling tube position"); diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index a228bb7436..e147356ea6 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -21,9 +21,11 @@ #include "TriangleMeshSlicer.hpp" #include "Utils.hpp" #include "Fill/FillAdaptive.hpp" +#include "Fill/Fill.hpp" #include "Fill/FillLightning.hpp" #include "Format/STL.hpp" #include "format.hpp" +#include "AABBTreeIndirect.hpp" #include "AABBTreeLines.hpp" #include @@ -672,6 +674,98 @@ void PrintObject::prepare_infill() } // for each region #endif /* SLIC3R_DEBUG_SLICE_PROCESSING */ + // Orca: precompute the object's 3D connected bodies for separated infills / per-model + // centering. Two islands belong to the same body when their slices overlap on adjacent + // layers; islands that only overlap in top-down projection but never touch (e.g. interleaved + // chain links) stay separate, matching "split to objects". Each layer island then records + // the full bounding box of its body, so its infill is centered on that body as if it were + // sliced alone. Compute this before bridges so anchors and extrusion share the same origin. + bool needs_separated_components = false; + for (size_t i = 0; i < this->num_printing_regions(); ++ i) { + const PrintRegionConfig &rc = this->printing_region(i).config(); + if (rc.separated_infills || rc.center_of_surface_pattern == CenterOfSurfacePattern::Each_Model) { + needs_separated_components = true; + break; + } + } + // Orca: Fast path: the feature only changes anything when the object is made of more than one + // connected body. Detect that cheaply the same way as "Split to objects" — more than one + // model part, or a single part whose mesh is splittable (is_splittable() is cached). A single + // body already shares the object center, i.e. the default, so skip the connectivity pass. + if (needs_separated_components) { + int parts = 0; + const ModelVolume *first_part = nullptr; + for (const ModelVolume *v : this->model_object()->volumes) + if (v->is_model_part()) { ++ parts; first_part = v; } + if (parts <= 1 && ! (first_part != nullptr && first_part->is_splittable())) + needs_separated_components = false; + } + for (Layer *layer : m_layers) + layer->lslices_separated_component_bboxes.clear(); + if (needs_separated_components) { + const size_t nl = m_layers.size(); + std::vector offset(nl + 1, 0); // Orca: flat index of the first island of each layer + for (size_t i = 0; i < nl; ++ i) + offset[i + 1] = offset[i] + m_layers[i]->lslices.size(); + const size_t nreg = offset[nl]; + // Orca: Union-find over every (layer, island). + std::vector parent(nreg); + for (size_t i = 0; i < nreg; ++ i) parent[i] = i; + auto find = [&parent](size_t x) { + while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } + return x; + }; + auto unite = [&](size_t a, size_t b) { a = find(a); b = find(b); if (a != b) parent[a] = b; }; + // Orca: Index the smaller of two consecutive layers instead of scanning every + // pair of islands. The tree prunes distant boxes on fragmented models; exact + // polygon intersections still decide connectivity for the remaining candidates. + for (size_t i = 0; i + 1 < nl; ++ i) { + m_print->throw_if_canceled(); + size_t layer_a = i, layer_b = i + 1; + if (m_layers[layer_a]->lslices.size() < m_layers[layer_b]->lslices.size()) + std::swap(layer_a, layer_b); + const Layer *la = m_layers[layer_a], *lb = m_layers[layer_b]; + if (lb->lslices.empty()) + continue; + + using IslandTree = AABBTreeIndirect::Tree<2, coord_t>; + std::vector bboxes; + bboxes.reserve(lb->lslices.size()); + for (size_t b = 0; b < lb->lslices.size(); ++ b) + bboxes.emplace_back(b, lb->lslices_bboxes[b]); + IslandTree tree; + tree.build_modify_input(bboxes); + for (size_t a = 0; a < la->lslices.size(); ++ a) { + const IslandTree::BoundingBox query(la->lslices_bboxes[a].min, la->lslices_bboxes[a].max); + AABBTreeIndirect::traverse(tree, + [&query](const IslandTree::Node &node) { return node.bbox.intersects(query); }, + [&](const IslandTree::Node &node) { + const size_t b = node.idx; + // Orca: Tree boxes include an epsilon, so retain the original box + // filter. Already-connected islands cannot change the partition + // and need no further polygon intersection. + if (la->lslices_bboxes[a].overlap(lb->lslices_bboxes[b]) && + find(offset[layer_a] + a) != find(offset[layer_b] + b) && + ! intersection_ex(la->lslices[a], lb->lslices[b]).empty()) + unite(offset[layer_a] + a, offset[layer_b] + b); + return true; + }); + } + } + // Orca: Full bounding box of each body, indexed by its union-find root. + std::vector body_bbox(nreg); + for (size_t i = 0; i < nl; ++ i) + for (size_t a = 0; a < m_layers[i]->lslices.size(); ++ a) + body_bbox[find(offset[i] + a)].merge(m_layers[i]->lslices_bboxes[a]); + // Orca: Store the body bbox for every island. + for (size_t i = 0; i < nl; ++ i) { + Layer *layer = m_layers[i]; + layer->lslices_separated_component_bboxes.resize(layer->lslices.size()); + for (size_t a = 0; a < layer->lslices.size(); ++ a) + layer->lslices_separated_component_bboxes[a] = body_bbox[find(offset[i] + a)]; + } + } + // the following step needs to be done before combination because it may need // to remove only half of the combined infill this->bridge_over_infill(); @@ -706,71 +800,6 @@ void PrintObject::infill() if (this->set_started(posInfill)) { m_print->set_status(35, L("Generating infill toolpath")); - // Orca: precompute the object's 3D connected bodies for separated infills / per-model - // centering. Two islands belong to the same body when their slices overlap on adjacent - // layers; islands that only overlap in top-down projection but never touch (e.g. interleaved - // chain links) stay separate, matching "split to objects". Each layer island then records - // the full bounding box of its body, so its infill is centered on that body as if it were - // sliced alone. Done once here, before the parallel fill, and only when a region needs it. - bool needs_separated_components = false; - for (size_t i = 0; i < this->num_printing_regions(); ++ i) { - const PrintRegionConfig &rc = this->printing_region(i).config(); - if (rc.separated_infills || rc.center_of_surface_pattern == CenterOfSurfacePattern::Each_Model) { - needs_separated_components = true; - break; - } - } - // Fast path: the feature only changes anything when the object is made of more than one - // connected body. Detect that cheaply the same way as "Split to objects" — more than one - // model part, or a single part whose mesh is splittable (is_splittable() is cached). A single - // body already shares the object center, i.e. the default, so skip the connectivity pass. - if (needs_separated_components) { - int parts = 0; - const ModelVolume *first_part = nullptr; - for (const ModelVolume *v : this->model_object()->volumes) - if (v->is_model_part()) { ++ parts; first_part = v; } - if (parts <= 1 && ! (first_part != nullptr && first_part->is_splittable())) - needs_separated_components = false; - } - for (Layer *layer : m_layers) - layer->lslices_separated_component_bboxes.clear(); - if (needs_separated_components) { - const size_t nl = m_layers.size(); - std::vector offset(nl + 1, 0); // flat index of the first island of each layer - for (size_t i = 0; i < nl; ++ i) - offset[i + 1] = offset[i] + m_layers[i]->lslices.size(); - const size_t nreg = offset[nl]; - // Union-find over every (layer, island). - std::vector parent(nreg); - for (size_t i = 0; i < nreg; ++ i) parent[i] = i; - auto find = [&parent](size_t x) { - while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } - return x; - }; - auto unite = [&](size_t a, size_t b) { a = find(a); b = find(b); if (a != b) parent[a] = b; }; - // Join islands that overlap between two consecutive layers. - for (size_t i = 0; i + 1 < nl; ++ i) { - const Layer *la = m_layers[i], *lb = m_layers[i + 1]; - for (size_t a = 0; a < la->lslices.size(); ++ a) - for (size_t b = 0; b < lb->lslices.size(); ++ b) - if (la->lslices_bboxes[a].overlap(lb->lslices_bboxes[b]) && - ! intersection_ex(la->lslices[a], lb->lslices[b]).empty()) - unite(offset[i] + a, offset[i + 1] + b); - } - // Full bounding box of each body, indexed by its union-find root. - std::vector body_bbox(nreg); - for (size_t i = 0; i < nl; ++ i) - for (size_t a = 0; a < m_layers[i]->lslices.size(); ++ a) - body_bbox[find(offset[i] + a)].merge(m_layers[i]->lslices_bboxes[a]); - // Store the body bbox for every island. - for (size_t i = 0; i < nl; ++ i) { - Layer *layer = m_layers[i]; - layer->lslices_separated_component_bboxes.resize(layer->lslices.size()); - for (size_t a = 0; a < layer->lslices.size(); ++ a) - layer->lslices_separated_component_bboxes[a] = body_bbox[find(offset[i] + a)]; - } - } - const auto& adaptive_fill_octree = this->m_adaptive_fill_octrees.first; const auto& support_fill_octree = this->m_adaptive_fill_octrees.second; @@ -1401,8 +1430,6 @@ bool PrintObject::invalidate_state_by_config_options( || opt_key == "infill_anchor_max" || opt_key == "top_surface_line_width" || opt_key == "bottom_surface_density" - || opt_key == "center_of_surface_pattern" - || opt_key == "separated_infills" || opt_key == "initial_layer_line_width" || opt_key == "small_area_infill_flow_compensation" || opt_key == "lateral_lattice_angle_1" @@ -1410,6 +1437,10 @@ bool PrintObject::invalidate_state_by_config_options( || opt_key == "infill_overhang_angle") { steps.emplace_back(posInfill); } else if (opt_key == "sparse_infill_pattern" + // Orca: Body centering now also determines bridge anchors during preparation. + // Invalidating preparation also invalidates infill, including top/bottom surfaces. + || opt_key == "center_of_surface_pattern" + || opt_key == "separated_infills" || opt_key == "sparse_infill_smooth_factor" || opt_key == "symmetric_infill_y_axis" || opt_key == "infill_shift_step" @@ -3009,21 +3040,12 @@ void PrintObject::bridge_over_infill() return diff(layers_sparse_infill, not_sparse_infill); }; - // LAMBDA do determine optimal bridging angle - auto determine_bridging_angle = [](const Polygons &bridged_area, const Lines &anchors, InfillPattern dominant_pattern, double infill_direction) { + // Orca: Derive the fallback bridge direction from the supplied anchor geometry. + // Pattern-specific angle selection belongs at the call site, where the supporting + // layer and region are known; this helper must not override it with a base config angle. + auto determine_bridging_angle = [](const Polygons &bridged_area, const Lines &anchors) { AABBTreeLines::LinesDistancer lines_tree(anchors); - // Orca: since 3D Honeycomb was "fixed" by forcing coordf_t layerHeight = scale_(1.0), this is no longer needed. - // CorssHatch also does not need fixed angle. - // - // Check it the infill that require a fixed infill angle. - //switch (dominant_pattern) { - //case ip3DHoneycomb: - //case ipCrossHatch: - // return (infill_direction + 45.0) * 2.0 * M_PI / 360.; - //default: break; - //} - std::map counted_directions; for (const Polygon &p : bridged_area) { double acc_distance = 0; @@ -3089,18 +3111,15 @@ void PrintObject::bridge_over_infill() if (bridging_angle == 0) { bridging_angle = 0.001; } - switch (dominant_pattern) { - case ipHilbertCurve: bridging_angle += 0.25 * PI; break; - case ipOctagramSpiral: bridging_angle += (1.0 / 16.0) * PI; break; - default: break; - } return bridging_angle; }; - // LAMBDA that will fill given polygons with lines, exapand the lines to the nearest anchor, and reconstruct polygons from the newly - // generated lines - auto construct_anchored_polygon = [](Polygons bridged_area, Lines anchors, const Flow &bridging_flow, double bridging_angle) { + // Orca: Extend scan sections to the nearest anchors and reconstruct the bridge area. + // scan_spacing controls boundary sampling independently of the extrusion spacing; + // anchoring overlap and smoothing thresholds still use the physical bridging flow. + auto construct_anchored_polygon = [](Polygons bridged_area, Lines anchors, const Flow &bridging_flow, double bridging_angle, + coord_t scan_spacing, bool restore_anchors = false) { auto lines_rotate = [](Lines &lines, double cos_angle, double sin_angle) { for (Line &l : lines) { double ax = double(l.a.x()); @@ -3127,12 +3146,12 @@ void PrintObject::bridge_over_infill() BoundingBox bb_x = get_extents(bridged_area); BoundingBox bb_y = get_extents(anchors); - const size_t n_vlines = (bb_x.max.x() - bb_x.min.x() + bridging_flow.scaled_spacing() - 1) / bridging_flow.scaled_spacing(); + const size_t n_vlines = (bb_x.max.x() - bb_x.min.x() + scan_spacing - 1) / scan_spacing; std::vector vertical_lines(n_vlines); for (size_t i = 0; i < n_vlines; i++) { - // Orca: Make sure the line is placed in the middle of the extrusion - // coord_t x = bb_x.min.x() + i * bridging_flow.scaled_spacing(); - coord_t x = bb_x.min.x() + (i + 0.5) * bridging_flow.scaled_spacing(); + // Orca: Sample the center of each reconstructed strip. Its edges lie + // half a scan step away, even when the sampling is finer than extrusion. + coord_t x = bb_x.min.x() + (i + 0.5) * scan_spacing; coord_t y_min = bb_y.min.y() - bridging_flow.scaled_spacing(); coord_t y_max = bb_y.max.y() + bridging_flow.scaled_spacing(); vertical_lines[i].a = Point{x, y_min}; @@ -3155,7 +3174,11 @@ void PrintObject::bridge_over_infill() auto anchors_intersections = anchors_and_walls_tree.intersections_with_line(vertical_lines[i]); for (Line §ion : polygon_sections[i]) { - auto maybe_below_anchor = std::upper_bound(anchors_intersections.rbegin(), anchors_intersections.rend(), section.a, + // Orca: A repaired boundary may already overlap its anchor by one flow width. + // Include that overlap in the search so restoring rounded corners does not + // extend every already anchored section into the next sparse infill cell. + const coord_t overlap = restore_anchors ? bridging_flow.scaled_width() + SCALED_EPSILON : 0; + auto maybe_below_anchor = std::upper_bound(anchors_intersections.rbegin(), anchors_intersections.rend(), section.a + Point{0, overlap}, [](const Point &a, const std::pair &b) { return a.y() > b.first.y(); }); @@ -3164,7 +3187,7 @@ void PrintObject::bridge_over_infill() section.a.y() -= bridging_flow.scaled_width() * (0.5 + 0.5); } - auto maybe_upper_anchor = std::upper_bound(anchors_intersections.begin(), anchors_intersections.end(), section.b, + auto maybe_upper_anchor = std::upper_bound(anchors_intersections.begin(), anchors_intersections.end(), section.b - Point{0, overlap}, [](const Point &a, const std::pair &b) { return a.y() < b.first.y(); }); @@ -3194,7 +3217,9 @@ void PrintObject::bridge_over_infill() }); } - // reconstruct polygon from polygon sections + // Orca: Reconstruct the polygon from scan sections. At discontinuities and + // strip starts/ends, use half the scan step for the X offsets; using half an + // extrusion spacing would overlap the finer strips and distort curved anchors. struct TracedPoly { Points lows; @@ -3220,8 +3245,8 @@ void PrintObject::bridge_over_infill() 36.0 * double(bridging_flow.scaled_spacing()) * bridging_flow.scaled_spacing()) { traced_poly.lows.push_back(candidate->a); } else { - traced_poly.lows.push_back(traced_poly.lows.back() + Point{bridging_flow.scaled_spacing() / 2, 0}); - traced_poly.lows.push_back(candidate->a - Point{bridging_flow.scaled_spacing() / 2, 0}); + traced_poly.lows.push_back(traced_poly.lows.back() + Point{scan_spacing / 2, 0}); + traced_poly.lows.push_back(candidate->a - Point{scan_spacing / 2, 0}); traced_poly.lows.push_back(candidate->a); } @@ -3229,8 +3254,8 @@ void PrintObject::bridge_over_infill() 36.0 * double(bridging_flow.scaled_spacing()) * bridging_flow.scaled_spacing()) { traced_poly.highs.push_back(candidate->b); } else { - traced_poly.highs.push_back(traced_poly.highs.back() + Point{bridging_flow.scaled_spacing() / 2, 0}); - traced_poly.highs.push_back(candidate->b - Point{bridging_flow.scaled_spacing() / 2, 0}); + traced_poly.highs.push_back(traced_poly.highs.back() + Point{scan_spacing / 2, 0}); + traced_poly.highs.push_back(candidate->b - Point{scan_spacing / 2, 0}); traced_poly.highs.push_back(candidate->b); } segment_added = true; @@ -3238,9 +3263,9 @@ void PrintObject::bridge_over_infill() } if (!segment_added) { - // Zero overlapping segments, we just close this polygon - traced_poly.lows.push_back(traced_poly.lows.back() + Point{bridging_flow.scaled_spacing() / 2, 0}); - traced_poly.highs.push_back(traced_poly.highs.back() + Point{bridging_flow.scaled_spacing() / 2, 0}); + // Orca: No section continues this strip; close at its right edge. + traced_poly.lows.push_back(traced_poly.lows.back() + Point{scan_spacing / 2, 0}); + traced_poly.highs.push_back(traced_poly.highs.back() + Point{scan_spacing / 2, 0}); Polygon &new_poly = expanded_bridged_area.emplace_back(std::move(traced_poly.lows)); new_poly.points.insert(new_poly.points.end(), traced_poly.highs.rbegin(), traced_poly.highs.rend()); traced_poly.lows.clear(); @@ -3255,9 +3280,9 @@ void PrintObject::bridge_over_infill() for (const auto &segment : polygon_slice) { if (used_segments.find(&segment) == used_segments.end()) { TracedPoly &new_tp = current_traced_polys.emplace_back(); - new_tp.lows.push_back(segment.a - Point{bridging_flow.scaled_spacing() / 2, 0}); + new_tp.lows.push_back(segment.a - Point{scan_spacing / 2, 0}); new_tp.lows.push_back(segment.a); - new_tp.highs.push_back(segment.b - Point{bridging_flow.scaled_spacing() / 2, 0}); + new_tp.highs.push_back(segment.b - Point{scan_spacing / 2, 0}); new_tp.highs.push_back(segment.b); } } @@ -3364,7 +3389,10 @@ void PrintObject::bridge_over_infill() total_fill_area = closing(total_fill_area, float(SCALED_EPSILON)); expansion_area = closing(expansion_area, float(SCALED_EPSILON)); expansion_area = intersection(expansion_area, deep_infill_area); - Polylines anchors = intersection_pl(infill_lines[lidx - 1], shrink(expansion_area, spacing)); + // Orca: Preserve the real lower-layer anchors for every candidate in this + // layer. Replacing this shared set for one pattern also changes later regions, + // and synthetic straight lines can claim support where no infill is printed. + const Polylines anchors = intersection_pl(infill_lines[lidx - 1], shrink(expansion_area, spacing)); Polygons internal_unsupported_area = shrink(deep_infill_area, spacing * 4.5); #ifdef DEBUG_BRIDGE_OVER_INFILL @@ -3375,6 +3403,9 @@ void PrintObject::bridge_over_infill() std::vector expanded_surfaces; expanded_surfaces.reserve(surfaces_by_layer[lidx].size()); for (const CandidateSurface &candidate : surfaces_by_layer[lidx]) { + const auto ®ion_config = candidate.region->region().config(); + const bool turning_pattern = region_config.sparse_infill_pattern == ipHilbertCurve || + region_config.sparse_infill_pattern == ipOctagramSpiral; const Flow &flow = candidate.region->bridging_flow(frSolidInfill, true); Polygons area_to_be_bridge = expand(candidate.new_polys, flow.scaled_spacing()); area_to_be_bridge = intersection(area_to_be_bridge, deep_infill_area); @@ -3403,20 +3434,40 @@ void PrintObject::bridge_over_infill() to_lines(area_to_be_bridge), to_lines(boundary_plines), to_lines(anchors), to_lines(expansion_area)); #endif - double bridging_angle = 0; - if (!anchors.empty()) { - bridging_angle = determine_bridging_angle(area_to_be_bridge, to_lines(anchors), - candidate.region->region().config().sparse_infill_pattern.value, - candidate.region->region().config().infill_direction.value); - } else { - // use expansion boundaries as anchors. - // Also, use Infill pattern that is neutral for angle determination, since there are no infill lines. - bridging_angle = determine_bridging_angle(area_to_be_bridge, to_lines(boundary_plines), InfillPattern::ipLine, 0); + double bridging_angle = -1.; + if (!anchors.empty() && turning_pattern) { + // Orca: Keep adjacent bridges over Hilbert/Octagram aligned despite + // their many local turning directions. Use the lower layer's rotation, + // since that is the infill supporting the bridge, not the current layer's. + for (const LayerRegion *lower_region : layer->lower_layer->regions()) { + // Orca: Apply the configured direction only if the same region has + // sparse infill below this bridge. A height modifier may put another + // pattern underneath, requiring the geometry-based fallback below. + if (&lower_region->region() != &candidate.region->region() || + intersection(area_to_be_bridge, to_polygons(lower_region->fill_surfaces.filter_by_type(stInternal))).empty()) + continue; + bridging_angle = calculate_infill_rotation_angle(po, layer->lower_layer->id(), region_config.infill_direction.value, + region_config.sparse_infill_rotate_template.value) + 0.5 * PI; + // Orca: Apply model alignment as infill generation does, then normalize + // the undirected bridge angle to [0, PI), including negative rotations. + if (region_config.align_infill_direction_to_model) { + const auto &m = po->trafo().matrix(); + bridging_angle += std::atan2(double(m(1, 0)), double(m(0, 0))); + } + bridging_angle = std::fmod(bridging_angle, PI); + if (bridging_angle < 0.) + bridging_angle += PI; + break; + } } + // Orca: A different region below (e.g. a height modifier) needs the actual anchor + // directions. When there are no sparse anchors, use the expansion boundaries. + if (bridging_angle < 0.) + bridging_angle = determine_bridging_angle(area_to_be_bridge, to_lines(anchors.empty() ? boundary_plines : anchors)); - // ORCA: Internal bridge angle override + // Orca: Preserve the user's absolute or relative internal bridge angle + // override after automatic direction selection. if (candidate.region->region().config().internal_bridge_angle.value > 0) { - const auto ®ion_config = candidate.region->region().config(); const double custom_angle_rad = Geometry::deg2rad(region_config.internal_bridge_angle.value); if (region_config.relative_bridge_angle.value) bridging_angle += custom_angle_rad; @@ -3429,11 +3480,19 @@ void PrintObject::bridge_over_infill() } } + // Orca: Changing the bridge direction must not change its physical supports. + // Extend to actual sparse infill or the existing boundary anchors, never to + // a synthetic grid that merely has the same nominal angle and spacing. boundary_plines.insert(boundary_plines.end(), anchors.begin(), anchors.end()); if (!lightning_area.empty() && !intersection(area_to_be_bridge, lightning_area).empty()) { boundary_plines = intersection_pl(boundary_plines, expand(area_to_be_bridge, scale_(10))); } - Polygons bridging_area = construct_anchored_polygon(area_to_be_bridge, to_lines(boundary_plines), flow, bridging_angle); + // Orca: Use four samples per extrusion spacing for Hilbert/Octagram so the + // reconstructed boundary follows rounded anchors instead of cutting corners. + // Keep the original step for other patterns and at least one coordinate unit + // after integer division. This changes boundary accuracy, not infill density. + const coord_t scan_spacing = std::max(coord_t(1), flow.scaled_spacing() / (turning_pattern ? 4 : 1)); + Polygons bridging_area = construct_anchored_polygon(area_to_be_bridge, to_lines(boundary_plines), flow, bridging_angle, scan_spacing); // Check collision with other expanded surfaces { @@ -3447,7 +3506,9 @@ void PrintObject::bridge_over_infill() } } if (reconstruct) { - bridging_area = construct_anchored_polygon(area_to_be_bridge, to_lines(boundary_plines), flow, bridging_angle); + // Orca: Retain the same sampling accuracy when matching a nearby + // bridge's direction; rebuilding must not lose the curved supports. + bridging_area = construct_anchored_polygon(area_to_be_bridge, to_lines(boundary_plines), flow, bridging_angle, scan_spacing); } } @@ -3455,6 +3516,13 @@ void PrintObject::bridge_over_infill() // bridging_area = opening(bridging_area, flow.scaled_spacing()); bridging_area = opening(bridging_area, flow.scaled_spacing() * 0.75); bridging_area = closing(bridging_area, flow.scaled_spacing()); + // Orca: Opening/closing can pull rounded bridge ends away from their real + // supports. Restore those contacts after smoothing, preserving the cleaned + // area and the selected angle; do not smooth the restored contacts again. + if (turning_pattern && !bridging_area.empty()) { + bridging_area = union_(bridging_area, construct_anchored_polygon(bridging_area, to_lines(boundary_plines), flow, + bridging_angle, scan_spacing, true)); + } bridging_area = intersection(bridging_area, limiting_area); bridging_area = intersection(bridging_area, total_fill_area); bridging_area = diff(bridging_area, total_top_area); diff --git a/src/libslic3r/PublishSettings.cpp b/src/libslic3r/PublishSettings.cpp new file mode 100644 index 0000000000..6d28cc0af3 --- /dev/null +++ b/src/libslic3r/PublishSettings.cpp @@ -0,0 +1,315 @@ +#include "PublishSettings.hpp" + +#include "PresetBundle.hpp" +#include "Preset.hpp" +#include "PrintConfig.hpp" +#include "MaterialType.hpp" + +#include +#include + +#include +#include + +namespace Slic3r { + +std::string publish_base_key(const std::string &key) +{ + const size_t pos = key.find('#'); + return pos == std::string::npos ? key : key.substr(0, pos); +} + +// Parse the trailing "#N" variant index ("retraction_length#2" -> 2). Returns -1 when the key +// carries no '#' separator or its suffix is malformed; mirrors the importer's strict parse +// (PresetBundle.cpp) so the export side rejects the same variants the receiver would skip. +static int publish_variant_index(const std::string &key, const std::string &base_key) +{ + if (key.size() <= base_key.size() || key.compare(0, base_key.size(), base_key) != 0 || key[base_key.size()] != '#') + return -1; + const std::string suffix = key.substr(base_key.size() + 1); + if (suffix.empty()) + return -1; + int idx = 0; + for (const char c : suffix) { + if (c < '0' || c > '9') + return -1; + idx = idx * 10 + (c - '0'); + if (idx > 1000000) // overflow guard; real vector sizes are tiny + return -1; + } + return idx; +} + +std::string normalize_filament_type(const std::string& type) +{ + if (type.empty()) + return type; + if (MaterialType::find(type) != nullptr) + return type; + // "PLA High Speed" -> "PLA": strip a space-separated modifier, but keep dash-separated + // types like "PA-CF" / "PETG-CF" intact (they are distinct materials, not modifiers). + const size_t sep = type.find(' '); + if (sep != std::string::npos) { + const std::string base = type.substr(0, sep); + if (MaterialType::find(base) != nullptr) + return base; + } + return type; +} + +void make_publish_universal(DynamicPrintConfig &config) +{ + // Lists: empty => compatible with every printer / every print preset. Conditions: + // empty so a leftover expression left behind by the baseline clone can never + // re-narrow the match (see is_compatible_with_printer, Preset.cpp:840). All four + // keys exist on filament presets; nil-guard for hand-crafted future schemas. + if (auto *opt = config.opt("compatible_printers", false)) + opt->values.clear(); + if (auto *opt = config.opt("compatible_prints", false)) + opt->values.clear(); + if (auto *opt = config.opt("compatible_printers_condition", false)) + opt->value.clear(); + if (auto *opt = config.opt("compatible_prints_condition", false)) + opt->value.clear(); +} + +std::string publish_material_base_name(const std::string &preset_name) +{ + if (preset_name.empty()) + return preset_name; + const size_t at = preset_name.find('@'); + std::string base = (at == std::string::npos) ? preset_name : preset_name.substr(0, at); + boost::trim_right(base); + return base; +} + +const std::set& publish_structural_keys() +{ + // Non-publishable keys: the *_settings_id keys are also in PresetCollection::skipped_in_dirty + // (Preset.cpp) / stripped from configs (profile_print_params_same); publishing them would + // rewrite the user's preset inheritance/structure. + static const std::set structural_keys = { + "printer_settings_id", "filament_settings_id", "print_settings_id", + "sla_print_settings_id", "sla_material_settings_id", + "compatible_printers", "compatible_prints", + "compatible_printers_condition", "compatible_prints_condition", + "default_filament_profile", "default_print_profile", + "default_sla_print_profile", "default_sla_material_profile", + "extruder_count", "bed_shape", + "inherits", "inherits_group", + "printer_technology", "printer_model", "printer_variant", + "physical_printer_settings_id", "filament_ids", + "different_settings_to_system" + }; + return structural_keys; +} + +const std::set& publish_mixed_keys() +{ + // Must match PresetBundle's s_project_options mixed-color group (PresetBundle.cpp): these + // are project-level parallel per-slot arrays, not filament-preset options, so the import + // material pass applies them into project_config instead of a filament preset config. + static const std::set mixed_keys = { + "filament_is_mixed", + "filament_mixed_components", + "filament_mixed_sublayer_ratios", + "filament_mixed_gradient", + "filament_mixed_gradient_range", + "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" + }; + return mixed_keys; +} + +// The printer tab's "Retraction" optgroup (TabPrinter::build_fff, Tab.cpp), in tab order. +// KEEP IN SYNC with that optgroup: the published-3MF printer allowlist is built from these +// lists, so any key shown there must be publishable here (and vice versa). +const std::vector& publishable_printer_retraction_options() +{ + static const std::vector options = { + { "retraction_length", "printer_extruder_retraction#length" }, + { "retract_restart_extra", "printer_extruder_retraction#extra-length-on-restart" }, + { "retraction_speed", "printer_extruder_retraction#retraction-speed" }, + { "deretraction_speed", "printer_extruder_retraction#deretraction-speed" }, + { "retraction_minimum_travel", "printer_extruder_retraction#travel-distance-threshold" }, + { "retract_when_changing_layer", "printer_extruder_retraction#retract-on-layer-change" }, + { "wipe", "printer_extruder_retraction#wipe-while-retracting" }, + { "wipe_distance", "printer_extruder_retraction#wipe-distance" }, + { "retract_before_wipe", "printer_extruder_retraction#retract-amount-before-wipe" }, + { "retract_after_wipe", "printer_extruder_retraction#retract-amount-after-wipe" }, + }; + return options; +} + +// The printer tab's "Z-Hop" optgroup (TabPrinter::build_fff, Tab.cpp), in tab order. KEEP IN +// SYNC with that optgroup, same as publishable_printer_retraction_options(). +const std::vector& publishable_printer_z_hop_options() +{ + static const std::vector options = { + { "retract_lift_enforce", "printer_extruder_z_hop#on-surfaces" }, + { "z_hop_types", "printer_extruder_z_hop#z-hop-type" }, + { "z_hop", "printer_extruder_z_hop#z-hop-height" }, + { "travel_slope", "printer_extruder_z_hop#traveling-angle" }, + { "retract_lift_above", "printer_extruder_z_hop#only-lift-z-above" }, + { "retract_lift_below", "printer_extruder_z_hop#only-lift-z-below" }, + }; + return options; +} + +const std::set& publishable_printer_keys() +{ + // Union of the two optgroups; "Retraction when switching material" keys are excluded + // (toolchange retraction is device/profile territory, not a publishable behavior tweak). + static const std::set printer_keys = [] { + std::set keys; + for (const PublishablePrinterOption &opt : publishable_printer_retraction_options()) + keys.insert(opt.key); + for (const PublishablePrinterOption &opt : publishable_printer_z_hop_options()) + keys.insert(opt.key); + return keys; + }(); + return printer_keys; +} + +std::vector collect_dirty_settings_keys(const PresetBundle& bundle) +{ + std::set keys; + + // Union the dirty keys of each collection's edited preset (filaments may span multiple + // slots); feeds only the Publish dialog's pre-check. + for (const std::string& key : bundle.prints.current_dirty_options(true)) + keys.insert(key); + for (const std::string& key : bundle.printers.current_dirty_options(true)) + keys.insert(key); + for (const std::string& key : bundle.filaments.current_dirty_options(true)) + keys.insert(key); + + return std::vector(keys.begin(), keys.end()); +} + +DynamicPrintConfig filter_published_config( + const DynamicPrintConfig &full_config, + const std::vector &published_keys, + const std::vector &material_keys) +{ + DynamicPrintConfig filtered; + + std::set base_keys_to_include; + // Never masked (whole-vector serialization): identity, plate geometry, process keys and + // printer keys without a "#N" variant. + std::set mask_exempt_keys; + // Material entries: base key -> author slots whose values must survive; other slots are + // masked to their defaults so a publish (partial or full) does not leak unrelated slot + // data. + std::map> slot_mask_map; + + // 1. Mandatory material identity & slot count keys for 3MF validation/normalization + // (filament_ids: exported for validation, denylisted on apply - see publish_structural_keys). + static const std::vector s_material_identity_keys = { + "filament_colour", + "filament_type", + "filament_vendor", + "filament_ids", + "filament_diameter", + "filament_self_index", + "filament_extruder_variant" + }; + for (const std::string &key : s_material_identity_keys) { + base_keys_to_include.insert(key); + mask_exempt_keys.insert(key); + } + + // 2. Published plate / bed geometry keys (wipe tower positioning) + static const std::vector s_plate_geometry_keys = { + "wipe_tower_x", + "wipe_tower_y", + "wipe_tower_rotation_angle" + }; + for (const std::string &key : s_plate_geometry_keys) { + base_keys_to_include.insert(key); + mask_exempt_keys.insert(key); + } + + // 3. Process and printer published keys. Printer per-extruder keys carry a "#N" variant + // (e.g. retraction_length#2): mask the base to the author's extruder index so a partial + // publish does not serialize every extruder's value (same slot-masking as the material side). + const std::set &printer_keys = publishable_printer_keys(); + for (const std::string &key : published_keys) { + const std::string base_key = publish_base_key(key); + if (base_key.empty()) + continue; + base_keys_to_include.insert(base_key); + if (printer_keys.count(base_key) != 0) { + const int variant_idx = publish_variant_index(key, base_key); + if (variant_idx >= 0) + slot_mask_map[base_key].insert(variant_idx); + else + mask_exempt_keys.insert(base_key); // bare printer key or malformed variant: whole vector + } else { + mask_exempt_keys.insert(base_key); // process key: whole vector + } + } + + // 4. Material-specific published keys. Both partial (entry.keys) and full-publish + // (entry.full_keys) entries mask to the author's slot on export (see the copy loop below); + // slot-less entries (hand-crafted files) stay unmasked (whole vector). + for (const PublishedMaterialEntry &entry : material_keys) { + for (const std::string &key : entry.keys) { + const std::string base_key = publish_base_key(key); + if (base_key.empty()) + continue; + base_keys_to_include.insert(base_key); + if (entry.slot >= 0) + slot_mask_map[base_key].insert(entry.slot); + } + for (const std::string &key : entry.full_keys) { + const std::string base_key = publish_base_key(key); + if (base_key.empty()) + continue; + base_keys_to_include.insert(base_key); + if (entry.slot >= 0) + slot_mask_map[base_key].insert(entry.slot); + } + } + + // Masking restores every non-published slot of a vector option with the option default, so + // a partial publish does not leak unrelated slot data. can_mask_slots reports whether a key + // is maskable at all (vector option plus a registered default of the same type); an + // unmaskable key is dropped from the payload entirely instead of shipping the author's + // whole vector. + auto can_mask_slots = [](const ConfigOption &opt, const ConfigOptionDef *def) -> bool { + if (def == nullptr || !def->default_value || def->default_value->type() != opt.type()) + return false; + const auto *vec = dynamic_cast(&opt); + const auto *default_vec = dynamic_cast(def->default_value.get()); + return vec != nullptr && vec->size() > 0 && default_vec != nullptr && !default_vec->empty(); + }; + auto mask_slots = [](ConfigOption &opt, const ConfigOptionDef *def, const std::set &keep_slots) { + auto *vec = dynamic_cast(&opt); + for (size_t idx = 0; idx < vec->size(); ++idx) + if (keep_slots.count(static_cast(idx)) == 0) + vec->set_at(def->default_value.get(), idx, 0); + }; + + // Copy the selected options from full_config into the filtered config. + for (const std::string &key : base_keys_to_include) { + const ConfigOption *opt = full_config.option(key); + if (opt == nullptr) + continue; + const auto mask_it = slot_mask_map.find(key); + const bool needs_masking = mask_exempt_keys.count(key) == 0 && mask_it != slot_mask_map.end() && !mask_it->second.empty(); + if (needs_masking && !can_mask_slots(*opt, print_config_def.get(key))) { + BOOST_LOG_TRIVIAL(warning) << "publish: dropping unmaskable key \"" << key + << "\" from the published payload (no usable option default)"; + continue; + } + ConfigOption *cloned = opt->clone(); + if (needs_masking) + mask_slots(*cloned, print_config_def.get(key), mask_it->second); + filtered.set_key_value(key, cloned); + } + + return filtered; +} + +} // namespace Slic3r diff --git a/src/libslic3r/PublishSettings.hpp b/src/libslic3r/PublishSettings.hpp new file mode 100644 index 0000000000..60ea9a8354 --- /dev/null +++ b/src/libslic3r/PublishSettings.hpp @@ -0,0 +1,99 @@ +#pragma once +#include +#include +#include + +namespace Slic3r { +class PresetBundle; + +// Strip a trailing "#N" variant suffix ("retraction_length#2" -> "retraction_length"). +std::string publish_base_key(const std::string &key); + +// Structural keys that are never applied onto the receiver's presets when loading a published +// 3MF (single source of truth for the denylist); applying them would rewrite the user's preset +// inheritance/structure. filament_ids is still exported via the identity list (3MF validation +// needs it) - exported, never applied. +const std::set& publish_structural_keys(); + +// The mixed-color filament project keys (parallel per-slot arrays, see PresetBundle's +// s_project_options). Import applies them into project_config, not a filament preset. +const std::set& publish_mixed_keys(); + +// One row of the printer tab's "Retraction" / "Z-Hop" optgroups (config key + tab icon id). +struct PublishablePrinterOption { + const char *key; // config key, e.g. "retraction_length" + const char *icon; // tab icon id, e.g. "printer_extruder_retraction#length" +}; + +// The printer tab's "Retraction" / "Z-Hop" optgroup options, in tab order. +const std::vector& publishable_printer_retraction_options(); +const std::vector& publishable_printer_z_hop_options(); + +// Union of the two optgroup option lists; printer keys apply on import only if their base +// key is in this allowlist. +const std::set& publishable_printer_keys(); + +// Union of setting keys differing from the base/system preset across the current print, +// printer and filament presets (feeds the Publish dialog's pre-check). +std::vector collect_dirty_settings_keys(const PresetBundle& bundle); + +// Per-slot published material keys, applied positionally (author slot N -> receiver slot N). +// The identity fields drive the created copy's naming and grouping on Full entries, the +// notification labels, and the partial type gate (publish_type) is the author's explicit +// opt-in for requiring a material type. +struct PublishedMaterialEntry { + std::string filament_type; // material family, e.g. "PLA" (may be empty) + std::string filament_vendor; // e.g. "Generic", "Bambu" (may be empty) + std::string filament_id; // stable material id, e.g. "GFL99" (may be empty) + // Unique preset id of the author's slot preset (e.g. Orca Filament Library "setting_id"). + // Not matched against the receiver's library; carried so identical Full entries within one + // load share one created instance (within-load dedup key). + std::string setting_id; + // Canonical name of the author's slot preset (e.g. "Generic PLA @System"). On Full import + // it names the created copy after its "@variant" tail is stripped; never matched against + // the receiver's library. + std::string preset_name; + // 0-based author filament slot; -1 (hand-crafted files) is skipped. + int slot{-1}; + std::vector keys; + // "Full Publish": the whole filament preset (full_keys) is published. On the receiver Full + // Publish always creates a standalone parentless copy (libslic3r's "Detach from parent"), + // universally compatible and project-embedded only - never written to the user's library. + // Identical Full entries within one load share one created instance (within-load dedup). + bool full{false}; + // All non-structural filament keys of the author's slot preset; values travel in the file + // config, masked to the author's slot index. + std::vector full_keys; + // Vendor-agnostic (MaterialType) filament type the author requires for this slot; on a + // partial entry's mismatch the slot is replaced with a same-type filament. Full entries + // consult no gate. + bool publish_type{false}; + std::string publish_type_value; + // Required filament colour, applied on load regardless of the type match. + bool publish_color{false}; + std::string color; + // Import-side only, never serialized: the authored slot sits past the receiver's physical + // capacity, so the entry is appended as an empty mixed-filament placeholder (virtual tail + // slot; the GUI flags it for the user to assign components). + bool mixed_placeholder{false}; +}; + +// "PLA High Speed" -> "PLA" (strip a space modifier); dash types like "PA-CF" are kept intact. +std::string normalize_filament_type(const std::string& type); + +class DynamicPrintConfig; +// Clear the compatibility lists/conditions on a filament config so it is universally +// compatible once detached (empty lists + empty conditions = compatible with everything). +void make_publish_universal(DynamicPrintConfig &config); + +// Naming base for a detached published-material copy: "Generic PLA @System" -> "Generic PLA" +// (truncate/right-trim at the first '@' tail). Empty result means "fall back to identity". +std::string publish_material_base_name(const std::string &preset_name); + +// Minimal DynamicPrintConfig for a published 3MF export: only the selected published keys, +// material keys, identity fields and plate geometry keys. +DynamicPrintConfig filter_published_config( + const DynamicPrintConfig &full_config, + const std::vector &published_keys, + const std::vector &material_keys); +} diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 7c25792bb3..691675a452 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -97,6 +97,8 @@ set(SLIC3R_GUI_SOURCES GUI/CloneDialog.hpp GUI/ConfigManipulation.cpp GUI/ConfigManipulation.hpp + GUI/ConfigValueFormatter.cpp + GUI/ConfigValueFormatter.hpp GUI/ConfigWizard.cpp GUI/ConfigWizard.hpp GUI/ConfigWizard_private.hpp @@ -451,6 +453,8 @@ set(SLIC3R_GUI_SOURCES GUI/Project.hpp GUI/PublishDialog.cpp GUI/PublishDialog.hpp + GUI/PublishSettingsDialog.cpp + GUI/PublishSettingsDialog.hpp GUI/PurgeModeDialog.cpp GUI/PurgeModeDialog.hpp GUI/RammingChart.cpp diff --git a/src/slic3r/GUI/ConfigValueFormatter.cpp b/src/slic3r/GUI/ConfigValueFormatter.cpp new file mode 100644 index 0000000000..6f9128841b --- /dev/null +++ b/src/slic3r/GUI/ConfigValueFormatter.cpp @@ -0,0 +1,245 @@ +#include "ConfigValueFormatter.hpp" + +#include +#include +#include +#include + +#include +#include + +#include "libslic3r/Config.hpp" +#include "libslic3r/PrintConfig.hpp" + +#include "I18N.hpp" +#include "GUI.hpp" +#include "Field.hpp" + +namespace Slic3r { +namespace GUI { + +std::string get_pure_opt_key(const std::string& opt_key) +{ + std::string pure_key = opt_key; + const int pos = pure_key.find("#"); + if (pos > 0) + boost::erase_tail(pure_key, pure_key.size() - pos); + return pure_key; +} + +wxString get_string_from_enum(const std::string& opt_key, const DynamicPrintConfig& config, bool is_infill, int idx) +{ + const ConfigOptionDef& def = config.def()->options.at(opt_key); + const std::vector& names = def.enum_labels;//ConfigOptionEnum::get_enum_names(); + int val = 0; + + if (idx >= 0) + val = dynamic_cast(config.option(opt_key))->get_at(idx); + else + val = config.option(opt_key)->getInt(); + + // Each infill doesn't use all list of infill declared in PrintConfig.hpp. + // So we should "convert" val to the correct one + if (is_infill) { + for (auto key_val : *def.enum_keys_map) + if (int(key_val.second) == val) { + auto it = std::find(def.enum_values.begin(), def.enum_values.end(), key_val.first); + if (it == def.enum_values.end()) + return ""; + return from_u8(_utf8(names[it - def.enum_values.begin()])); + } + return _L("Undefined"); + } + return from_u8(_utf8(names[val])); +} + +wxString get_full_label(const std::string& opt_key, const DynamicPrintConfig& config) +{ + const std::string pure_key = get_pure_opt_key(opt_key); + auto option = config.option(pure_key); + + if (!option || option->is_nil()) + return _L("N/A"); + + const ConfigOptionDef* opt = config.def()->get(pure_key); + return opt->full_label.empty() ? opt->label : opt->full_label; +} + +wxString get_string_value(const std::string& opt_key, const DynamicPrintConfig& config) +{ + int orig_opt_idx = -1; + int opt_idx = -1; + int pos = opt_key.find("#"); + std::string temp_str = opt_key; + if (pos > 0) { + boost::erase_head(temp_str, pos + 1); + orig_opt_idx = std::atoi(temp_str.c_str()); + } + opt_idx = orig_opt_idx >= 0 ? orig_opt_idx : 0; + const std::string pure_key = get_pure_opt_key(opt_key); + auto option = config.option(pure_key); + if (!option) { + return _L("N/A"); + } + auto opt_vector = dynamic_cast(option); + + if ((option->is_scalar() && option->is_nil()) || + (option->is_vector() && opt_vector && opt_idx >= 0 && opt_idx < opt_vector->size() && opt_vector->is_nil(opt_idx))) + return _L("N/A"); + + wxString out; + + const ConfigOptionDef* opt = config.def()->get(pure_key); + bool is_nullable = opt->nullable; + + switch (opt->type) { + case coInt: + return from_u8((boost::format("%1%") % config.opt_int(pure_key)).str()); + case coInts: { + if (is_nullable) { + auto values = config.opt(pure_key); + if (opt_idx < values->size()) + return from_u8((boost::format("%1%") % values->get_at(opt_idx)).str()); + } + else { + auto values = config.opt(pure_key); + if (orig_opt_idx >= 0 && orig_opt_idx < values->size()) { + return from_u8((boost::format("%1%") % values->get_at(opt_idx)).str()); + } + else { + std::string value_str; + for (int i = 0; i < values->size(); i++) { + value_str += std::to_string(values->get_at(i)); + if (i != values->size() - 1) { + value_str += ","; + } + } + return from_u8(value_str); + } + } + return _L("Undefined"); + } + case coBool: + return config.opt_bool(pure_key) ? "true" : "false"; + case coBools: { + if (is_nullable) { + auto values = config.opt(pure_key); + if (opt_idx < values->size()) + return values->get_at(opt_idx) ? "true" : "false"; + } + else { + auto values = config.opt(pure_key); + if (opt_idx < values->size()) + return values->get_at(opt_idx) ? "true" : "false"; + } + return _L("Undefined"); + } + case coPercent: + return from_u8((boost::format("%1%%%") % int(config.optptr(pure_key)->getFloat())).str()); + case coPercents: { + if (is_nullable) { + auto values = config.opt(pure_key); + if (opt_idx < values->size()) + return from_u8((boost::format("%1%%%") % values->get_at(opt_idx)).str()); + } + else { + auto values = config.opt(pure_key); + if (opt_idx < values->size()) + return from_u8((boost::format("%1%%%") % values->get_at(opt_idx)).str()); + } + return _L("Undefined"); + } + case coFloat: + return double_to_string(config.opt_float(pure_key)); + case coFloats: { + if (is_nullable) { + auto values = config.opt(pure_key); + if (opt_idx < values->size()) + return double_to_string(values->get_at(opt_idx)); + } + else { + auto values = config.opt(pure_key); + if (values && opt_idx < values->size()) + return double_to_string(values->get_at(opt_idx)); + } + return _L("Undefined"); + } + case coString: + return from_u8(config.opt_string(pure_key)); + case coStrings: { + const ConfigOptionStrings* strings = config.opt(pure_key); + if (strings) { + if (pure_key == "compatible_printers" || pure_key == "compatible_prints") { + if (strings->empty()) + return _L("All"); + for (size_t id = 0; id < strings->size(); id++) + out += from_u8(strings->get_at(id)) + "\n"; + out.RemoveLast(1); + return out; + } + if (!strings->empty() && opt_idx < strings->values.size()) + return from_u8(strings->get_at(opt_idx)); + } + break; + } + case coFloatOrPercent: { + const ConfigOptionFloatOrPercent* opt = config.opt(pure_key); + if (opt) + out = double_to_string(opt->value) + (opt->percent ? "%" : ""); + return out; + } + case coEnum: { + return get_string_from_enum(pure_key, config, + pure_key == "top_surface_pattern" || + pure_key == "bottom_surface_pattern" || + pure_key == "internal_solid_infill_pattern" || + pure_key == "sparse_infill_pattern" || + pure_key == "ironing_pattern" || + pure_key == "support_ironing_pattern" || + pure_key == "support_pattern" || + pure_key == "support_interface_pattern") + ; + } + case coEnums: { + return get_string_from_enum(pure_key, config, + pure_key == "top_surface_pattern" || + pure_key == "bottom_surface_pattern" || + pure_key == "internal_solid_infill_pattern" || + pure_key == "sparse_infill_pattern" || + pure_key == "ironing_pattern" || + pure_key == "support_ironing_pattern" || + pure_key == "support_pattern" || + pure_key == "support_interface_pattern" + , opt_idx); + } + case coPoint: { + Vec2d val = config.opt(pure_key)->value; + return from_u8((boost::format("[%1%]") % ConfigOptionPoint(val).serialize()).str()); + } + case coPoints: { + //BBS: add bed_exclude_area + if (pure_key == "printable_area" || pure_key == "thumbnails") { + ConfigOptionPoints points = *config.option(pure_key); + //BuildVolume build_volume = {points.values, 0.}; + return get_thumbnails_string(points.values); + } + else if (pure_key == "bed_exclude_area") { + return get_thumbnails_string(config.option(pure_key)->values); + } + else if (pure_key == "head_wrap_detect_zone") { + return get_thumbnails_string(config.option(pure_key)->values); + } + else if (pure_key == "wrapping_exclude_area") { + return get_thumbnails_string(config.option(pure_key)->values); + } + Vec2d val = config.opt(pure_key)->get_at(opt_idx); + return from_u8((boost::format("[%1%]") % ConfigOptionPoint(val).serialize()).str()); + } + default: + break; + } + return out; +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/ConfigValueFormatter.hpp b/src/slic3r/GUI/ConfigValueFormatter.hpp new file mode 100644 index 0000000000..67b16f9326 --- /dev/null +++ b/src/slic3r/GUI/ConfigValueFormatter.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include + +namespace Slic3r { + +class DynamicPrintConfig; + +namespace GUI { + +// Human-readable value of opt_key (may carry a "#" suffix) in config. +wxString get_string_value(const std::string& opt_key, const DynamicPrintConfig& config); + +// Full label of opt_key; "N/A" when the option is not set. +wxString get_full_label(const std::string& opt_key, const DynamicPrintConfig& config); + +// Strip the "#" suffix (if any) from the option key. +std::string get_pure_opt_key(const std::string& opt_key); + +// Localized label of the currently selected value of an enum option. +wxString get_string_from_enum(const std::string& opt_key, const DynamicPrintConfig& config, bool is_infill = false, int idx = -1); + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/Field.hpp b/src/slic3r/GUI/Field.hpp index 6773d7a3f4..6219921202 100644 --- a/src/slic3r/GUI/Field.hpp +++ b/src/slic3r/GUI/Field.hpp @@ -627,8 +627,10 @@ private: void on_button_click(wxCommandEvent &WXUNUSED(ev)); void save_colors_to_config(); private: +#if !defined(__linux__) && !defined(__LINUX__) wxColourData* m_clrData{nullptr}; wxColourPickerWidget* m_picker_widget{nullptr}; +#endif }; class PointCtrl : public Field { diff --git a/src/slic3r/GUI/FilamentBitmapUtils.cpp b/src/slic3r/GUI/FilamentBitmapUtils.cpp index 1f51fc79b3..3af3d1ad01 100644 --- a/src/slic3r/GUI/FilamentBitmapUtils.cpp +++ b/src/slic3r/GUI/FilamentBitmapUtils.cpp @@ -1,16 +1,66 @@ #include +#include #include +#include +#include #include #include +#include +#include +#include +#include #include "EncodedFilament.hpp" #include "FilamentBitmapUtils.hpp" #include "GUI_App.hpp" +#include "GuiColor.hpp" +#include "I18N.hpp" +#include "Widgets/Label.hpp" +#include "Widgets/StateColor.hpp" #include "libslic3r/FilamentMixer.hpp" #include "libslic3r/PrintConfig.hpp" namespace Slic3r { namespace GUI { +// Barycentric utilities for a ternary (triangle) ratio picker. +double tri_signed_area2(TriPoint a, TriPoint b, TriPoint c) +{ + return (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y); +} + +bool tri_contains(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2) +{ + double total = tri_signed_area2(v0, v1, v2); + if (std::abs(total) < 1e-9) return false; + double s0 = tri_signed_area2(p, v1, v2) / total; + double s1 = tri_signed_area2(v0, p, v2) / total; + double s2 = 1.0 - s0 - s1; + return s0 >= -0.001 && s1 >= -0.001 && s2 >= -0.001; +} + +void tri_barycentric(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2, + double& w0, double& w1, double& w2) +{ + double total = std::abs(tri_signed_area2(v0, v1, v2)); + if (total < 1e-9) { w0 = w1 = w2 = 1.0 / 3.0; return; } + w0 = std::abs(tri_signed_area2(p, v1, v2)) / total; + w1 = std::abs(tri_signed_area2(v0, p, v2)) / total; + w2 = 1.0 - w0 - w1; + w0 = std::clamp(w0, 0.0, 1.0); + w1 = std::clamp(w1, 0.0, 1.0); + w2 = std::clamp(w2, 0.0, 1.0); + double s = w0 + w1 + w2; + if (s > 0) { w0 /= s; w1 /= s; w2 /= s; } +} + +TriPoint tri_clamp(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2) +{ + double w0, w1, w2; + tri_barycentric(p, v0, v1, v2, w0, w1, w2); + return {w0 * v0.x + w1 * v1.x + w2 * v2.x, + w0 * v0.y + w1 * v1.y + w2 * v2.y}; +} + void fill_gradient_rect_east(wxDC& dc, const wxRect& rect, const wxColour& from, const wxColour& to) { if (rect.width <= 0 || rect.height <= 0) return; @@ -73,7 +123,7 @@ std::vector sample_gradient_ramp(const wxColour& first, // Resolve the curve a gradient slot is sampled with, mirroring the slicer's fallback in // ToolOrdering: a custom curve wins, otherwise a straight line between gradient_range's // endpoints, otherwise the 0.10 -> 0.90 default. -static Slic3r::GradientCurve mixed_gradient_curve(const Slic3r::DynamicPrintConfig& cfg, size_t slot) +Slic3r::GradientCurve mixed_gradient_curve(const Slic3r::DynamicPrintConfig& cfg, size_t slot) { const auto* curve_opt = cfg.option("filament_mixed_gradient_curve"); if (curve_opt && slot < curve_opt->values.size() && !curve_opt->values[slot].empty()) { @@ -449,4 +499,378 @@ void recompute_mixed_slot_colors(std::vector& colors, } } +namespace { + +// Layout ratios of the gradient plot rect, copied from GradientCurveEditor so the read-only +// preview and the interactive editor stay pixel-identical. Plot rect is square 1:1; the +// right/bottom margins host the axis arrows and labels. +constexpr double kPlotLeftRatio = 0.0316; +constexpr double kPlotRightRatio = 0.6766; +constexpr double kPlotTopRatio = 0.1529; +constexpr double kPlotBottomRatio = 0.8474; +constexpr int kGridDivisions = 9; // 10 grid lines including the outer borders. +constexpr int kStrokeAxis = 2; // axis line width (px, no DPI scaling) +constexpr int kAxisArrowHalf = 5; // half-base of the axis arrow triangle (DIP) +constexpr int kAxisArrowLen = 10; // length of the axis arrow triangle (DIP) +constexpr int kPointRadius = 4; // anchor outer radius (DIP) +constexpr float kBgSimilarThreshold = 15.0f; +constexpr int kOutlineExtraDip = 2; +constexpr double kTriangleMarginDip = 20.0; + +// Quadratic blend that never goes out of gamut, matching MixedFilamentDialog::blend_colors. +wxColour lerp_blend(const wxColour& a, const wxColour& b, double ratio_a) +{ + unsigned char r, g, bl; + Slic3r::filament_mixer_lerp(a.Red(), a.Green(), a.Blue(), + b.Red(), b.Green(), b.Blue(), + static_cast(1.0 - ratio_a), &r, &g, &bl); + return wxColour(r, g, bl); +} + +// DIP conversion for these free functions: unlike the wxWindow member FromDIP, it needs the +// window parameter explicitly; nullptr picks the app's default DPI like the Publish dialog does. +int dip_px(int v) { return wxWindow::FromDIP(v, nullptr); } + +} // namespace + +wxRect mixed_gradient_plot_rect(const wxSize& sz) +{ + const int x = static_cast(std::lround(sz.x * kPlotLeftRatio)); + const int y = static_cast(std::lround(sz.y * kPlotTopRatio)); + const int x2 = static_cast(std::lround(sz.x * kPlotRightRatio)); + const int y2 = static_cast(std::lround(sz.y * kPlotBottomRatio)); + const int side = std::max(1, std::min(x2 - x, y2 - y)); + return wxRect(x, y, side, side); +} + +void draw_mixed_gradient_plot(wxDC& raw_dc, const wxSize& canvas, + const std::vector& curves, + const std::vector& anchors, + const MixedGradientTheme& theme) +{ + // Draw into an internal opaque buffer so wxGCDC text/curves anti-alias against a solid + // background (never a transparent one), then blit the finished image onto the caller's + // buffered paint DC. wxGCDC cannot wrap a generic wxDC&, so the buffer is always a + // wxMemoryDC -- the one type wxGCDC accepts on every platform. + if (canvas.x <= 0 || canvas.y <= 0) + return; + const wxRect rc = mixed_gradient_plot_rect(canvas); + if (rc.width <= 0 || rc.height <= 0) + return; + + wxBitmap buf(canvas); + wxMemoryDC memdc(buf); + memdc.SetBackground(wxBrush(theme.background)); + memdc.Clear(); + wxGCDC dc(memdc); + wxGraphicsContext* gc = dc.GetGraphicsContext(); + + // 10x10 light grid (10 lines including outer borders, 9 equal divisions). + dc.SetPen(wxPen(theme.grid, 1)); + for (int i = 0; i <= kGridDivisions; ++i) { + const int x = rc.x + rc.width * i / kGridDivisions; + const int y = rc.y + rc.height * i / kGridDivisions; + dc.DrawLine(x, rc.y, x, rc.y + rc.height); + dc.DrawLine(rc.x, y, rc.x + rc.width, y); + } + + // Set the label font first so text width measurements drive arrow / label placement. + wxFont label_font = wxSystemSettings::GetFont(wxSYS_DEFAULT_GUI_FONT); + label_font.SetPointSize(std::max(7, label_font.GetPointSize() - 1)); + dc.SetFont(label_font); + + const wxString axis_y_title = _L("Material Ratio"); + const wxString axis_x_title = _L("Model Height"); + const wxString pct_text = wxT("100%"); + const wxSize x_title_sz = dc.GetTextExtent(axis_x_title); + const wxSize y_title_sz = dc.GetTextExtent(axis_y_title); + + wxFont strong_font = label_font; + strong_font.SetWeight(wxFONTWEIGHT_SEMIBOLD); + dc.SetFont(strong_font); + const wxSize pct_text_sz = dc.GetTextExtent(pct_text); + dc.SetFont(label_font); + + // Axes (grey 700) with filled triangle arrows. Y-axis extends above the plot top to the + // canvas top edge; X-axis extends past the plot right toward the canvas right edge. + const int arrow_half = dip_px(kAxisArrowHalf); + const int arrow_len = dip_px(kAxisArrowLen); + dc.SetPen(wxPen(theme.axis, kStrokeAxis)); + dc.SetBrush(wxBrush(theme.axis)); + + const int y_axis_x = rc.x; + const int y_title_pct_gap = dip_px(1); + const int y_title_bottom_pad = dip_px(2); + const int y_title_y = std::max(0, rc.y - y_title_sz.y - y_title_pct_gap - pct_text_sz.y - y_title_bottom_pad); + const int y_arrow_tip_y = y_title_y; + const int y_arrow_ty = y_arrow_tip_y + arrow_len; + dc.DrawLine(y_axis_x, y_arrow_ty, y_axis_x, rc.y + rc.height); + { + wxPoint tri[3] = { + wxPoint(y_axis_x, y_arrow_tip_y), + wxPoint(y_axis_x - arrow_half, y_arrow_ty), + wxPoint(y_axis_x + arrow_half, y_arrow_ty), + }; + dc.DrawPolygon(3, tri); + } + + const int x_axis_y = rc.y + rc.height; + const int x_label_gap = dip_px(4); + const int x_edge_pad = dip_px(6); + const int x_arrow_ideal = rc.x + rc.width + dip_px(10); + const int x_arrow_max = canvas.x - x_title_sz.x - x_label_gap - x_edge_pad - arrow_len; + const int x_arrow_tx = std::max(rc.x + rc.width + arrow_len, std::min(x_arrow_ideal, x_arrow_max)); + const int x_arrow_tip_x = x_arrow_tx + arrow_len; + const int x_title_x = x_arrow_tip_x + x_label_gap; + dc.DrawLine(rc.x, x_axis_y, x_arrow_tx, x_axis_y); + { + wxPoint tri[3] = { + wxPoint(x_arrow_tip_x, x_axis_y), + wxPoint(x_arrow_tx, x_axis_y - arrow_half), + wxPoint(x_arrow_tx, x_axis_y + arrow_half), + }; + dc.DrawPolygon(3, tri); + } + + // Labels: "Material Ratio" and the leading "100%" share the same left x; the trailing + // "Model Height" follows the X-axis arrow tip (already clamped to make room). + const int label_left_x = y_axis_x + dip_px(10); + dc.SetTextForeground(theme.label); + dc.DrawText(axis_y_title, label_left_x, y_title_y); + + dc.SetFont(strong_font); + dc.SetTextForeground(theme.label_strong); + dc.DrawText(pct_text, label_left_x, y_title_y + y_title_sz.y + y_title_pct_gap); + + dc.DrawText(pct_text, rc.x + rc.width - pct_text_sz.x, x_axis_y); + dc.SetFont(label_font); + dc.SetTextForeground(theme.label); + dc.DrawText(axis_x_title, x_title_x, x_axis_y - x_title_sz.y / 2); + + if (!gc) + return; + + // Outline only when the curve colour is perceptually close to the background; otherwise the + // plain filament colour reads fine and the extra stroke would look heavy. + auto needs_outline = [&](const wxColour& c) { + return calc_color_distance(c, theme.background) < kBgSimilarThreshold; + }; + + // Only the geometry goes through the graphics context: dc.DrawLines() takes integer wxPoint + // and would quantize the curve back to whole pixels. The pen is still set on the dc, which + // forwards it here while keeping its own cached state in sync for later dc drawing. + auto draw_polyline = [&](const MixedGradientCurve& curve) { + if (curve.points.size() < 2) + return; + dc.SetPen(wxPen(curve.colour, dip_px(curve.stroke_dip))); + gc->StrokeLines(curve.points.size(), curve.points.data()); + }; + + for (const MixedGradientCurve& curve : curves) { + if (needs_outline(curve.colour)) + draw_polyline({curve.points, theme.outline, curve.stroke_dip + kOutlineExtraDip}); + draw_polyline(curve); + } + + // Control points: hollow circle with axis-colour border, theme-aware fill, drawn with a + // sub-pixel centre so the ring stays centred on the curve. + if (!anchors.empty()) { + const double r = dip_px(kPointRadius); + dc.SetPen(wxPen(theme.axis, 1)); + dc.SetBrush(wxBrush(theme.point_fill)); + for (const wxPoint2DDouble& p : anchors) + gc->DrawEllipse(p.m_x - r, p.m_y - r, r * 2, r * 2); + } + + memdc.SelectObject(wxNullBitmap); + raw_dc.DrawBitmap(buf, 0, 0); +} + +void draw_mixed_ratio_blend_bar(wxDC& dc, const wxRect& rect, const wxColour& first, + const wxColour& second, double second_fraction) +{ + if (rect.width <= 0 || rect.height <= 0) + return; + for (int x = 0; x < rect.width; ++x) { + const double t = rect.width > 1 ? double(x) / rect.width : 0.0; + const wxColour c = lerp_blend(first, second, 1.0 - t); + dc.SetPen(wxPen(c)); + dc.DrawLine(rect.x + x, rect.y, rect.x + x, rect.y + rect.height); + } + + // Fixed in both themes, like the triangle picker's drag handle: the divider is drawn over + // blended filament colour, so it has to keep its contrast against data rather than chrome. + const int div_x = rect.x + static_cast(second_fraction * rect.width); + dc.SetPen(wxPen(wxColour(80, 80, 80), dip_px(4))); + dc.DrawLine(div_x, rect.y, div_x, rect.y + rect.height); + dc.SetPen(wxPen(*wxWHITE, dip_px(2))); + dc.DrawLine(div_x, rect.y, div_x, rect.y + rect.height); +} + +void draw_mixed_ratio_segments(wxDC& dc, const wxRect& rect, const std::vector& colours, + const std::vector& shares) +{ + const size_t n = std::min(colours.size(), shares.size()); + if (n == 0 || rect.width <= 0 || rect.height <= 0) + return; + std::vector norm = shares; + double total = 0.0; + for (double s : norm) + total += s; + if (total <= 0.0) { + norm.assign(n, 1.0 / n); + total = 1.0; + } + auto share_to_px = [&](double share_sum) { return rect.x + int(std::lround(share_sum / total * double(rect.width))); }; + int x0 = rect.x; + std::vector segs(n); + for (size_t i = 0; i < n; ++i) { + int x1 = rect.x + rect.width; + if (i + 1 < n) + x1 = share_to_px(std::accumulate(norm.begin(), norm.begin() + i + 1, 0.0)); + segs[i] = wxRect(x0, rect.y, std::max(1, x1 - x0), rect.height); + x0 = segs[i].GetRight() + 1; + } + for (size_t i = 0; i < n; ++i) { + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(colours[i])); + dc.DrawRectangle(segs[i]); + } + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.SetPen(wxPen(StateColor::darkModeColorFor(wxColour("#ACACAC")), 1)); + dc.DrawRectangle(rect); +} + +namespace { + +struct TriCacheKey +{ + int w, h; + int c0r, c0g, c0b, c1r, c1g, c1b, c2r, c2g, c2b; + int bg_r, bg_g, bg_b, ol_r, ol_g, ol_b; + bool operator<(const TriCacheKey& o) const + { + return std::tie(w, h, c0r, c0g, c0b, c1r, c1g, c1b, c2r, c2g, c2b, bg_r, bg_g, bg_b, ol_r, ol_g, ol_b) < + std::tie(o.w, o.h, o.c0r, o.c0g, o.c0b, o.c1r, o.c1g, o.c1b, o.c2r, o.c2g, o.c2b, o.bg_r, o.bg_g, o.bg_b, o.ol_r, o.ol_g, o.ol_b); + } +}; + +std::map& tri_cache() +{ + static std::map cache; + return cache; +} + +} // namespace + +std::array mixed_triangle_vertices(const wxSize& size, double margin_dip) +{ + const double pw = size.GetWidth(), ph = size.GetHeight(); + const double margin = dip_px(int(margin_dip)); + const double avail = std::min(pw, ph) - 2.0 * margin; + const double side = avail; + const double tri_h = side * std::sqrt(3.0) / 2.0; + const double cx = pw / 2.0; + const double top_y = (ph - tri_h) / 2.0; + return {{{cx, top_y}, {cx - side / 2.0, top_y + tri_h}, {cx + side / 2.0, top_y + tri_h}}}; +} + +void draw_mixed_triangle_picker(wxDC& dc, const wxSize& size, const std::array& colours, + const std::array& weights, const MixedTriangleTheme& theme) +{ + if (size.GetWidth() <= 0 || size.GetHeight() <= 0) + return; + const std::array v = mixed_triangle_vertices(size, kTriangleMarginDip); + + dc.SetBrush(wxBrush(theme.background)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, size.GetWidth(), size.GetHeight()); + + const wxColour& c0 = colours[0]; + const wxColour& c1 = colours[1]; + const wxColour& c2 = colours[2]; + const TriCacheKey key{size.GetWidth(), size.GetHeight(), + c0.Red(), c0.Green(), c0.Blue(), + c1.Red(), c1.Green(), c1.Blue(), + c2.Red(), c2.Green(), c2.Blue(), + theme.background.Red(), theme.background.Green(), theme.background.Blue(), + theme.outline.Red(), theme.outline.Green(), theme.outline.Blue()}; + + wxBitmap& bmp = tri_cache()[key]; + if (!bmp.IsOk()) { + bmp = wxBitmap(size.GetWidth(), size.GetHeight(), 24); + wxMemoryDC mdc(bmp); + mdc.SetBrush(wxBrush(theme.background)); + mdc.SetPen(*wxTRANSPARENT_PEN); + mdc.DrawRectangle(0, 0, size.GetWidth(), size.GetHeight()); + + const int min_y = int(std::min({v[0].y, v[1].y, v[2].y})); + const int max_y = int(std::max({v[0].y, v[1].y, v[2].y})); + const int min_x = int(std::min({v[0].x, v[1].x, v[2].x})); + const int max_x = int(std::max({v[0].x, v[1].x, v[2].x})); + for (int py = min_y; py <= max_y; ++py) { + for (int px = min_x; px <= max_x; ++px) { + const TriPoint p = {double(px), double(py)}; + if (!tri_contains(p, v[0], v[1], v[2])) + continue; + double w0, w1, w2; + tri_barycentric(p, v[0], v[1], v[2], w0, w1, w2); + unsigned char mr, mg, mb; + if (w0 + w1 > 1e-6) { + float t01 = float(w1 / (w0 + w1)); + Slic3r::filament_mixer_lerp(c0.Red(), c0.Green(), c0.Blue(), c1.Red(), c1.Green(), c1.Blue(), t01, &mr, &mg, &mb); + Slic3r::filament_mixer_lerp(mr, mg, mb, c2.Red(), c2.Green(), c2.Blue(), float(w2), &mr, &mg, &mb); + } else { + mr = c2.Red(); mg = c2.Green(); mb = c2.Blue(); + } + mdc.SetPen(wxPen(wxColour(mr, mg, mb))); + mdc.DrawPoint(px, py); + } + } + + mdc.SetPen(wxPen(theme.outline, 1)); + mdc.SetBrush(*wxTRANSPARENT_BRUSH); + const wxPoint pts[3] = {{int(v[0].x), int(v[0].y)}, {int(v[1].x), int(v[1].y)}, {int(v[2].x), int(v[2].y)}}; + mdc.DrawPolygon(3, pts); + mdc.SelectObject(wxNullBitmap); + + // Keep the cache from growing without bound across DPI/size changes. + if (tri_cache().size() > 6) { + auto& cache = tri_cache(); + cache.erase(cache.begin()); + } + } + dc.DrawBitmap(bmp, 0, 0); + + // Published-ratio marker (read-only twin of the editor's drag handle). + const double w0 = weights[0], w1 = weights[1], w2 = weights[2]; + const int hx = int(w0 * v[0].x + w1 * v[1].x + w2 * v[2].x); + const int hy = int(w0 * v[0].y + w1 * v[1].y + w2 * v[2].y); + dc.SetBrush(*wxWHITE_BRUSH); + dc.SetPen(wxPen(theme.ring, dip_px(2))); + dc.DrawCircle(hx, hy, dip_px(5)); +} + +void draw_mixed_triangle_labels(wxDC& dc, const wxSize& size, const std::array& weights, + const MixedTriangleTheme& theme) +{ + const std::array v = mixed_triangle_vertices(size, kTriangleMarginDip); + dc.SetFont(::Label::Body_12); + dc.SetTextForeground(theme.label); + + // "Ratio" title, sitting above the top vertex. + const wxString title = _L("Ratio"); + dc.DrawText(title, dip_px(2), std::max(0, int(v[0].y - dc.GetTextExtent(title).GetHeight() - dip_px(4)))); + + for (int i = 0; i < 3; ++i) { + const wxString text = wxString::Format("%d%%", int(std::lround(weights[i] * 100.0))); + const wxSize tsz = dc.GetTextExtent(text); + int lx = int(v[i].x - tsz.GetWidth() / 2.0); + int ly = (i == 0) ? int(v[i].y - tsz.GetHeight() - dip_px(4)) : int(v[i].y + dip_px(3)); + ly = std::clamp(ly, 0, size.GetHeight() - tsz.GetHeight()); + lx = std::clamp(lx, 0, size.GetWidth() - tsz.GetWidth()); + dc.DrawText(text, lx, ly); + } +} + }} // namespace Slic3r::GUI \ No newline at end of file diff --git a/src/slic3r/GUI/FilamentBitmapUtils.hpp b/src/slic3r/GUI/FilamentBitmapUtils.hpp index 11696f3401..687d34a825 100644 --- a/src/slic3r/GUI/FilamentBitmapUtils.hpp +++ b/src/slic3r/GUI/FilamentBitmapUtils.hpp @@ -5,6 +5,9 @@ #include #include #include +#include +#include +#include #include // Orca: forward-declare so the header is self-contained outside libslic3r_gui's @@ -13,6 +16,16 @@ namespace Slic3r { class DynamicPrintConfig; struct GradientCurve; } namespace Slic3r { namespace GUI { +// Barycentric utilities for a ternary (triangle) ratio picker, shared by the mixed-filament +// editor and the Publish dialog's read-only definition preview. +struct TriPoint { double x, y; }; + +double tri_signed_area2(TriPoint a, TriPoint b, TriPoint c); +bool tri_contains(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2); +void tri_barycentric(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2, + double& w0, double& w1, double& w2); +TriPoint tri_clamp(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2); + // Fills a rect with a west->east linear gradient by drawing solid 1px columns. // Use instead of wxDC::GradientFillLinear, whose CoreGraphics (CGShading) backend // fails to render on some macOS builds; solid fills are unaffected. @@ -51,6 +64,12 @@ std::vector sample_gradient_ramp(const wxColour& first, // destination's height in pixels. std::vector mixed_gradient_ramp(const Slic3r::DynamicPrintConfig& cfg, size_t slot, int steps); +// Resolve the curve a gradient slot is sampled with: the custom curve wins when it has at +// least two points, otherwise a straight line between gradient_range's endpoints, otherwise +// the 0.10 -> 0.90 default. Mirrors the slicer's ToolOrdering fallback so every preview +// agrees with what gets sliced. Always returns a two-point curve. +Slic3r::GradientCurve mixed_gradient_curve(const Slic3r::DynamicPrintConfig& cfg, size_t slot); + // Fill rect with a ramp, ramp.front() along the bottom edge. void fill_gradient_ramp_rect(wxDC& dc, const wxRect& rect, const std::vector& ramp); @@ -63,6 +82,81 @@ wxBitmap create_gradient_ramp_bitmap(const std::vector& ramp, const wx void recompute_mixed_slot_colors(std::vector& colors, const Slic3r::DynamicPrintConfig& cfg); +// --- Gradient plot (shared by GradientCurveEditor and the Publish dialog's read-only +// preview). The plot is a square 1:1 rect laid out with the editor's ratios so both +// render identically; curves are drawn as sub-pixel anti-aliased polylines through +// wxGCDC so they never quantize to whole pixels. + +// One curve of the plot: screen-space sub-pixel points already mapped into the plot +// rect, the stroke colour and the stroke width in DIP. +struct MixedGradientCurve +{ + std::vector points; + wxColour colour; + int stroke_dip; +}; + +// Theme tokens, resolved by the caller through StateColor::darkModeColorFor. +struct MixedGradientTheme +{ + wxColour background; // for near-background outline detection + wxColour grid; // grid line + wxColour axis; // axis + arrow fill + wxColour label; // "Material Ratio" / "Model Height" + wxColour label_strong; // "100%" + wxColour outline; // near-background curve lift + wxColour point_fill; // anchor fill +}; + +// Square 1:1 plot rect inside `canvas`, using the editor's plot ratios. +wxRect mixed_gradient_plot_rect(const wxSize& canvas); + +// Draw the whole plot (grid, axes + arrowheads, axis labels, each curve with an optional +// near-background outline, and anchor circles). `anchors` are empty when the caller has +// none to show. `dc` is the caller's buffered paint DC; a wxGCDC is created inside so the +// geometry gets anti-aliased. +void draw_mixed_gradient_plot(wxDC& dc, const wxSize& canvas, + const std::vector& curves, + const std::vector& anchors, + const MixedGradientTheme& theme); + +// --- Ratio bar (2-component continuous blend + divider, matching MixedFilamentDialog). +// Colours blend first->second across the rect; the divider marks `second_fraction` of the +// rect's width (the second component's share, 0..1). +void draw_mixed_ratio_blend_bar(wxDC& dc, const wxRect& rect, const wxColour& first, + const wxColour& second, double second_fraction); + +// Fallback ratio bar for N>2 non-gradient slots: one solid segment per component, +// widths proportional to shares. Label text (the "NN%" inside wide-enough segments) is +// the caller's concern. +void draw_mixed_ratio_segments(wxDC& dc, const wxRect& rect, const std::vector& colours, + const std::vector& shares); + +// --- Triangle picker (3-component), shared by MixedFilamentDialog and the Publish preview. +struct MixedTriangleTheme +{ + wxColour background; + wxColour outline; // triangle border + wxColour ring; // drag-handle ring + wxColour label; // "Ratio" title + per-vertex labels +}; + +// The three vertices of the read-only/miniature triangle inside a `size` square panel, +// with `margin_dip` inset. Order: top, bottom-left, bottom-right. +std::array mixed_triangle_vertices(const wxSize& size, double margin_dip = 20.0); + +// Draw background, the cached barycentric fill, the outline and the drag-handle marker. +// `weights` are the three barycentric shares (sum 1). The "Ratio" title and per-vertex +// percentage labels are drawn by the caller so the interactive editor can keep its own +// live child labels while the read-only preview draws them as text. +void draw_mixed_triangle_picker(wxDC& dc, const wxSize& size, const std::array& colours, + const std::array& weights, const MixedTriangleTheme& theme); + +// Draw the "Ratio" title plus one "NN%" label per vertex (used by the read-only preview; +// the interactive editor positions its own live labels instead). +void draw_mixed_triangle_labels(wxDC& dc, const wxSize& size, const std::array& weights, + const MixedTriangleTheme& theme); + }} // namespace Slic3r::GUI #endif // slic3r_GUI_FilamentBitmapUtils_hpp_ \ No newline at end of file diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 99a829bdcc..6d08f052da 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -598,7 +598,7 @@ wxString file_wildcards(FileType file_type, const std::string &custom_extension) static std::string libslic3r_translate_callback(const char *s) { return wxGetTranslation(wxString(s, wxConvUTF8)).utf8_str().data(); } #ifdef WIN32 -static GUID GUID_DEVINTERFACE_HID = { 0x4D1E55B2, 0xF16F, 0x11CF, 0x88, 0xCB, 0x00, 0x11, 0x11, 0x00, 0x00, 0x30 }; +static GUID GUID_DEVINTERFACE_HID = { 0x4D1E55B2, 0xF16F, 0x11CF, { 0x88, 0xCB, 0x00, 0x11, 0x11, 0x00, 0x00, 0x30 } }; static void register_win32_device_notification_event() { diff --git a/src/slic3r/GUI/GUI_Utils.cpp b/src/slic3r/GUI/GUI_Utils.cpp index bc66d90ffd..10dd29c9c1 100644 --- a/src/slic3r/GUI/GUI_Utils.cpp +++ b/src/slic3r/GUI/GUI_Utils.cpp @@ -69,6 +69,8 @@ CopyFileResult copy_file_gui(const std::string &from, const std::string &to, std HANDLE handlesrc = nullptr; HANDLE handledst = nullptr; CopyFileResult ret = SUCCESS; + DWORD size = 0; + DWORD dwRead = 0, dwWrite = 0; handlesrc = CreateFile(src.wc_str(), GENERIC_READ, @@ -96,9 +98,8 @@ CopyFileResult copy_file_gui(const std::string &from, const std::string &to, std goto __finished; } - DWORD size=GetFileSize(handlesrc,NULL); + size = GetFileSize(handlesrc,NULL); buff = new char[size+1]; - DWORD dwRead=0,dwWrite; result = ReadFile(handlesrc, buff, size, &dwRead, NULL); if (!result) { DWORD errCode = GetLastError(); diff --git a/src/slic3r/GUI/Gizmos/GizmoObjectManipulation.cpp b/src/slic3r/GUI/Gizmos/GizmoObjectManipulation.cpp index 7c4a2afd38..5d70fde833 100644 --- a/src/slic3r/GUI/Gizmos/GizmoObjectManipulation.cpp +++ b/src/slic3r/GUI/Gizmos/GizmoObjectManipulation.cpp @@ -702,7 +702,7 @@ bool GizmoObjectManipulation::reset_zero_button(ImGuiWrapper *imgui_wrapper, bo for (int i = 0; i < number; i++) { - char buf[3][64] = {0}; + char buf[3][64] = {}; float buf_size[3] = {0}; for (int j = 0; j < 3; j++) { ImGui::DataTypeFormatString(buf[j], IM_ARRAYSIZE(buf[j]), ImGuiDataType_Double, (void *) &vec[i][j], "%.2f"); diff --git a/src/slic3r/GUI/GradientCurveEditor.cpp b/src/slic3r/GUI/GradientCurveEditor.cpp index 5b1073d231..6d38ef81a5 100644 --- a/src/slic3r/GUI/GradientCurveEditor.cpp +++ b/src/slic3r/GUI/GradientCurveEditor.cpp @@ -1,4 +1,5 @@ #include "GradientCurveEditor.hpp" +#include "FilamentBitmapUtils.hpp" #include "GUI_App.hpp" #include "GuiColor.hpp" #include "I18N.hpp" @@ -19,23 +20,13 @@ namespace GUI { wxDEFINE_EVENT(wxEVT_GRADIENT_CURVE_CHANGED, wxCommandEvent); namespace { -// Layout ratios of the plot rect within the widget, taken from a 214 x 180 px reference drawing. -// Plot rect occupies the upper-left region; right + bottom margins host axis arrows / labels. -constexpr double kPlotLeftRatio = 0.0316; -constexpr double kPlotRightRatio = 0.6766; -constexpr double kPlotTopRatio = 0.1529; -constexpr double kPlotBottomRatio = 0.8474; -constexpr int kGridDivisions = 9; // 10 grid lines including the outer borders. - -// Hit / stroke (DIP). +// Hit / stroke (DIP). The plot-rect ratios, grid divisions, axis/arrow geometry and the +// near-background outline threshold now live in FilamentBitmapUtils so the read-only Publish +// preview and this editor stay pixel-identical. constexpr int kHitRadius = 6; constexpr int kCurveHitRadius = 5; -constexpr int kPointRadius = 4; // anchor outer radius (DIP) constexpr int kStrokeUnselected = 2; constexpr int kStrokeSelected = 4; -constexpr int kStrokeAxis = 2; // axis line width (px, no DPI scaling - matches kGridColor pen and 2DBed convention) -constexpr int kAxisArrowHalf = 5; // half-base of the axis arrow triangle (DIP) -constexpr int kAxisArrowLen = 10; // length of the axis arrow triangle (DIP) // Light-mode design tokens. Resolved through StateColor::darkModeColorFor() // at paint time so the editor follows the app theme (#EEEEEE -> #4C4C55, #6B6B6B -> @@ -46,12 +37,6 @@ const wxColour kAxisColor (107, 107, 107); // #6B6B6B grey 700 const wxColour kLabelMuted (107, 107, 107); // #6B6B6B grey 700 const wxColour kLabelStrong ( 38, 46, 48); // #262E30 grey 900 const wxColour kOutlineColor(172, 172, 172); // #ACACAC dimmed elements - -// LAB (DeltaE76) threshold for "curve color is too close to the background": below it the curve -// gets a subtle outline so it does not visually vanish, otherwise it is drawn plain. Looser than -// the 5.0 of FlushPredict::is_similar_color, so a pastel pink on white still gets an outline. -constexpr float kBgSimilarThreshold = 15.0f; -constexpr int kOutlineExtraDip = 2; } // namespace GradientCurveEditor::GradientCurveEditor(wxWindow* parent, @@ -177,15 +162,8 @@ void GradientCurveEditor::emit_changed() wxRect GradientCurveEditor::plot_rect() const { - const wxSize sz = GetClientSize(); - const int x = static_cast(std::lround(sz.x * kPlotLeftRatio)); - const int y = static_cast(std::lround(sz.y * kPlotTopRatio)); - const int x2 = static_cast(std::lround(sz.x * kPlotRightRatio)); - const int y2 = static_cast(std::lround(sz.y * kPlotBottomRatio)); - // Force square 1:1 so X/Y axes share the same scale and grid cells stay square. Anchor at - // the top-left so the "100%" labels on the bottom/right still align with the plot edges. - const int side = std::max(1, std::min(x2 - x, y2 - y)); - return wxRect(x, y, side, side); + // Square 1:1 plot, shared with the Publish dialog's read-only preview. + return mixed_gradient_plot_rect(GetClientSize()); } wxPoint2DDouble GradientCurveEditor::data_to_px_f(double x, double y) const @@ -330,171 +308,52 @@ void GradientCurveEditor::on_paint(wxPaintEvent& /*evt*/) raw_dc.SetBackground(wxBrush(bg)); raw_dc.Clear(); - // Render through wxGCDC so curves, arrows and anchor circles get anti-aliased; the buffered - // DC is the actual back buffer that gets blitted to the window. - wxGCDC dc(raw_dc); - // The curve and its anchors are drawn straight on the graphics context so their - // coordinates stay sub-pixel accurate (see data_to_px_f). - wxGraphicsContext* gc = dc.GetGraphicsContext(); - - const wxRect rc = plot_rect(); - if (rc.width <= 0 || rc.height <= 0) - return; - - // 10x10 light grid (10 lines including outer borders, 9 equal divisions). - dc.SetPen(wxPen(grid_color, 1)); - for (int i = 0; i <= kGridDivisions; ++i) { - const int x = rc.x + rc.width * i / kGridDivisions; - const int y = rc.y + rc.height * i / kGridDivisions; - dc.DrawLine(x, rc.y, x, rc.y + rc.height); - dc.DrawLine(rc.x, y, rc.x + rc.width, y); - } - - // Set the label font first so text width measurements drive arrow / label placement. - wxFont label_font = wxSystemSettings::GetFont(wxSYS_DEFAULT_GUI_FONT); - label_font.SetPointSize(std::max(7, label_font.GetPointSize() - 1)); - dc.SetFont(label_font); - - const wxString axis_y_title = _L("Material Ratio"); - const wxString axis_x_title = _L("Model Height"); - const wxString pct_text = wxT("100%"); - const wxSize x_title_sz = dc.GetTextExtent(axis_x_title); - const wxSize y_title_sz = dc.GetTextExtent(axis_y_title); - - wxFont strong_font = label_font; - strong_font.SetWeight(wxFONTWEIGHT_SEMIBOLD); - dc.SetFont(strong_font); - const wxSize pct_text_sz = dc.GetTextExtent(pct_text); - dc.SetFont(label_font); - - // Axes (grey 700) with filled triangle arrows. Y-axis extends above the plot top to the - // canvas top edge; X-axis extends past the plot right toward the canvas right edge. - const int arrow_half = FromDIP(kAxisArrowHalf); - const int arrow_len = FromDIP(kAxisArrowLen); - const wxSize sz = GetClientSize(); - dc.SetPen(wxPen(axis_color, kStrokeAxis)); - dc.SetBrush(wxBrush(axis_color)); - - // Y-axis: vertical line at plot_left, from arrow tip near canvas top down to plot bottom. - const int y_axis_x = rc.x; - const int y_title_pct_gap = FromDIP(1); - const int y_title_bottom_pad = FromDIP(2); - const int y_title_y = std::max(0, rc.y - y_title_sz.y - y_title_pct_gap - pct_text_sz.y - y_title_bottom_pad); - const int y_arrow_tip_y = y_title_y; - const int y_arrow_ty = y_arrow_tip_y + arrow_len; - dc.DrawLine(y_axis_x, y_arrow_ty, y_axis_x, rc.y + rc.height); - { - wxPoint tri[3] = { - wxPoint(y_axis_x, y_arrow_tip_y), - wxPoint(y_axis_x - arrow_half, y_arrow_ty), - wxPoint(y_axis_x + arrow_half, y_arrow_ty), + // Render the plot (grid, axes, labels, curves, anchors) through the shared painter so the + // interactive editor and the Publish dialog's read-only preview stay pixel-identical. The + // curves are handed over as sub-pixel polylines and anti-alias inside the helper. + std::vector curves; + std::vector anchors; + if (m_points.size() >= 2) { + auto color_for_curve = [&](int curve_idx) -> wxColour { + wxColour c = (curve_idx == 0) ? m_color_low : m_color_high; + // Transparent filaments (alpha == 0, e.g. #FFFFFF00) would be invisible. + if (c.Alpha() == 0) + c.Set(c.Red(), c.Green(), c.Blue(), 150); + return c; }; - dc.DrawPolygon(3, tri); - } - // X-axis arrow tip: stays just past the plot ideally, but is clamped so the trailing - // "Material Ratio" label still fits inside the canvas without overlapping the arrow. - const int x_axis_y = rc.y + rc.height; - const int x_label_gap = FromDIP(4); - const int x_edge_pad = FromDIP(6); - const int x_arrow_ideal = rc.x + rc.width + FromDIP(10); - const int x_arrow_max = sz.x - x_title_sz.x - x_label_gap - x_edge_pad - arrow_len; - const int x_arrow_tx = std::max(rc.x + rc.width + arrow_len, - std::min(x_arrow_ideal, x_arrow_max)); - const int x_arrow_tip_x = x_arrow_tx + arrow_len; - const int x_title_x = x_arrow_tip_x + x_label_gap; - dc.DrawLine(rc.x, x_axis_y, x_arrow_tx, x_axis_y); - { - wxPoint tri[3] = { - wxPoint(x_arrow_tip_x, x_axis_y), - wxPoint(x_arrow_tx, x_axis_y - arrow_half), - wxPoint(x_arrow_tx, x_axis_y + arrow_half), + auto build_polyline = [&](int curve_idx) -> std::vector { + const int samples = std::max(128, plot_rect().width * 2); + std::vector poly; + poly.reserve(samples + 1); + for (int s = 0; s <= samples; ++s) { + const double x = double(s) / samples; + const double y0 = sample_curve_y(x); + const double vy = to_visual_y(curve_idx, y0); + poly.push_back(data_to_px_f(x, vy)); + } + return poly; }; - dc.DrawPolygon(3, tri); - } - // Labels. - // "Model Height" and "100%" share the same left x; the gap is larger than the - // axis-arrow half-base so the text never visually touches the Y-axis arrow. - const int label_left_x = y_axis_x + FromDIP(10); - dc.SetTextForeground(label_muted); - dc.DrawText(axis_y_title, label_left_x, y_title_y); - - dc.SetFont(strong_font); - dc.SetTextForeground(label_strong); - dc.DrawText(pct_text, label_left_x, y_title_y + y_title_sz.y + y_title_pct_gap); - - // Bottom-right "100%" sits under the right end of the plot; "Material Ratio" follows the - // X-axis arrow tip (placement was already clamped above to leave room). - dc.DrawText(pct_text, rc.x + rc.width - pct_text_sz.x, x_axis_y); - dc.SetFont(label_font); - dc.SetTextForeground(label_muted); - dc.DrawText(axis_x_title, x_title_x, x_axis_y - x_title_sz.y / 2); - - if (m_points.size() < 2 || !gc) - return; - - auto color_for_curve = [&](int curve_idx) -> wxColour { - wxColour c = (curve_idx == 0) ? m_color_low : m_color_high; - // Transparent filaments (alpha == 0, e.g. #FFFFFF00) would be invisible. - // Lift alpha so the curve stays visible while still hinting at transparency. - if (c.Alpha() == 0) - c.Set(c.Red(), c.Green(), c.Blue(), 150); - return c; - }; - - auto build_polyline = [&](int curve_idx) -> std::vector { - const int samples = std::max(128, rc.width * 2); - std::vector poly; - poly.reserve(samples + 1); - for (int s = 0; s <= samples; ++s) { - const double x = double(s) / samples; - const double y0 = sample_curve_y(x); - const double vy = to_visual_y(curve_idx, y0); - poly.push_back(data_to_px_f(x, vy)); + // Draw unselected first so the selected curve sits on top. + const int other = 1 - m_selected_curve; + for (const int idx : {other, m_selected_curve}) { + std::vector pts = build_polyline(idx); + if (pts.empty()) + continue; + curves.push_back({std::move(pts), color_for_curve(idx), idx == m_selected_curve ? kStrokeSelected : kStrokeUnselected}); } - return poly; - }; - // Only the geometry goes through the graphics context: dc.DrawLines() takes integer wxPoint - // and would quantize the curve back to whole pixels. The pen is still set on the dc, which - // forwards it here while keeping its own cached state in sync for later dc drawing. - auto draw_polyline = [&](const std::vector& poly, const wxColour& col, int stroke_dip) { - dc.SetPen(wxPen(col, FromDIP(stroke_dip))); - gc->StrokeLines(poly.size(), poly.data()); - }; - - // Outline only when the curve color is perceptually close to the background; otherwise - // the plain filament color reads fine and the extra stroke would look heavy. - auto needs_outline = [&](const wxColour& c) { - return calc_color_distance(c, bg) < kBgSimilarThreshold; - }; - - auto draw_one = [&](int curve_idx, int stroke_dip) { - const auto poly = build_polyline(curve_idx); - const wxColour col = color_for_curve(curve_idx); - if (needs_outline(col)) - draw_polyline(poly, outline_color, stroke_dip + kOutlineExtraDip); - draw_polyline(poly, col, stroke_dip); - }; - - // Draw unselected first so the selected curve sits on top. - const int other = 1 - m_selected_curve; - draw_one(other, kStrokeUnselected); - draw_one(m_selected_curve, kStrokeSelected); - - // Control points (selected curve only): hollow circle with axis-color border, theme-aware fill. - // Drawn on the graphics context with a sub-pixel center so the ring stays centered on the - // curve instead of drifting up to half a pixel off it; pen and brush go through the dc for - // the same reason as in draw_polyline above. - const double r = FromDIP(kPointRadius); - dc.SetPen(wxPen(axis_color, 1)); - dc.SetBrush(wxBrush(point_fill)); - for (size_t i = 0; i < m_points.size(); ++i) { - const double vy = to_visual_y(m_selected_curve, m_points[i].y); - const wxPoint2DDouble p = data_to_px_f(m_points[i].x, vy); - gc->DrawEllipse(p.m_x - r, p.m_y - r, r * 2, r * 2); + // Control points (selected curve only). + anchors.reserve(m_points.size()); + for (size_t i = 0; i < m_points.size(); ++i) { + const double vy = to_visual_y(m_selected_curve, m_points[i].y); + anchors.push_back(data_to_px_f(m_points[i].x, vy)); + } } + + const MixedGradientTheme theme{bg, grid_color, axis_color, label_muted, label_strong, outline_color, point_fill}; + draw_mixed_gradient_plot(raw_dc, GetClientSize(), curves, anchors, theme); } void GradientCurveEditor::on_left_down(wxMouseEvent& evt) diff --git a/src/slic3r/GUI/IMSlider.cpp b/src/slic3r/GUI/IMSlider.cpp index c008963646..0d0d6739f8 100644 --- a/src/slic3r/GUI/IMSlider.cpp +++ b/src/slic3r/GUI/IMSlider.cpp @@ -790,7 +790,7 @@ void IMSlider::draw_ticks(const ImRect& slideable_region) { void IMSlider::show_tooltip(const std::string tooltip) { ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, { 6 * m_scale, 3 * m_scale }); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, { 3 * m_scale }); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 3 * m_scale); ImGui::PushStyleColor(ImGuiCol_PopupBg, ImGuiWrapper::COL_WINDOW_BACKGROUND); ImGui::PushStyleColor(ImGuiCol_Border, { 0,0,0,0 }); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.00f, 1.00f, 1.00f, 1.00f)); diff --git a/src/slic3r/GUI/KBShortcutsDialog.cpp b/src/slic3r/GUI/KBShortcutsDialog.cpp index ed513c425f..2f882e35aa 100644 --- a/src/slic3r/GUI/KBShortcutsDialog.cpp +++ b/src/slic3r/GUI/KBShortcutsDialog.cpp @@ -174,6 +174,7 @@ void KBShortcutsDialog::fill_shortcuts() { ctrl + "O", L("Open Project") }, { ctrl + "S", L("Save Project") }, { ctrl + shift + "S", L("Save Project as")}, + { ctrl + shift + "E", L("Publish 3MF") }, // File>Import { ctrl + "I", L("Import geometry data from STL/STEP/3MF/OBJ/AMF files") }, // File>Export diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index aeed034b0f..c734804c22 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -39,7 +39,6 @@ #include "Plater.hpp" #include "WebViewDialog.hpp" #include "../Utils/Process.hpp" -#include "format.hpp" // BBS #include "PartPlate.hpp" #include "Preferences.hpp" @@ -49,11 +48,9 @@ #include "../Utils/NetworkAgentFactory.hpp" #include "../Utils/PrintHost.hpp" -#include -#include - #include "GUI_App.hpp" #include "UnsavedChangesDialog.hpp" +#include "PublishSettingsDialog.hpp" #include "MsgDialog.hpp" #include "Notebook.hpp" #include "GUI_Factories.hpp" @@ -743,6 +740,10 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_ if (m_plater) { m_plater->add_file(); } return; } + if (evt.CmdDown() && evt.ShiftDown() && evt.GetKeyCode() == 'E') { + if (can_export_model()) publish_project(); + return; + } evt.Skip(); }); @@ -1590,7 +1591,7 @@ void MainFrame::register_win32_callbacks() //static GUID GUID_DEVINTERFACE_USB_DEVICE = { 0xA5DCBF10, 0x6530, 0x11D2, 0x90, 0x1F, 0x00, 0xC0, 0x4F, 0xB9, 0x51, 0xED }; //static GUID GUID_DEVINTERFACE_DISK = { 0x53f56307, 0xb6bf, 0x11d0, 0x94, 0xf2, 0x00, 0xa0, 0xc9, 0x1e, 0xfb, 0x8b }; //static GUID GUID_DEVINTERFACE_VOLUME = { 0x71a27cdd, 0x812a, 0x11d0, 0xbe, 0xc7, 0x08, 0x00, 0x2b, 0xe2, 0x09, 0x2f }; - static GUID GUID_DEVINTERFACE_HID = { 0x4D1E55B2, 0xF16F, 0x11CF, 0x88, 0xCB, 0x00, 0x11, 0x11, 0x00, 0x00, 0x30 }; + static GUID GUID_DEVINTERFACE_HID = { 0x4D1E55B2, 0xF16F, 0x11CF, { 0x88, 0xCB, 0x00, 0x11, 0x11, 0x00, 0x00, 0x30 } }; // Register USB HID (Human Interface Devices) notifications to trigger the 3DConnexion enumeration. DEV_BROADCAST_DEVICEINTERFACE NotificationFilter = { 0 }; @@ -1630,7 +1631,7 @@ void MainFrame::register_win32_callbacks() { static constexpr int device_count = 1; - RAWINPUTDEVICE devices[device_count] = { 0 }; + RAWINPUTDEVICE devices[device_count] = {}; // multi-axis mouse (SpaceNavigator, etc.) devices[0].usUsagePage = 0x01; devices[0].usUsage = 0x08; @@ -1739,6 +1740,22 @@ bool MainFrame::save_project_as(const wxString& filename) return ret; } +void MainFrame::publish_project() +{ + if (m_plater == nullptr) + return; + // Seed the dialog from the session selection (a remembered state or a freshly loaded + // published 3MF); a null pointer means "fresh", keeping the dirty defaults. + std::vector pending_keys; + std::vector pending_material; + const bool has_prior = m_plater->get_pending_published(pending_keys, pending_material); + PublishSettingsDialog dlg(this, has_prior ? &pending_keys : nullptr, has_prior ? &pending_material : nullptr); + if (dlg.ShowModal() != wxID_OK) + return; + m_plater->set_pending_published(dlg.GetPublishedKeys(), dlg.GetPublishedMaterialKeys()); + m_plater->export_published_3mf(dlg.GetPublishedKeys(), dlg.GetPublishedMaterialKeys()); +} + bool MainFrame::can_upload() const { return true; @@ -2834,6 +2851,20 @@ void MainFrame::init_menubar_as_editor() [this](){return m_plater != nullptr && can_save_as(); }, this); #endif + // BBS: publish + fileMenu->AppendSeparator(); + auto publish_handler = [this](wxCommandEvent&) { publish_project(); }; + +#ifndef __APPLE__ + append_menu_item(fileMenu, wxID_ANY, _L("Publish 3MF") + dots + "\t" + ctrl + shift + "E", _L("Export a 3MF file with the selected settings embedded"), + publish_handler, "menu_publish", nullptr, + [this](){return can_export_model(); }, this); +#else + append_menu_item(fileMenu, wxID_ANY, _L("Publish 3MF") + dots + "\t" + ctrl + shift + "E", _L("Export a 3MF file with the selected settings embedded"), + publish_handler, "", nullptr, + [this](){return can_export_model(); }, this); +#endif + fileMenu->AppendSeparator(); @@ -4212,15 +4243,23 @@ std::wstring MainFrame::FileHistory::GetThumbnailUrl(int index) const return wss.str(); } +bool MainFrame::FileHistory::GetPublished(int index) const +{ + return index >= 0 && index < static_cast(m_published_files.size()) && m_published_files[index]; +} + void MainFrame::FileHistory::AddFileToHistory(const wxString &file) { if (this->m_fileMaxFiles == 0) return; wxFileHistory::AddFileToHistory(file); - if (m_load_called) + if (m_load_called) { m_thumbnails.push_front(bbs_3mf_get_thumbnail(into_u8(file).c_str())); - else + m_published_files.push_front(bbs_3mf_is_published(into_u8(file))); + } else { m_thumbnails.push_front(""); + m_published_files.push_front(false); + } } void MainFrame::FileHistory::RemoveFileFromHistory(size_t i) @@ -4229,6 +4268,7 @@ void MainFrame::FileHistory::RemoveFileFromHistory(size_t i) return; wxFileHistory::RemoveFileFromHistory(i); m_thumbnails.erase(m_thumbnails.begin() + i); + m_published_files.erase(m_published_files.begin() + i); } size_t MainFrame::FileHistory::FindFileInHistory(const wxString & file) @@ -4244,6 +4284,7 @@ void MainFrame::FileHistory::LoadThumbnails() if (!thumbnail.empty()) { m_thumbnails[i] = thumbnail; } + m_published_files[i] = bbs_3mf_is_published(into_u8(GetHistoryFile(i))); } }); m_load_called = true; @@ -4264,6 +4305,7 @@ void MainFrame::get_recent_projects(boost::property_tree::wptree &tree, int imag std::wstring proj = m_recent_projects.GetHistoryFile(i).ToStdWstring(); item.put(L"project_name", proj.substr(proj.find_last_of(L"/\\") + 1)); item.put(L"path", proj); + item.put(L"published", m_recent_projects.GetPublished(i) ? L"1" : L"0"); boost::system::error_code ec; std::time_t t = boost::filesystem::last_write_time(proj, ec); if (!ec) { diff --git a/src/slic3r/GUI/MainFrame.hpp b/src/slic3r/GUI/MainFrame.hpp index b8b7ef2e7d..44e0547f50 100644 --- a/src/slic3r/GUI/MainFrame.hpp +++ b/src/slic3r/GUI/MainFrame.hpp @@ -178,6 +178,7 @@ class MainFrame : public DPIFrame { FileHistory(int max) : wxFileHistory(max) {} std::wstring GetThumbnailUrl(int index) const; + bool GetPublished(int index) const; virtual void AddFileToHistory(const wxString &file); virtual void RemoveFileFromHistory(size_t i); @@ -188,6 +189,7 @@ class MainFrame : public DPIFrame void SetMaxFiles(int max); private: std::deque m_thumbnails; + std::deque m_published_files; // parallel to m_thumbnails: is it a published 3mf? bool m_load_called = false; }; @@ -341,6 +343,8 @@ public: bool can_upload() const; void save_project(); bool save_project_as(const wxString& filename = wxString()); + // Open the Publish dialog and export the selected settings as a published 3MF. + void publish_project(); void add_to_recent_projects(const wxString& filename); void get_recent_projects(boost::property_tree::wptree &tree, int images); diff --git a/src/slic3r/GUI/MixedFilamentDialog.cpp b/src/slic3r/GUI/MixedFilamentDialog.cpp index a0e2eaeaf7..8176a76202 100644 --- a/src/slic3r/GUI/MixedFilamentDialog.cpp +++ b/src/slic3r/GUI/MixedFilamentDialog.cpp @@ -845,23 +845,8 @@ wxBoxSizer* MixedFilamentDialog::create_ratio_slider() m_ratio_bar->Bind(wxEVT_PAINT, [this](wxPaintEvent&) { wxBufferedPaintDC dc(m_ratio_bar); wxSize sz = m_ratio_bar->GetClientSize(); - - wxColour col_a = comp_colour(0), col_b = comp_colour(1); - - for (int x = 0; x < sz.GetWidth(); ++x) { - double t = (double)x / sz.GetWidth(); - wxColour c = blend_colors(col_a, col_b, 1.0 - t); - dc.SetPen(wxPen(c)); - dc.DrawLine(x, 0, x, sz.GetHeight()); - } - - int div_x = (int)(ratio(1) / 100.0 * sz.GetWidth()); - // Fixed in both themes, like the triangle picker's drag handle: the divider is drawn over - // blended filament colour, so it has to keep its contrast against data rather than chrome. - dc.SetPen(wxPen(wxColour(80, 80, 80), FromDIP(4))); - dc.DrawLine(div_x, 0, div_x, sz.GetHeight()); - dc.SetPen(wxPen(*wxWHITE, FromDIP(2))); - dc.DrawLine(div_x, 0, div_x, sz.GetHeight()); + draw_mixed_ratio_blend_bar(dc, wxRect(0, 0, sz.GetWidth(), sz.GetHeight()), + comp_colour(0), comp_colour(1), ratio(1) / 100.0); }); m_ratio_bar->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& e) { @@ -917,52 +902,8 @@ wxBoxSizer* MixedFilamentDialog::create_ratio_slider() } // ---- Triangle (ternary) ratio picker ---- - -// Barycentric coordinate utilities -struct TriPoint { double x, y; }; - -static double tri_signed_area2(TriPoint a, TriPoint b, TriPoint c) -{ - return (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y); -} - -static bool tri_contains(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2) -{ - double total = tri_signed_area2(v0, v1, v2); - if (std::abs(total) < 1e-9) return false; - double s0 = tri_signed_area2(p, v1, v2) / total; - double s1 = tri_signed_area2(v0, p, v2) / total; - double s2 = 1.0 - s0 - s1; - return s0 >= -0.001 && s1 >= -0.001 && s2 >= -0.001; -} - -static void tri_barycentric(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2, - double& w0, double& w1, double& w2) -{ - double total = std::abs(tri_signed_area2(v0, v1, v2)); - if (total < 1e-9) { w0 = w1 = w2 = 1.0 / 3.0; return; } - w0 = std::abs(tri_signed_area2(p, v1, v2)) / total; - w1 = std::abs(tri_signed_area2(v0, p, v2)) / total; - w2 = 1.0 - w0 - w1; - w0 = std::clamp(w0, 0.0, 1.0); - w1 = std::clamp(w1, 0.0, 1.0); - w2 = std::clamp(w2, 0.0, 1.0); - double s = w0 + w1 + w2; - if (s > 0) { w0 /= s; w1 /= s; w2 /= s; } -} - -static TriPoint tri_clamp(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2) -{ - double w0, w1, w2; - tri_barycentric(p, v0, v1, v2, w0, w1, w2); - w0 = std::clamp(w0, 0.0, 1.0); - w1 = std::clamp(w1, 0.0, 1.0); - w2 = std::clamp(w2, 0.0, 1.0); - double s = w0 + w1 + w2; - if (s > 0) { w0 /= s; w1 /= s; w2 /= s; } - return {w0 * v0.x + w1 * v1.x + w2 * v2.x, - w0 * v0.y + w1 * v1.y + w2 * v2.y}; -} +// The barycentric utilities (TriPoint, tri_contains, tri_barycentric, tri_clamp) live in +// FilamentBitmapUtils so the Publish dialog can mirror this picker read-only. wxBoxSizer* MixedFilamentDialog::create_triangle_picker() { @@ -996,72 +937,15 @@ wxBoxSizer* MixedFilamentDialog::create_triangle_picker() wxSize sz = m_triangle_panel->GetClientSize(); auto [v0, v1, v2] = get_vertices(); - wxColour tri_bg = StateColor::darkModeColorFor(*wxWHITE); - dc.SetBrush(wxBrush(tri_bg)); - dc.SetPen(*wxTRANSPARENT_PEN); - dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); - - wxColour c0 = comp_colour(0), c1 = comp_colour(1), c2 = comp_colour(2); - - const bool cache_valid = m_tri_cache_bmp.IsOk() && - m_tri_cache_size == sz && - m_tri_cache_c0 == c0 && m_tri_cache_c1 == c1 && m_tri_cache_c2 == c2; - - if (!cache_valid) { - int min_y = (int)std::min({v0.y, v1.y, v2.y}); - int max_y = (int)std::max({v0.y, v1.y, v2.y}); - int min_x = (int)std::min({v0.x, v1.x, v2.x}); - int max_x = (int)std::max({v0.x, v1.x, v2.x}); - - m_tri_cache_bmp = wxBitmap(sz.GetWidth(), sz.GetHeight(), 24); - wxMemoryDC mdc(m_tri_cache_bmp); - mdc.SetBrush(wxBrush(tri_bg)); - mdc.SetPen(*wxTRANSPARENT_PEN); - mdc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); - - for (int py = min_y; py <= max_y; ++py) { - for (int px = min_x; px <= max_x; ++px) { - TriPoint p = {(double)px, (double)py}; - if (!tri_contains(p, v0, v1, v2)) continue; - double w0, w1, w2; - tri_barycentric(p, v0, v1, v2, w0, w1, w2); - unsigned char mr, mg, mb; - if (w0 + w1 > 1e-6) { - float t01 = static_cast(w1 / (w0 + w1)); - Slic3r::filament_mixer_lerp(c0.Red(), c0.Green(), c0.Blue(), - c1.Red(), c1.Green(), c1.Blue(), - t01, &mr, &mg, &mb); - float t2 = static_cast(w2); - Slic3r::filament_mixer_lerp(mr, mg, mb, - c2.Red(), c2.Green(), c2.Blue(), - t2, &mr, &mg, &mb); - } else { - mr = c2.Red(); mg = c2.Green(); mb = c2.Blue(); - } - mdc.SetPen(wxPen(wxColour(mr, mg, mb))); - mdc.DrawPoint(px, py); - } - } - - mdc.SetPen(wxPen(StateColor::darkModeColorFor(wxColour("#CECECE")), 1)); - mdc.SetBrush(*wxTRANSPARENT_BRUSH); - wxPoint pts[3] = {{(int)v0.x, (int)v0.y}, {(int)v1.x, (int)v1.y}, {(int)v2.x, (int)v2.y}}; - mdc.DrawPolygon(3, pts); - - mdc.SelectObject(wxNullBitmap); - m_tri_cache_c0 = c0; m_tri_cache_c1 = c1; m_tri_cache_c2 = c2; - m_tri_cache_size = sz; - } - - dc.DrawBitmap(m_tri_cache_bmp, 0, 0); - - // Drag handle (always redrawn on top of cached bitmap) - double hx = m_tri_wx * v0.x + m_tri_wy * v1.x + m_tri_wz * v2.x; - double hy = m_tri_wx * v0.y + m_tri_wy * v1.y + m_tri_wz * v2.y; - int handle_r = FromDIP(5); - dc.SetBrush(*wxWHITE_BRUSH); - dc.SetPen(wxPen(wxColour("#262E30"), FromDIP(2))); - dc.DrawCircle((int)hx, (int)hy, handle_r); + // Draw the background, cached barycentric fill, outline and drag-handle marker through the + // shared picker painter (same geometry the read-only Publish preview uses). + draw_mixed_triangle_picker(dc, sz, + {comp_colour(0), comp_colour(1), comp_colour(2)}, + {m_tri_wx, m_tri_wy, m_tri_wz}, + {StateColor::darkModeColorFor(*wxWHITE), + StateColor::darkModeColorFor(wxColour("#CECECE")), + StateColor::darkModeColorFor(wxColour("#262E30")), + StateColor::darkModeColorFor(COLOR_LABEL_MUTED)}); if (m_result.ratios.size() >= 3) { dc.SetFont(::Label::Body_10); diff --git a/src/slic3r/GUI/MixedFilamentDialog.hpp b/src/slic3r/GUI/MixedFilamentDialog.hpp index 45b297da4a..381896494e 100644 --- a/src/slic3r/GUI/MixedFilamentDialog.hpp +++ b/src/slic3r/GUI/MixedFilamentDialog.hpp @@ -169,10 +169,6 @@ private: // Triangle picker drag point (barycentric weights) double m_tri_wx{0.333}, m_tri_wy{0.333}, m_tri_wz{0.334}; - // Cached triangle color bitmap (invalidated when colors or size change) - wxBitmap m_tri_cache_bmp; - wxColour m_tri_cache_c0, m_tri_cache_c1, m_tri_cache_c2; - wxSize m_tri_cache_size; std::array m_triangle_ratio_labels{nullptr, nullptr, nullptr}; }; diff --git a/src/slic3r/GUI/NotificationManager.cpp b/src/slic3r/GUI/NotificationManager.cpp index 5e83ad845f..2ee875901c 100644 --- a/src/slic3r/GUI/NotificationManager.cpp +++ b/src/slic3r/GUI/NotificationManager.cpp @@ -3083,7 +3083,7 @@ bool NotificationManager::push_notification_data(std::unique_ptractivate_existing(notification.get())) { - if (m_initialized) { // ignore update action - it cant be initialized if canvas and imgui context is not ready + if (m_initialized && m_imgui_ready) { if (notification->get_type() == NotificationType::SlicingWarning) { m_pop_notifications.back()->append(notification->get_data().ori_text); } else { @@ -3129,6 +3129,10 @@ void NotificationManager::stop_delayed_notifications_of_type(const NotificationT void NotificationManager::render_notifications(GLCanvas3D &canvas, float overlay_width, float bottom_margin, float right_margin) { + // Notifications render inside an ImGui frame, so the font atlas is built from this point on + // and pushed notifications may safely measure their text. + m_imgui_ready = true; + sort_notifications(); float bottom_up_last_y = bottom_margin; // ORCA dont scale margins @@ -3339,17 +3343,7 @@ size_t NotificationManager::get_notification_count() const void NotificationManager::bbl_show_plateinfo_notification(const std::string &text) { NotificationData data{NotificationType::BBLPlateInfo, NotificationLevel::PrintInfoNotificationLevel, BBL_NOTICE_MAX_INTERVAL, text}; - - for (std::unique_ptr ¬ification : m_pop_notifications) { - if (notification->get_type() == NotificationType::BBLPlateInfo) { - notification->reinit(); - notification->update(data); - return; - } - } - - auto notification = std::make_unique(data, m_id_provider, m_evt_handler); - push_notification_data(std::move(notification), 0); + push_notification_data(data, 0); } void NotificationManager::bbl_close_3mf_warn_notification() @@ -3360,20 +3354,10 @@ void NotificationManager::bbl_close_3mf_warn_notification() } } -void NotificationManager::bbl_show_3mf_warn_notification(const std::string &text) +void NotificationManager::bbl_show_3mf_warn_notification(const std::string &text, NotificationLevel level) { - NotificationData data{NotificationType::BBL3MFInfo, NotificationLevel::ErrorNotificationLevel, BBL_NOTICE_MAX_INTERVAL, text}; - - for (std::unique_ptr ¬ification : m_pop_notifications) { - if (notification->get_type() == NotificationType::BBL3MFInfo) { - notification->reinit(); - notification->update(data); - return; - } - } - - auto notification = std::make_unique(data, m_id_provider, m_evt_handler); - push_notification_data(std::move(notification), 0); + NotificationData data{NotificationType::BBL3MFInfo, level, BBL_NOTICE_MAX_INTERVAL, text}; + push_notification_data(data, 0); } void NotificationManager::bbl_close_plateinfo_notification() @@ -3388,17 +3372,7 @@ void NotificationManager::bbl_close_plateinfo_notification() void NotificationManager::bbl_show_preview_only_notification(const std::string &text) { NotificationData data{NotificationType::BBLPreviewOnlyMode, NotificationLevel::WarningNotificationLevel, 0, text}; - - for (std::unique_ptr ¬ification : m_pop_notifications) { - if (notification->get_type() == NotificationType::BBLPreviewOnlyMode) { - notification->reinit(); - notification->update(data); - return; - } - } - - auto notification = std::make_unique(data, m_id_provider, m_evt_handler); - push_notification_data(std::move(notification), 0); + push_notification_data(data, 0); } void NotificationManager::bbl_close_preview_only_notification() diff --git a/src/slic3r/GUI/NotificationManager.hpp b/src/slic3r/GUI/NotificationManager.hpp index bf0a5cfe1a..cb65524dac 100644 --- a/src/slic3r/GUI/NotificationManager.hpp +++ b/src/slic3r/GUI/NotificationManager.hpp @@ -378,7 +378,9 @@ public: void bbl_close_plateinfo_notification(); //BBS-- 3mf warning - void bbl_show_3mf_warn_notification(const std::string &text); + // level defaults to the historical error styling; callers reporting informational + // 3MF load notices (published settings) pass WarningNotificationLevel instead. + void bbl_show_3mf_warn_notification(const std::string &text, NotificationLevel level = NotificationLevel::ErrorNotificationLevel); void bbl_close_3mf_warn_notification(); //BBS--preview only mode @@ -1052,6 +1054,11 @@ private: bool m_is_dark = false; // set by init(), until false notifications are only added not updated and frame is not requested after push bool m_initialized{ false }; + // set by render_notifications() on the first rendered frame. m_initialized only proves the + // manager exists, not that the ImGui context can measure text: the font atlas is built lazily + // in ImGuiWrapper::new_frame() on the first GL render, so updating a notification before that + // (PopNotification::init -> count_spaces -> ImGui::CalcTextSize) dereferences a null font. + bool m_imgui_ready{ false }; // Target for wxWidgets events sent by clicking on the hyperlink available at some notifications. wxEvtHandler* m_evt_handler; // Cache of IDs to identify and reuse ImGUI windows. @@ -1076,7 +1083,10 @@ private: NotificationType::ProgressBar, NotificationType::PrintHostUpload, NotificationType::SimplifySuggestion, - NotificationType::ValidateWarning + NotificationType::ValidateWarning, + // A published file load can produce several distinct 3MF warnings (invalid values, + // skipped settings, changed slots); let them stack rather than clobber each other. + NotificationType::BBL3MFInfo }; //prepared (basic) notifications // non-static so its not loaded too early. If static, the translations wont load correctly. diff --git a/src/slic3r/GUI/ParamsPanel.hpp b/src/slic3r/GUI/ParamsPanel.hpp index 0726db91d3..91bf3d2a7e 100644 --- a/src/slic3r/GUI/ParamsPanel.hpp +++ b/src/slic3r/GUI/ParamsPanel.hpp @@ -66,7 +66,6 @@ class ParamsPanel : public wxPanel { #if __WXOSX__ wxWindow* m_tmp_panel; - int m_size_move = -1; #endif // __WXOSX__ private: diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 90e2c96ab6..9826498fbd 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -1112,7 +1112,7 @@ void PartPlate::show_tooltip(const std::string tooltip) { const auto scale = m_plater->get_current_canvas3D()->get_scale(); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {6 * scale, 3 * scale}); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, {3 * scale}); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 3 * scale); ImGui::PushStyleColor(ImGuiCol_PopupBg, ImGuiWrapper::COL_WINDOW_BACKGROUND); ImGui::PushStyleColor(ImGuiCol_Border, {0, 0, 0, 0}); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.00f, 1.00f, 1.00f, 1.00f)); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 109d7b3c10..a28add0e4d 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -1,12 +1,10 @@ #include "Plater.hpp" #include "../Utils/NetworkAgent.hpp" -#include "../Utils/NetworkAgentFactory.hpp" #include "libslic3r/Config.hpp" #include "libslic3r_version.h" #include #include -#include #include #include #include @@ -16,8 +14,6 @@ #include #include #include -#include -#include #include #include #include @@ -69,7 +65,6 @@ #include "libslic3r/Format/bbs_3mf.hpp" #include "libslic3r/GCode/ThumbnailData.hpp" #include "libslic3r/Model.hpp" -#include "libslic3r/SLA/Hollowing.hpp" #include "libslic3r/SLA/SupportPoint.hpp" #include "libslic3r/SLA/ReprojectPointsOnMesh.hpp" #include "libslic3r/Polygon.hpp" @@ -78,16 +73,15 @@ #include "libslic3r/SLAPrint.hpp" #include "libslic3r/Utils.hpp" #include "libslic3r/PresetBundle.hpp" +#include "libslic3r/PublishSettings.hpp" #include "slic3r/Utils/CrealityPrint.hpp" #include "libslic3r/ClipperUtils.hpp" -#include "libslic3r/ObjColorUtils.hpp" // For stl export #include "libslic3r/CSGMesh/ModelToCSGMesh.hpp" #include "libslic3r/CSGMesh/PerformCSGMeshBooleans.hpp" #include "GUI.hpp" #include "GUI_App.hpp" -#include "GuiColor.hpp" #include "GUI_ObjectList.hpp" #ifdef __WXGTK__ #include "LinuxDisplayBackend.hpp" @@ -123,7 +117,6 @@ #include "SendMultiMachinePage.hpp" #include "SendToPrinter.hpp" #include "PublishDialog.hpp" -#include "ModelMall.hpp" #include "ConfigWizard.hpp" #include "SyncAmsInfoDialog.hpp" #include "../Utils/ASCIIFolding.hpp" @@ -149,7 +142,6 @@ #include "ParamsDialog.hpp" #include "ImageDPIFrame.hpp" #include "Widgets/Label.hpp" -#include "Widgets/RoundedRectangle.hpp" #include "Widgets/RadioGroup.hpp" #include "Widgets/CheckBox.hpp" #include "Widgets/Button.hpp" @@ -6712,6 +6704,12 @@ struct Plater::priv SendToPrinterDialog* m_send_to_sdcard_dlg = nullptr; PublishDialog *m_publish_dlg = nullptr; + // Session-level stash of the last published selection. Written on publish and on + // loading a published 3MF; read when the Publish dialog is opened. + bool m_has_pending_published{false}; + std::vector m_pending_published_keys; + std::vector m_pending_material_keys; + // Data Slic3r::DynamicPrintConfig *config; // FIXME: leak? Slic3r::Print fff_print; @@ -6936,7 +6934,10 @@ struct Plater::priv // BBS: backup & restore using LoadProgressCallback = std::function; - std::vector load_files(const std::vector& input_files, LoadStrategy strategy, bool ask_multi = false); + std::vector load_files(const std::vector& input_files, + LoadStrategy strategy, + bool ask_multi = false, + bool* published_out = nullptr); std::vector load_model_objects(const ModelObjectPtrs& model_objects, bool allow_negative_z = false, bool split_object = false, bool auto_drop = true); // Texture-to-color import: a mesh loaded with UVs + a texture map gets its faces clustered @@ -6964,7 +6965,7 @@ struct Plater::priv std::function cancel_callback = {}); fs::path get_export_file_path(GUI::FileType file_type); - wxString get_export_file(GUI::FileType file_type); + wxString get_export_file(GUI::FileType file_type, const wxString& title = {}, bool published = false); // BBS void load_auxiliary_files(); @@ -8272,7 +8273,10 @@ void read_binary_stl(const std::string& filename, std::string& model_id, std::st } // BBS: backup & restore -std::vector Plater::priv::load_files(const std::vector& input_files, LoadStrategy strategy, bool ask_multi) +std::vector Plater::priv::load_files(const std::vector& input_files, + LoadStrategy strategy, + bool ask_multi, + bool* published_out) { std::vector empty_result; bool dlg_cont = true; @@ -8352,6 +8356,7 @@ std::vector Plater::priv::load_files(const std::vector& input_ const float INPUT_FILES_RATIO = 0.7; const float INIT_MODEL_RATIO = 0.75; const float CENTER_AROUND_ORIGIN_RATIO = 0.8; + const float LOAD_MODEL_RATIO = 0.9; for (size_t i = 0; i < input_files.size(); ++i) { @@ -8392,6 +8397,11 @@ std::vector Plater::priv::load_files(const std::vector& input_ DynamicPrintConfig config; Semver file_version; En3mfType en_3mf_file_type = En3mfType::From_BBS; + // BBS: a "published" 3MF carries a flag plus the author-selected setting keys; + // on load keep the user's current presets and overlay only those keys. Declared + // here (outside the config block below) so it stays alive for the embedded-preset + // gate, the metadata strip and the preset overlay after the block closes. + PublishedConfig published_config; { DynamicPrintConfig config_loaded; @@ -8419,6 +8429,107 @@ std::vector Plater::priv::load_files(const std::vector& input_ << boost::format(", plate_data.size %1%, project_preset.size %2%, is_bbs_or_orca_3mf %3%, file_version %4% \n") % plate_data.size() % project_presets.size() % (en_3mf_file_type == En3mfType::From_BBS || en_3mf_file_type == En3mfType::From_Orca) % file_version.to_string(); + // BBS: a "published" 3MF carries a flag plus the author-selected setting keys; + // on load keep the user's current presets and overlay only those keys. Parsed + // here (before the version/fallback chain below) because a published file has + // no project_settings.config: its values travel in the published_config + // metadata payload, which must fill config_loaded before the chain decides + // whether to import geometry only. + if (model.model_info != nullptr) { + auto published_it = model.model_info->metadata_items.find(ORCA_PUBLISHED_TAG); + if (published_it != model.model_info->metadata_items.end() && is_published_3mf_flag(published_it->second)) { + published_config.published = true; + auto keys_it = model.model_info->metadata_items.find(ORCA_PUBLISHED_KEYS_TAG); + if (keys_it != model.model_info->metadata_items.end()) { + try { + auto j = nlohmann::json::parse(keys_it->second); + if (j.is_array()) + for (const auto& k : j) + if (k.is_string()) + published_config.published_keys.emplace_back(k.get()); + } catch (...) { + // Ignore malformed published_keys; the project still loads normally. + } + } + + auto material_keys_it = model.model_info->metadata_items.find(ORCA_PUBLISHED_MATERIAL_TAG); + if (material_keys_it != model.model_info->metadata_items.end()) { + try { + auto jm = nlohmann::json::parse(material_keys_it->second); + if (jm.is_array()) + for (const auto& m : jm) { + try { + // Malformed entries are isolated so one bad item + // cannot discard valid entries that follow it. + if (!m.is_object()) + continue; + PublishedMaterialEntry entry; + const auto mat_it = m.find("material"); + if (mat_it != m.end() && mat_it->is_object()) { + const auto& mat = *mat_it; + if (mat.contains("filament_type") && mat["filament_type"].is_string()) + entry.filament_type = mat["filament_type"].get(); + if (mat.contains("filament_vendor") && mat["filament_vendor"].is_string()) + entry.filament_vendor = mat["filament_vendor"].get(); + if (mat.contains("filament_id") && mat["filament_id"].is_string()) + entry.filament_id = mat["filament_id"].get(); + if (mat.contains("setting_id") && mat["setting_id"].is_string()) + entry.setting_id = mat["setting_id"].get(); + if (mat.contains("name") && mat["name"].is_string()) + entry.preset_name = mat["name"].get(); + } + if (m.contains("slot") && m["slot"].is_number_integer()) + entry.slot = m["slot"].get(); + const auto entry_keys_it = m.find("keys"); + if (entry_keys_it != m.end() && entry_keys_it->is_array()) + for (const auto& k : *entry_keys_it) + if (k.is_string()) + entry.keys.emplace_back(k.get()); + // Fields always written by the current exporter. + if (m.contains("full") && m["full"].is_boolean()) + entry.full = m["full"].get(); + const auto entry_full_keys_it = m.find("full_keys"); + if (entry_full_keys_it != m.end() && entry_full_keys_it->is_array()) + for (const auto& k : *entry_full_keys_it) + if (k.is_string()) + entry.full_keys.emplace_back(k.get()); + if (m.contains("publish_type") && m["publish_type"].is_boolean()) + entry.publish_type = m["publish_type"].get(); + if (m.contains("type") && m["type"].is_string()) + entry.publish_type_value = m["type"].get(); + if (m.contains("publish_color") && m["publish_color"].is_boolean()) + entry.publish_color = m["publish_color"].get(); + if (m.contains("color") && m["color"].is_string()) + entry.color = m["color"].get(); + published_config.material_keys.emplace_back(std::move(entry)); + } catch (const nlohmann::json::exception&) { + // Ignore only this malformed material entry. + } + } + } catch (const nlohmann::json::exception&) { + // Ignore malformed published_material_keys; the project still loads normally. + } + } + + // Rebuild the published values from the metadata payload: a published + // file carries no project_settings.config, so config_loaded is filled + // from here; a missing or malformed payload leaves it empty and the + // fallback chain below imports the geometry only. + auto payload_it = model.model_info->metadata_items.find(ORCA_PUBLISHED_CONFIG_TAG); + if (payload_it != model.model_info->metadata_items.end()) { + try { + ConfigSubstitutions payload_substitutions = + config_loaded.load_from_ini_string(payload_it->second, ForwardCompatibilitySubstitutionRule::Enable); + config_substitutions.substitutions.insert(config_substitutions.substitutions.end(), + std::make_move_iterator(payload_substitutions.begin()), + std::make_move_iterator(payload_substitutions.end())); + } catch (...) { + // Ignore malformed published_config; the project still loads normally. + } + } + } + } + // 1. add extruder for prusa model if the number of existing extruders is not enough // 2. add extruder for BBS or Other model if only import geometry if (en_3mf_file_type == En3mfType::From_Prusa || (load_model && !load_config)) { @@ -8582,7 +8693,7 @@ std::vector Plater::priv::load_files(const std::vector& input_ text += "\n"; log_and_show_3mf_info(text, bambu_project_title); } - } else if (load_config) { + } else if (load_config && !published_config.published) { // BambuStudio version is older or same as our SLIC3R_VERSION wxString text = _L("The 3MF was created by BambuStudio. Some settings may differ from OrcaSlicer."); log_and_show_3mf_info(text, bambu_project_title); @@ -8642,7 +8753,9 @@ std::vector Plater::priv::load_files(const std::vector& input_ } Semver old_version(1, 5, 9); - if ((en_3mf_file_type == En3mfType::From_BBS || en_3mf_file_type == En3mfType::From_Orca) && (file_version < old_version) && load_model && load_config && !config_loaded.empty()) { + // A published 3MF has no project config to migrate: skip the old-version + // translations even if a slicer tag slipped through classification. + if ((en_3mf_file_type == En3mfType::From_BBS || en_3mf_file_type == En3mfType::From_Orca) && (file_version < old_version) && !published_config.published && load_model && load_config && !config_loaded.empty()) { translate_old = true; partplate_list.get_plate_size(current_width, current_depth, current_height); } @@ -8664,8 +8777,10 @@ std::vector Plater::priv::load_files(const std::vector& input_ } } - // BBS:: project embedded presets - if ((project_presets.size() > 0) && load_config) { + // BBS:: project embedded presets (skipped for published projects: the author's + // embedded presets must not pollute the receiver's library, the overlay applies + // the published keys to the receiver's own presets instead). + if ((project_presets.size() > 0) && load_config && !published_config.published) { // load project embedded presets PresetsConfigSubstitutions preset_substitutions; PresetBundle & preset_bundle = *wxGetApp().preset_bundle; @@ -8721,6 +8836,19 @@ std::vector Plater::priv::load_files(const std::vector& input_ } } + // BBS: a "published" 3MF loads as a new project: its path must not become the + // project filename (Save/Ctrl-S would overwrite the shared file), and the + // published metadata is consumed above and stripped so a later save is a normal + // unpublished 3MF. + if (published_out != nullptr && published_config.published) + *published_out = true; + if (published_config.published && load_config && this->model.model_info != nullptr) { + this->model.model_info->metadata_items.erase(ORCA_PUBLISHED_TAG); + this->model.model_info->metadata_items.erase(ORCA_PUBLISHED_KEYS_TAG); + this->model.model_info->metadata_items.erase(ORCA_PUBLISHED_MATERIAL_TAG); + this->model.model_info->metadata_items.erase(ORCA_PUBLISHED_CONFIG_TAG); + } + if (load_config) { if (!config.empty()) { Preset::normalize(config); @@ -8729,7 +8857,7 @@ std::vector Plater::priv::load_files(const std::vector& input_ { // BBS: modify the prime tower params for old version file Semver old_version3(2, 0, 0); - if ((en_3mf_file_type == En3mfType::From_BBS || en_3mf_file_type == En3mfType::From_Orca) && file_version < old_version3) { + if ((en_3mf_file_type == En3mfType::From_BBS || en_3mf_file_type == En3mfType::From_Orca) && !published_config.published && file_version < old_version3) { double old_filament_prime_volume = 0.; int filament_count = 0; { @@ -8779,7 +8907,7 @@ std::vector Plater::priv::load_files(const std::vector& input_ } auto choise = wxGetApp().app_config->get("no_warn_when_modified_gcodes"); - if (choise.empty() || choise != "true") { + if (!published_config.published && (choise.empty() || choise != "true")) { // BBS: first validate the printer // validate the system profiles std::set modified_gcodes; @@ -8824,12 +8952,56 @@ std::vector Plater::priv::load_files(const std::vector& input_ if (wipe_tower_y_opt) file_wipe_tower_y = *wipe_tower_y_opt; + // Convert the printer's filament ids to Orca ids before loading. if (auto* agent = wxGetApp().getAgent()) { if (auto* ids = config.opt("filament_ids")) for (std::string& id : ids->values) id = agent->to_orca_filament_id(id); } - preset_bundle->load_config_model(filename.string(), std::move(config), file_version); + preset_bundle->load_config_model(filename.string(), std::move(config), file_version, &published_config); + + // Mixed-filament definitions that collided with one of the + // receiver's real slots were relocated during the preset load. + // Re-point the freshly parsed model's extruder references and + // color painting from the author's slot numbers to where each + // definition landed, so volumes colored with a mix follow it. + // Runs before the objects are handed over to the plater below. + if (load_model && !published_config.mixed_slot_relocations.empty()) + Slic3r::remap_model_filament_slots(model, published_config.mixed_slot_relocations); + + // BBS: notify the user about published settings that could not be applied. + if (!published_config.skipped_keys.empty()) { + NotificationManager* notify_manager = q->get_notification_manager(); + std::string message = _u8L("Some published settings could not be applied:"); + for (const std::string& key : published_config.skipped_keys) + message += "\n-" + key; + // Informational: the load succeeded, these keys were skipped. + notify_manager + ->bbl_show_3mf_warn_notification(message, + NotificationManager::NotificationLevel::WarningNotificationLevel); + } + + // BBS: notify the user about slot materials that were replaced while + // loading a published project (type mismatch / no same-type match). + if (!published_config.material_replacements.empty()) { + NotificationManager* notify_manager = q->get_notification_manager(); + std::string message = _u8L("Some filament slots were changed:"); + for (const std::string& replacement : published_config.material_replacements) + message += "\n-" + replacement; + // Informational: the load succeeded, the slots were adapted. + notify_manager + ->bbl_show_3mf_warn_notification(message, + NotificationManager::NotificationLevel::WarningNotificationLevel); + } + + // Remember the imported published selection so the Publish dialog is + // pre-seeded with the file's settings. Stored after the load so any + // per-slot relocations are already reflected in material_keys. + if (published_config.published) { + this->m_has_pending_published = true; + this->m_pending_published_keys = published_config.published_keys; + this->m_pending_material_keys = published_config.material_keys; + } ConfigOption* bed_type_opt = preset_bundle->project_config.option("curr_bed_type"); if (bed_type_opt != nullptr) { @@ -8970,7 +9142,13 @@ std::vector Plater::priv::load_files(const std::vector& input_ dynamic_map->value = false; } // Update filament combobox after loading config - wxGetApp().plater()->sidebar().update_presets(Preset::TYPE_FILAMENT); + if (published_config.published) { + q->update_filament_colors_in_full_config(); + wxGetApp().plater()->sidebar().update_all_preset_comboboxes(); + wxGetApp().plater()->sidebar().update_dynamic_filament_list(); + } else { + wxGetApp().plater()->sidebar().update_presets(Preset::TYPE_FILAMENT); + } // The loaded project supplies nozzle_volume_type; refresh the sidebar // nozzle-count badges against it. if (auto *nozzle_volumes = wxGetApp().preset_bundle->project_config.option("nozzle_volume_type")) { @@ -9723,7 +9901,7 @@ fs::path Plater::priv::get_export_file_path(GUI::FileType file_type) return output_file; } -wxString Plater::priv::get_export_file(GUI::FileType file_type) +wxString Plater::priv::get_export_file(GUI::FileType file_type, const wxString& title, bool published) { wxString wildcard; switch (file_type) { @@ -9765,8 +9943,11 @@ wxString Plater::priv::get_export_file(GUI::FileType file_type) } case FT_3MF: { - output_file.replace_extension("3mf"); - dlg_title = _L("Save file as"); + // A published export is suggested as ".published.3mf" so the role is visible in the + // dialog and in the recent-files list. This is only a pre-filled suggestion; the user's + // typed filename wins, keeping a plain ".3mf" output fully valid. + output_file.replace_extension(published ? "published.3mf" : "3mf"); + dlg_title = title.empty() ? _L("Save file as") : title; break; } case FT_OBJ: @@ -10004,6 +10185,11 @@ void Plater::priv::reset(bool apply_presets_change) clear_warnings(); + // A new project must not inherit the previous project's published selection (Feature A/B). + m_has_pending_published = false; + m_pending_published_keys.clear(); + m_pending_material_keys.clear(); + set_project_filename(""); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << " call set_project_filename: empty"; @@ -15104,14 +15290,15 @@ void Plater::load_project(wxString const& filename2, if (strategy & LoadStrategy::Restore) input_paths.push_back(into_u8(originfile)); - std::vector res = load_files(input_paths, strategy); + bool loaded_published = false; + std::vector res = load_files(input_paths, strategy, false, &loaded_published); reset_project_dirty_initial_presets(); update_project_dirty_from_presets(); wxGetApp().preset_bundle->export_selections(*wxGetApp().app_config); // if res is empty no data has been loaded - if (!res.empty() && (load_restore || !(strategy & LoadStrategy::Silence))) { + if (!res.empty() && !loaded_published && (load_restore || !(strategy & LoadStrategy::Silence))) { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << " call set_project_filename: " << (load_restore ? originfile : filename); p->set_project_filename(load_restore ? originfile : filename); if (load_restore && originfile.IsEmpty()) { @@ -15122,6 +15309,15 @@ void Plater::load_project(wxString const& filename2, if (using_exported_file()) { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << " using ecported set project filename: " << filename; p->set_project_filename(filename); + } else if (loaded_published && !res.empty()) { + // A "published" 3MF loads as a new project: its path must not become the project + // filename (Save/Ctrl-S prompts for a destination instead of overwriting it); + // reset() already cleared the project name, so restore the default title and keep + // the file in recents. Only on a successful load (res not empty): a failed or + // cancelled load must not pollute "Recently opened". + p->set_project_name(_L("Untitled")); + if (!filename.IsEmpty()) + wxGetApp().mainframe->add_to_recent_projects(filename); } } @@ -16779,22 +16975,12 @@ void Plater::force_update_all_plate_thumbnails() } // BBS: backup -std::vector Plater::load_files(const std::vector& input_files, LoadStrategy strategy, bool ask_multi) { +std::vector Plater::load_files(const std::vector& input_files, LoadStrategy strategy, bool ask_multi, bool* published_out) { //BBS: wish to reset state when load a new file p->m_slice_all_only_has_gcode = false; //BBS: wish to reset all plates stats item selected state when load a new file p->preview->get_canvas3d()->reset_select_plate_toolbar_selection(); - return p->load_files(input_files, strategy, ask_multi); -} - -// To be called when providing a list of files to the GUI slic3r on command line. -std::vector Plater::load_files(const std::vector& input_files, LoadStrategy strategy, bool ask_multi) -{ - std::vector paths; - paths.reserve(input_files.size()); - for (const std::string& path : input_files) - paths.emplace_back(path); - return p->load_files(paths, strategy, ask_multi); + return p->load_files(input_files, strategy, ask_multi, published_out); } bool Plater::preview_zip_archive(const boost::filesystem::path& archive_path) @@ -18023,7 +18209,6 @@ void Plater::send_gcode_finish(wxString name) auto out_str = GUI::format(_L("The file %s has been sent to the printer's storage space and can be viewed on the printer."), name); p->notification_manager->push_exporting_finished_notification(out_str, "", false); } - void Plater::export_core_3mf() { wxString path = p->get_export_file(FT_3MF); @@ -18032,6 +18217,182 @@ void Plater::export_core_3mf() export_3mf(path_u8, SaveStrategy::Silence); } +// Export the current project as a "published" 3MF: a pure export that never touches the +// project's file name, dirty state, backup path or title, and attaches the published metadata +// to the model only for the duration of the export (a later Save Project is a normal 3MF). +int Plater::export_published_3mf(const std::vector& published_keys, + const std::vector& material_keys) +{ + wxString path = p->get_export_file(FT_3MF, _L("Publish 3MF file as:"), true); + if (path.empty() || path == "") + return wxID_CANCEL; + + nlohmann::json j = nlohmann::json::array(); + for (const std::string& key : published_keys) + j.push_back(key); + nlohmann::json jm = nlohmann::json::array(); + for (const Slic3r::PublishedMaterialEntry& e : material_keys) + jm.push_back({{"material", + {{"filament_type", e.filament_type}, + {"filament_vendor", e.filament_vendor}, + {"filament_id", e.filament_id}, + {"setting_id", e.setting_id}, + {"name", e.preset_name}}}, + {"slot", e.slot}, + {"keys", e.keys}, + {"full", e.full}, + {"full_keys", e.full_keys}, + {"publish_type", e.publish_type}, + {"type", e.publish_type_value}, + {"publish_color", e.publish_color}, + {"color", e.color}}); + + Model& model = this->model(); + // Save the previous metadata so it can be restored after the export, keeping the in-memory + // project pristine (the published flag lives only in the exported file). + const bool had_model_info = (model.model_info != nullptr); + const bool had_published = had_model_info && + (model.model_info->metadata_items.find(ORCA_PUBLISHED_TAG) != model.model_info->metadata_items.end()); + const bool had_published_keys = had_model_info && (model.model_info->metadata_items.find(ORCA_PUBLISHED_KEYS_TAG) != + model.model_info->metadata_items.end()); + const bool had_material_keys = had_model_info && (model.model_info->metadata_items.find(ORCA_PUBLISHED_MATERIAL_TAG) != + model.model_info->metadata_items.end()); + const bool had_payload = had_model_info && + (model.model_info->metadata_items.find(ORCA_PUBLISHED_CONFIG_TAG) != model.model_info->metadata_items.end()); + const std::string prev_published = had_published ? model.model_info->metadata_items.at(ORCA_PUBLISHED_TAG) : std::string(); + const std::string prev_published_keys = had_published_keys ? model.model_info->metadata_items.at(ORCA_PUBLISHED_KEYS_TAG) : + std::string(); + const std::string prev_material_keys = had_material_keys ? model.model_info->metadata_items.at(ORCA_PUBLISHED_MATERIAL_TAG) : + std::string(); + const std::string prev_payload = had_payload ? model.model_info->metadata_items.at(ORCA_PUBLISHED_CONFIG_TAG) : std::string(); + + // export_3mf() assigns archive paths to previously unsaved SVGs. Preserve those fields too, + // otherwise a publish changes what a later normal project save writes. + std::vector> previous_svg_paths; + for (ModelObject* object : model.objects) + for (ModelVolume* volume : object->volumes) + if (volume != nullptr && volume->emboss_shape.has_value() && volume->emboss_shape->svg_file.has_value()) { + std::string* path_in_3mf = &volume->emboss_shape->svg_file->path_in_3mf; + previous_svg_paths.emplace_back(path_in_3mf, *path_in_3mf); + } + + auto restore_temporary_state = [&]() { + for (const auto& [path_in_3mf, previous_path] : previous_svg_paths) + *path_in_3mf = previous_path; + + if (!had_model_info) { + model.model_info = nullptr; + } else { + if (had_published) + model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = prev_published; + else + model.model_info->metadata_items.erase(ORCA_PUBLISHED_TAG); + if (had_published_keys) + model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] = prev_published_keys; + else + model.model_info->metadata_items.erase(ORCA_PUBLISHED_KEYS_TAG); + if (had_material_keys) + model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] = prev_material_keys; + else + model.model_info->metadata_items.erase(ORCA_PUBLISHED_MATERIAL_TAG); + if (had_payload) + model.model_info->metadata_items[ORCA_PUBLISHED_CONFIG_TAG] = prev_payload; + else + model.model_info->metadata_items.erase(ORCA_PUBLISHED_CONFIG_TAG); + } + }; + bool state_restored = false; + auto restore_now = [&]() { + if (state_restored) + return; + restore_temporary_state(); + state_restored = true; + }; + ScopeGuard restore_guard(restore_now); + + if (model.model_info == nullptr) + model.model_info = std::make_shared(); + model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = "1"; + model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] = j.dump(); + model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] = jm.dump(); + + int ret = -1; + try { + // Minimal published export: filter full_config to the published keys, material keys, + // identity fields and plate geometry keys, and omit the project config file, the + // project-embedded preset dumps and the OrcaSlicer version tag from the archive. The + // filtered values are serialized into the published_config metadata payload instead, so + // OrcaSlicer versions without the publish feature fall back to importing the geometry only + // (keeping the receiver's presets) while new versions rebuild the config from the payload. + DynamicPrintConfig full_cfg = wxGetApp().preset_bundle->full_config_secure(); + DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, published_keys, material_keys); + std::string payload; + for (const std::string& key : filtered_cfg.keys()) { + // A value containing a newline would break the INI written below (read_ini throws), + // so load_from_ini_string discards the whole settings block on import. Skip such + // keys instead of silently dropping every setting. + std::string value = filtered_cfg.opt_serialize(key); + if (value.find('\n') != std::string::npos) { + BOOST_LOG_TRIVIAL(warning) << "publish: dropping key \"" << key + << "\" from the published payload (value contains a newline)"; + continue; + } + payload += key + " = " + value + "\n"; + } + model.model_info->metadata_items[ORCA_PUBLISHED_CONFIG_TAG] = std::move(payload); + + // Same file layout as save_project(), plus Silence (so export_3mf does not set the project + // filename on success, keeping this a pure export like export_core_3mf()) and MinimalPublished. + auto save_strategy = SaveStrategy::SplitModel | SaveStrategy::ShareMesh | SaveStrategy::Silence | SaveStrategy::MinimalPublished; + bool full_pathnames = wxGetApp().app_config->get_bool("export_sources_full_pathnames"); + if (full_pathnames) + save_strategy = save_strategy | SaveStrategy::FullPathSources; + ret = export_3mf(into_path(path), save_strategy, -1, nullptr); + } catch (...) { + restore_now(); + MessageDialog(this, + _L("Failed to export the published 3MF file.\nPlease check whether the folder exists online or if other programs " + "have the file open."), + _L("Publish"), wxOK | wxICON_WARNING) + .ShowModal(); + return wxID_CANCEL; + } + + if (ret < 0) { + restore_now(); + MessageDialog(this, + _L("Failed to export the published 3MF file.\nPlease check whether the folder exists online or if other programs " + "have the file open."), + _L("Publish"), wxOK | wxICON_WARNING) + .ShowModal(); + return wxID_CANCEL; + } + restore_now(); + + // Register the exported file in the "Recently opened" list + wxGetApp().mainframe->add_to_recent_projects(path); + + return wxID_YES; +} + +bool Plater::get_pending_published(std::vector& out_keys, + std::vector& out_material) const +{ + if (!p->m_has_pending_published) + return false; + out_keys = p->m_pending_published_keys; + out_material = p->m_pending_material_keys; + return true; +} + +void Plater::set_pending_published(const std::vector& published_keys, + const std::vector& material_keys) +{ + p->m_has_pending_published = true; + p->m_pending_published_keys = published_keys; + p->m_pending_material_keys = material_keys; +} + Preset *get_printer_preset(const MachineObject *obj) { if (!obj) diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index 5308deec61..26cd06978b 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -407,9 +407,7 @@ public: bool preview_zip_archive(const boost::filesystem::path& archive_path); // BBS: restore - std::vector load_files(const std::vector& input_files, LoadStrategy strategy = LoadStrategy::LoadModel | LoadStrategy::LoadConfig, bool ask_multi = false); - // To be called when providing a list of files to the GUI slic3r on command line. - std::vector load_files(const std::vector& input_files, LoadStrategy strategy = LoadStrategy::LoadModel | LoadStrategy::LoadConfig, bool ask_multi = false); + std::vector load_files(const std::vector& input_files, LoadStrategy strategy = LoadStrategy::LoadModel | LoadStrategy::LoadConfig, bool ask_multi = false, bool* published_out = nullptr); // to be called on drag and drop bool load_files(const wxArrayString& filenames); @@ -519,6 +517,13 @@ public: void export_gcode_3mf(bool export_all = false); void send_gcode_finish(wxString name); void export_core_3mf(); + // Export a "published" 3MF embedding the author-selected settings in the file metadata; a + // pure export that leaves the in-memory project untouched. + int export_published_3mf(const std::vector& published_keys, const std::vector& material_keys); + // Session-level stash of the last published selection, seeded into the Publish dialog on + // open and written on publish or on loading a published 3MF + bool get_pending_published(std::vector& out_keys, std::vector& out_material) const; + void set_pending_published(const std::vector& published_keys, const std::vector& material_keys); static TriangleMesh combine_mesh_fff(const ModelObject& mo, int instance_id, std::function notify_func = {}); void export_stl(bool extended = false, bool selection_only = false, bool multi_stls = false, FileType file_type = FT_STL); //BBS: remove amf diff --git a/src/slic3r/GUI/PublishSettingsDialog.cpp b/src/slic3r/GUI/PublishSettingsDialog.cpp new file mode 100644 index 0000000000..16b0b6201c --- /dev/null +++ b/src/slic3r/GUI/PublishSettingsDialog.cpp @@ -0,0 +1,2198 @@ +#include "PublishSettingsDialog.hpp" + +#include "GUI_App.hpp" +#include "MainFrame.hpp" +#include "MsgDialog.hpp" +#include "I18N.hpp" +#include "Tab.hpp" +#include "ConfigValueFormatter.hpp" +#include "FilamentBitmapUtils.hpp" +#include "Widgets/Label.hpp" +#include "Widgets/TextInput.hpp" +#include "Widgets/DialogButtons.hpp" +#include "Widgets/StaticLine.hpp" +#include "Widgets/StateColor.hpp" + +#include "libslic3r/PrintConfig.hpp" +#include "libslic3r/Preset.hpp" +#include "libslic3r/PublishSettings.hpp" +#include "libslic3r/FilamentMixer.hpp" +#include "libslic3r/Model.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { namespace GUI { +namespace { + +// Menu ids for show_menu(): dedicated range so the popup cannot collide with application-level +// bindings (e.g. MainFrame's recent-files wxID_FILE1.. range). +enum { + kPublishSelectAll = wxID_HIGHEST + 1, + kPublishDeselectAll, + kPublishSelectVisible, + kPublishDeselectVisible, + kPublishFilterSelected, + kPublishFilterNonSelected +}; + +// Tree-style flags shared by the outer tabs and every group's inner tabs. +constexpr long s_tab_style = wxTR_NO_BUTTONS | wxTR_HIDE_ROOT | wxTR_SINGLE | wxTR_NO_LINES | wxBORDER_NONE | wxWANTS_CHARS | + wxTR_FULL_ROW_HIGHLIGHT; + +// The author's hex colour for a filament slot (empty when the slot is out of range); shared by +// the header chip, the inner-tab chip and the DPI rescale paths. +std::string filament_color_hex(const DynamicPrintConfig& full, size_t slot) +{ + if (const auto* colours = full.opt("filament_colour")) + if (slot < colours->size()) + return colours->get_at(slot); + return std::string(); +} + +PublishMaterialIdentity material_identity(size_t slot, const DynamicPrintConfig& full) +{ + PublishMaterialIdentity identity; + if (const auto* types = full.opt("filament_type")) + if (slot < types->size()) + identity.type = types->get_at(slot); + if (const auto* vendors = full.opt("filament_vendor")) + if (slot < vendors->size()) + identity.vendor = vendors->get_at(slot); + if (const auto* ids = full.opt("filament_ids")) + if (slot < ids->size()) + identity.id = ids->get_at(slot); + return identity; +} + +// "Generic PLA @System" -> "Generic PLA"; mirrors the alias derivation in PresetBundle.cpp. +std::string material_display_name(const std::string& preset_name) +{ + const size_t at = preset_name.find_first_of('@'); + if (at == std::string::npos) + return preset_name; + std::string bare = preset_name.substr(0, at); + boost::trim_right(bare); + return bare.empty() ? preset_name : bare; +} + +// Section title for a filament slot: the resolved preset name, then the filament type, then +// the generic "Material". +wxString material_title(size_t slot, const PresetBundle* bundle, const DynamicPrintConfig& full) +{ + if (slot < bundle->filament_presets.size()) { + const Preset* preset = bundle->filaments.find_preset(bundle->filament_presets[slot]); + if (preset != nullptr && !preset->name.empty()) { + // Display the name the sidebar does: the alias (already bare-name + variant-tail + // stripped for root filament presets). + const std::string& display = preset->alias.empty() ? get_preset_bare_name(preset->name) : preset->alias; + return from_u8(material_display_name(display)); + } + } + const PublishMaterialIdentity identity = material_identity(slot, full); + if (!identity.type.empty()) + return from_u8(identity.type); + return _L("Material"); +} + +// The component slots of a mixed filament ("1,2,3"), 1-based, or empty when out of range. +std::vector mixed_slot_components(const DynamicPrintConfig& full, size_t slot) +{ + const auto* comp_opt = full.opt("filament_mixed_components"); + if (comp_opt == nullptr || slot >= comp_opt->size()) + return {}; + return parse_mixed_components(comp_opt->values[slot]); +} + +// Human-readable label of a mixed filament, mirroring the sidebar's mixed filament rows: the +// 1-based component slot numbers with their blend percentages (or a "->" gradient arrow), +// e.g. "1 (60%) + 2 (40%)". Used as the slot's tab/header title in place of a preset name. +wxString mixed_filament_label(const DynamicPrintConfig& full, size_t slot) +{ + const std::vector comps = mixed_slot_components(full, slot); + if (comps.empty()) + return _L("Mixed filament"); + const auto* grad_opt = full.opt("filament_mixed_gradient"); + const bool is_gradient = grad_opt != nullptr && slot < grad_opt->size() && grad_opt->values[slot]; + const auto* ratios_opt = full.opt("filament_mixed_sublayer_ratios"); + wxString label; + for (size_t i = 0; i < comps.size(); ++i) { + if (i > 0) + label += is_gradient ? wxString::FromUTF8(" \u2192 ") : wxString::FromUTF8(" + "); + label += wxString::Format("%u", comps[i]); + if (!is_gradient) { + double ratio = 100.0 / comps.size(); + if (ratios_opt != nullptr && slot < ratios_opt->size()) { + const std::vector rs = parse_mixed_ratios(ratios_opt->values[slot], comps.size()); + if (i < rs.size()) + ratio = rs[i] * 100.0; + } + label += wxString::Format(" (%d%%)", int(ratio + 0.5)); + } + } + return label; +} + +// Blended representative colour of a mixed slot, computed exactly like the sidebar's swatches +// (recompute_mixed_slot_colors): sublayer slots blend by their ratios, gradient slots by their +// two end colours, broken references fall back to grey. +wxColour mixed_filament_blend_color(const DynamicPrintConfig& full, size_t slot) +{ + std::vector colors; + if (const auto* colours = full.opt("filament_colour")) { + colors.reserve(colours->values.size()); + for (const std::string& hex : colours->values) { + const wxColour c(hex); + colors.push_back(c.IsOk() ? c : wxColour(0, 0, 0)); + } + } + while (colors.size() <= slot) + colors.push_back(wxColour("#D9D9D9")); + recompute_mixed_slot_colors(colors, full); + return slot < colors.size() ? colors[slot] : wxColour("#D9D9D9"); +} + +// The slot's own chip as the main GUI renders it: a curve-sampled gradient swatch for a +// gradient mixed slot, the static blended colour otherwise. Returns wxNullBitmap when nothing +// could be rendered (the caller falls back to a plain label). +wxBitmap mixed_filament_chip_bitmap(const DynamicPrintConfig& full, size_t slot, int swatch_sz) +{ + const std::string label = std::to_string(slot + 1); + const auto* grad_opt = full.opt("filament_mixed_gradient"); + const bool is_gradient = grad_opt != nullptr && slot < grad_opt->size() && grad_opt->values[slot]; + wxBitmap* icon = nullptr; + if (is_gradient) { + // The same curve-sampled ramp the sidebar chips use; an empty ramp (broken definition) + // degrades to a plain fade between the slot's two component colours. + const std::vector ramp = mixed_gradient_ramp(full, slot, swatch_sz); + if (!ramp.empty()) { + icon = get_extruder_color_icon(std::vector(), true, label, swatch_sz, swatch_sz, &ramp); + } else { + std::vector hexes; + for (const unsigned int comp : mixed_slot_components(full, slot)) { + std::string hex = filament_color_hex(full, size_t(comp) - 1); + if (hex.empty()) + hex = "#D9D9D9"; + hexes.push_back(std::move(hex)); + } + if (hexes.size() >= 2) + icon = get_extruder_color_icon(std::move(hexes), true, label, swatch_sz, swatch_sz); + } + } + if (icon == nullptr) { + const wxColour blend = mixed_filament_blend_color(full, slot); + const std::string blend_hex = blend.IsOk() ? + std::string(wxString::Format("#%02X%02X%02X", blend.Red(), blend.Green(), blend.Blue()).ToUTF8()) : + std::string("#808080"); + icon = get_extruder_color_icon(blend_hex, label, swatch_sz, swatch_sz); + } + return icon != nullptr ? *icon : wxNullBitmap; +} + +// Tab-strip bitmap for a mixed slot: the mix's own chip, then its component swatches each +// followed by their percent share (or a "->" arrow for gradients), mirroring the main GUI's +// sidebar rows - e.g. "[3 purple]: [1 red] 50% + [2 blue] 50%". The whole composition is one +// bitmap because a TabCtrl item cannot interleave images into its text; the tab's text is +// therefore empty. The bitmap is opaque and bakes tab_bg (the tab strip's background colour): +// its texts are rasterized with plain GDI on that solid ground, like the sidebar rows and the +// color chips. Text on a transparent bitmap has to go through the graphics-context route, and +// on MSW that degrades into jagged ClearType-fallback glyphs with fringe halos - the same +// reason draw_mixed_gradient_plot renders into an opaque buffer (FilamentBitmapUtils.cpp). +wxBitmap mixed_filament_tab_bitmap(const DynamicPrintConfig& full, size_t slot, int swatch_sz, const wxColour& tab_bg) +{ + const std::vector comps = mixed_slot_components(full, slot); + if (comps.empty()) + return wxNullBitmap; + const auto* grad_opt = full.opt("filament_mixed_gradient"); + const bool is_gradient = grad_opt != nullptr && slot < grad_opt->size() && grad_opt->values[slot]; + const auto* ratios_opt = full.opt("filament_mixed_sublayer_ratios"); + std::vector ratio_pct(comps.size(), 100.0 / comps.size()); + if (!is_gradient && ratios_opt != nullptr && slot < ratios_opt->size()) { + const std::vector rs = parse_mixed_ratios(ratios_opt->values[slot], comps.size()); + for (size_t i = 0; i < rs.size() && i < comps.size(); ++i) + ratio_pct[i] = rs[i] * 100.0; + } + + const auto* colours = full.opt("filament_colour"); + const wxString lead_sep = wxString::FromUTF8(":"); + const wxString comp_sep = is_gradient ? wxString::FromUTF8("\u2192") : wxString::FromUTF8("+"); + + // Phase 1 (layout): render every swatch and measure every text piece so the composite + // width is known before drawing; the dummy bitmap keeps GetTextExtent reliable. + struct Piece + { + enum Kind { Swatch, Text } kind{Text}; + wxBitmap bmp; + wxString text; + }; + std::vector pieces; + bool has_lead = false; + wxBitmap dummy(1, 1); + wxMemoryDC measure_dc; + measure_dc.SelectObject(dummy); + measure_dc.SetFont(::Label::Body_12); + const int gap = wxWindow::FromDIP(4, nullptr); + + auto push_swatch = [&](const std::string& hex, const std::string& label) { + wxBitmap* icon = get_extruder_color_icon(hex, label, swatch_sz, swatch_sz); + if (icon == nullptr) + return; + pieces.push_back({Piece::Swatch, *icon, wxString()}); + }; + auto push_text = [&](const wxString& text) { pieces.push_back({Piece::Text, wxNullBitmap, text}); }; + + { + const wxBitmap chip = mixed_filament_chip_bitmap(full, slot, swatch_sz); + has_lead = chip.IsOk(); + if (has_lead) + pieces.push_back({Piece::Swatch, chip, wxString()}); + } + for (size_t ci = 0; ci < comps.size(); ++ci) { + if (pieces.empty()) + break; + push_text(has_lead && pieces.size() == 1 ? lead_sep : comp_sep); // lead chip may have failed to render + std::string hex = "#D9D9D9"; + if (colours != nullptr && comps[ci] >= 1 && comps[ci] <= colours->size()) + hex = colours->values[comps[ci] - 1]; + const size_t before = pieces.size(); + push_swatch(hex, std::to_string(comps[ci])); + if (pieces.size() == before) + break; // swatch failed: stop cleanly before an orphaned separator/percent pair + if (!is_gradient) { + push_text(wxString::Format("%d%%", int(ratio_pct[ci] + 0.5))); + } + } + if (pieces.empty()) + return wxNullBitmap; + + int width = 0; + for (const Piece& p : pieces) + width += (p.kind == Piece::Swatch ? swatch_sz : measure_dc.GetTextExtent(p.text).x + gap); + + // Phase 2 (draw): opaque, filled with the tab strip's background colour. Text goes through + // the plain DC on every platform (see the function comment for why the alpha route is + // avoided); chip bitmaps keep their own alpha and composite cleanly onto the fill. + wxBitmap composite(width, swatch_sz); + wxMemoryDC memdc; + memdc.SelectObject(composite); + memdc.SetBackground(wxBrush(tab_bg)); + memdc.Clear(); + memdc.SetBackgroundMode(wxTRANSPARENT); + memdc.SetFont(::Label::Body_12); + memdc.SetTextForeground(StateColor::darkModeColorFor(wxColour("#262E30"))); + int x = 0; + for (const Piece& p : pieces) { + if (p.kind == Piece::Swatch) { + memdc.DrawBitmap(p.bmp, x, 0, true); + x += swatch_sz; + } else { + const wxSize tsz = measure_dc.GetTextExtent(p.text); + memdc.DrawText(p.text, x + gap / 2, (swatch_sz - tsz.y) / 2); + x += tsz.x + gap; + } + } + memdc.SelectObject(wxNullBitmap); + return composite; +} + +// The filament slots (0-based) the current project actually uses, across every plate: base and +// MMU-painted volume extruders, per-object layer-range and support/wall/infill filament +// overrides, and custom-gcode tool changes. Used mixed slots are preserved as themselves (so an +// in-use mix is publishable) while their physical components are added too - a mix always ships +// with the materials of its components even if a component is not referenced directly anywhere. +std::set project_used_filament_slots(const PresetBundle& bundle, const DynamicPrintConfig& full, const Model& model) +{ + std::set used; // 0-based used slots, mixed slots not yet expanded + if (model.objects.empty()) + return used; + + auto add_1based = [&used](int id) { + if (id > 0) + used.insert(size_t(id - 1)); + }; + + const DynamicPrintConfig& print_cfg = bundle.prints.get_edited_preset().config; + const int glb_support_intf = print_cfg.opt_int("support_interface_filament"); + const int glb_support = print_cfg.opt_int("support_filament"); + const int glb_outer_wall = print_cfg.opt_int("outer_wall_filament_id"); + const int glb_inner_wall = print_cfg.opt_int("inner_wall_filament_id"); + const int glb_sparse_infill = print_cfg.opt_int("sparse_infill_filament_id"); + const int glb_internal_solid = print_cfg.opt_int("internal_solid_filament_id"); + const int glb_top_surface = print_cfg.opt_int("top_surface_filament_id"); + const int glb_bottom_surface = print_cfg.opt_int("bottom_surface_filament_id"); + const bool glb_support_on = print_cfg.opt_bool("enable_support") || print_cfg.opt_int("raft_layers") > 0; + + for (ModelObject* mo : model.objects) { + for (ModelVolume* mv : mo->volumes) + for (int e : mv->get_extruders()) + add_1based(e); + + for (const auto& range_entry : mo->layer_config_ranges) + if (const ConfigOption* ext_opt = range_entry.second.option("extruder")) + add_1based(ext_opt->getInt()); + + bool obj_support = glb_support_on; + if (const ConfigOption* s_opt = mo->config.option("enable_support"); s_opt != nullptr) + obj_support = s_opt->getBool(); + else if (const ConfigOption* r_opt = mo->config.option("raft_layers"); r_opt != nullptr) + obj_support = r_opt->getInt() > 0; + + if (obj_support) { + if (const ConfigOption* opt = mo->config.option("support_interface_filament"); opt != nullptr && opt->getInt() != 0) + add_1based(opt->getInt()); + else + add_1based(glb_support_intf); + + if (const ConfigOption* opt = mo->config.option("support_filament"); opt != nullptr && opt->getInt() != 0) + add_1based(opt->getInt()); + else + add_1based(glb_support); + } + + auto obj_id = [&mo](const char* key) { + const ConfigOption* opt = mo->config.option(key); + return (opt != nullptr) ? opt->getInt() : 0; + }; + + int obj_outer_wall = obj_id("outer_wall_filament_id"); + if (obj_outer_wall == 0) + obj_outer_wall = obj_id("inner_wall_filament_id"); + add_1based(obj_outer_wall != 0 ? obj_outer_wall : glb_outer_wall); + + int obj_inner_wall = obj_id("inner_wall_filament_id"); + if (obj_inner_wall == 0) + obj_inner_wall = obj_id("outer_wall_filament_id"); + add_1based(obj_inner_wall != 0 ? obj_inner_wall : glb_inner_wall); + + const int obj_sparse = obj_id("sparse_infill_filament_id"); + add_1based(obj_sparse != 0 ? obj_sparse : glb_sparse_infill); + + const int obj_internal_solid = obj_id("internal_solid_filament_id"); + add_1based(obj_internal_solid != 0 ? obj_internal_solid : glb_internal_solid); + + int obj_top = obj_id("top_surface_filament_id"); + if (obj_top == 0) + obj_top = obj_internal_solid; + add_1based(obj_top != 0 ? obj_top : glb_top_surface); + + int obj_bottom = obj_id("bottom_surface_filament_id"); + if (obj_bottom == 0) + obj_bottom = obj_internal_solid; + add_1based(obj_bottom != 0 ? obj_bottom : glb_bottom_surface); + } + + for (const auto& plate_entry : model.plates_custom_gcodes) + for (const CustomGCode::Item& item : plate_entry.second.gcodes) + if (item.type == CustomGCode::Type::ToolChange) + add_1based(item.extruder); + + // Expand used mixed slots to their physical components and keep each mixed slot itself so the + // dialog shows the mix. A component both used directly and pulled in via a mix is deduped. + const auto* is_mixed_opt = full.opt("filament_is_mixed"); + const auto* comp_opt = full.opt("filament_mixed_components"); + if (is_mixed_opt != nullptr && comp_opt != nullptr && has_any_mixed_filament(is_mixed_opt->values)) { + const std::vector raw(used.begin(), used.end()); + const std::vector expanded = expand_mixed_filaments(raw, is_mixed_opt->values, comp_opt->values); + std::set result(expanded.begin(), expanded.end()); + for (unsigned int s : raw) + if (s < is_mixed_opt->values.size() && is_mixed_opt->values[s]) + result.insert(s); + return result; + } + + return used; +} + +} // namespace + +// Warning shown on OK when an enabled mixed-filament slot relies on a filament that would ship +// without its material. One row per unmet dependency: the mixed slot's colour chip, the +// component filament's colour chip, and the reason. "Cancel" is the safe choice and keeps the +// dialog open; "Publish anyway" continues. +class MixedFilamentWarningDialog : public MsgDialog +{ +public: + MixedFilamentWarningDialog(wxWindow* parent, const DynamicPrintConfig& full, const std::vector& issues) + : MsgDialog(parent, _L("Warning"), wxEmptyString, wxOK | wxCANCEL | wxICON_WARNING) + { + auto* content = new wxBoxSizer(wxVERTICAL); + + auto* intro = new wxStaticText(this, wxID_ANY, _L("Some mixed filaments rely on filaments that will not be published:")); + intro->SetFont(Label::Body_13); + intro->Wrap(FromDIP(400)); + content->Add(intro, 0, wxEXPAND); + content->AddSpacer(FromDIP(10)); + + const int swatch = FromDIP(20); + for (const MixedDependencyIssue& issue : issues) { + auto* row = new wxBoxSizer(wxHORIZONTAL); + + // The mixed slot as just its own chip (gradient-aware, numbered like its tab); + // falls back to a plain label when the chip cannot be built. + const wxString mix_label = wxString::Format(_L("Filament %d (mixed)"), int(issue.mixed_slot) + 1); + const wxBitmap mix_bmp = mixed_filament_chip_bitmap(full, issue.mixed_slot, swatch); + if (mix_bmp.IsOk()) { + auto* bmp = new wxStaticBitmap(this, wxID_ANY, mix_bmp); + bmp->SetToolTip(mix_label); + row->Add(bmp, 0, wxALIGN_CENTER_VERTICAL); + } else { + auto* label = new wxStaticText(this, wxID_ANY, mix_label); + label->SetFont(Label::Body_12); + row->Add(label, 0, wxALIGN_CENTER_VERTICAL); + } + + auto* needs = new wxStaticText(this, wxID_ANY, _L("needs")); + needs->SetFont(Label::Body_12); + needs->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#6B6B6B"))); + row->Add(needs, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, FromDIP(6)); + + // The component filament's colour chip, numbered like the tab strips; the slot + // name stays on hover to keep the row itself short. + std::string hex = filament_color_hex(full, issue.component_slot); + if (hex.empty()) + hex = "#D9D9D9"; + if (wxBitmap* chip = get_extruder_color_icon(hex, std::to_string(issue.component_slot + 1), swatch, swatch)) { + auto* comp_bmp = new wxStaticBitmap(this, wxID_ANY, *chip); + comp_bmp->SetToolTip(wxString::Format(_L("Filament %d"), int(issue.component_slot) + 1)); + row->Add(comp_bmp, 0, wxALIGN_CENTER_VERTICAL); + } + + auto* reason = new wxStaticText(this, wxID_ANY, + issue.reason == MixedDependencyIssue::Reason::Disabled ? _L("not enabled") : + _L("material not published")); + reason->SetFont(Label::Body_12); + reason->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#989898"))); + row->Add(reason, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); + + content->Add(row, 0, wxLEFT, FromDIP(10)); + content->AddSpacer(FromDIP(6)); + } + + auto* hint = new wxStaticText( + this, wxID_ANY, + _L("To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement.")); + hint->SetFont(Label::Body_12); + hint->Wrap(FromDIP(380)); + content->Add(hint, 0, wxEXPAND | wxTOP, FromDIP(4)); + + content_sizer->Add(content, 0, wxEXPAND); + + SetButtonLabel(wxID_OK, _L("Publish anyway")); + SetButtonLabel(wxID_CANCEL, _L("Cancel"), true); // safe choice gets the focus + + finalize(); + } +}; + +PublishSettingsDialog::MixedVisualSpec PublishSettingsDialog::make_mixed_visual_spec(const DynamicPrintConfig& full, size_t slot) +{ + MixedVisualSpec spec; + const std::vector comps = mixed_slot_components(full, slot); + if (comps.empty()) + return spec; + + const auto* grad_opt = full.opt("filament_mixed_gradient"); + spec.is_gradient = grad_opt != nullptr && slot < grad_opt->size() && grad_opt->values[slot]; + + const auto* colours = full.opt("filament_colour"); + for (unsigned int cid : comps) { + std::string hex = "#D9D9D9"; + if (colours != nullptr && cid >= 1 && cid <= colours->size()) + hex = colours->values[cid - 1]; + const wxColour c(hex); + spec.component_colours.push_back(c.IsOk() ? c : wxColour("#D9D9D9")); + } + + if (!spec.is_gradient) { + // Sublayer shares; parse_mixed_ratios already falls back to equal shares and + // normalizes to sum 1. + spec.ratios.assign(comps.size(), 1.0 / comps.size()); + if (const auto* ratios_opt = full.opt("filament_mixed_sublayer_ratios")) + if (slot < ratios_opt->size()) { + const std::vector rs = parse_mixed_ratios(ratios_opt->values[slot], comps.size()); + if (rs.size() == comps.size()) + spec.ratios = rs; + } + if (comps.size() == 3) + spec.tri_weights = spec.ratios; // the picker's barycentric shares + } else { + const Slic3r::GradientCurve curve = mixed_gradient_curve(full, slot); + constexpr int kSamples = 256; + for (int i = 0; i <= kSamples; ++i) { + const double t = double(i) / kSamples; + spec.gradient_samples.emplace_back(t, sample_gradient_curve(curve, t)); + } + for (const Slic3r::GradientAnchor& anchor : curve.points) + spec.gradient_anchors.emplace_back(anchor.x, anchor.y); + } + + spec.valid = true; + return spec; +} + +PublishSettingsDialog::PublishSettingsDialog(wxWindow* parent, + const std::vector* published_keys, + const std::vector* material_keys) + : DPIDialog(parent ? parent : static_cast(wxGetApp().mainframe), + wxID_ANY, + _L("Publish 3MF..."), + wxDefaultPosition, + wxDefaultSize, + wxCAPTION | wxCLOSE_BOX | wxRESIZE_BORDER | wxFULL_REPAINT_ON_RESIZE) + , m_search(this, "search", 16) + , m_menu(this, "filter", 16) +{ + SetBackgroundColour(*wxWHITE); + + // --- filter bar: search box, All/None, menu button --- + wxPanel* f_bar = new wxPanel(this, wxID_ANY); + f_bar->SetBackgroundColour(GetBackgroundColour()); + wxBoxSizer* f_sizer = new wxBoxSizer(wxHORIZONTAL); + + m_filter_box = new TextInput(f_bar, "", "", "", wxDefaultPosition, wxDefaultSize, wxTE_PROCESS_ENTER); + m_filter_box->SetIcon(m_search.bmp()); + m_filter_box->SetMinSize(FromDIP(wxSize(200, 24))); + m_filter_box->SetSize(FromDIP(wxSize(-1, 24))); + m_filter_box->SetFocus(); + m_filter_ctrl = m_filter_box->GetTextCtrl(); + m_filter_ctrl->SetFont(Label::Body_13); + m_filter_ctrl->SetSize(wxSize(-1, FromDIP(16))); // centers text vertically + m_filter_ctrl->SetHint(_L("Type to filter...")); + m_filter_ctrl->Bind(wxEVT_TEXT, [this](auto&) { apply_filter(m_filter_ctrl->GetValue()); }); + m_filter_ctrl->Bind(wxEVT_TEXT_ENTER, [this](auto&) { apply_filter(m_filter_ctrl->GetValue()); }); + m_filter_ctrl->Bind(wxEVT_LEFT_DOWN, [this](auto& e) { + if (m_filter_mode != FilterMode::Text) + apply_filter(m_filter_ctrl->GetValue()); + e.Skip(); + }); + f_sizer->Add(m_filter_box, 1, wxEXPAND); + + m_fb_sizer = new wxBoxSizer(wxHORIZONTAL); + auto create_btn = [this, f_bar](wxString title, bool select) { + auto btn = new wxStaticText(f_bar, wxID_ANY, title); + btn->SetForegroundColour("#009687"); + btn->SetCursor(wxCURSOR_HAND); + btn->SetFont(Label::Body_13); + btn->Bind(wxEVT_LEFT_DOWN, [this, select](wxMouseEvent&) { select_all(select); }); + m_fb_sizer->Add(btn, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, FromDIP(10)); + }; + f_sizer->Add(m_fb_sizer, 0, wxALIGN_CENTER_VERTICAL); + create_btn(_L("All"), true); + create_btn(_L("None"), false); + + // Indicator for the menu's pseudo filters: sits where All/None go while they are hidden. + // Labels reuse the menu entries (no new strings); clicking the chip returns to text + // filtering, keeping the search box contents. + m_pseudo_chip = new wxStaticText(f_bar, wxID_ANY, ""); + m_pseudo_chip->SetForegroundColour("#009687"); + m_pseudo_chip->SetCursor(wxCURSOR_HAND); + m_pseudo_chip->SetFont(Label::Body_13); + m_pseudo_chip->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent&) { apply_filter(m_filter_ctrl->GetValue()); }); + m_pseudo_chip->Hide(); + f_sizer->Add(m_pseudo_chip, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, FromDIP(10)); + + m_menu_button = new wxStaticBitmap(f_bar, wxID_ANY, m_menu.bmp()); + m_menu_button->SetCursor(wxCURSOR_HAND); + m_menu_button->Bind(wxEVT_LEFT_DOWN, &PublishSettingsDialog::show_menu, this); + f_sizer->Add(m_menu_button, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, FromDIP(10)); + + f_bar->SetSizerAndFit(f_sizer); + + m_outer_tabs = new TabCtrl(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, s_tab_style); + m_outer_tabs->SetFont(Label::Body_14); + m_outer_tabs->SetBackgroundColour(GetBackgroundColour()); + + m_outer_host = new wxPanel(this, wxID_ANY); + m_outer_host->SetBackgroundColour(GetBackgroundColour()); + m_outer_host_sizer = new wxBoxSizer(wxVERTICAL); + m_outer_host->SetSizer(m_outer_host_sizer); + + wxBoxSizer* w_sizer = new wxBoxSizer(wxVERTICAL); + + wxStaticText* msg = new wxStaticText(this, wxID_ANY, _L("Select which settings to be published in the 3MF file")); + msg->SetFont(Label::Body_13); + msg->Wrap(-1); + w_sizer->Add(msg, 0, wxRIGHT | wxLEFT | wxTOP, FromDIP(10)); + + w_sizer->Add(f_bar, 0, wxRIGHT | wxLEFT | wxTOP | wxEXPAND, FromDIP(10)); + w_sizer->Add(m_outer_tabs, 0, wxRIGHT | wxLEFT | wxTOP | wxEXPAND, FromDIP(10)); + w_sizer->Add(m_outer_host, 1, wxRIGHT | wxLEFT | wxTOP | wxEXPAND, FromDIP(10)); + + build_option_model(); + + // Seed from a remembered session selection or a freshly loaded published 3MF (authoritative). + if (published_keys != nullptr || material_keys != nullptr) + apply_selection(published_keys != nullptr ? *published_keys : std::vector(), + material_keys != nullptr ? *material_keys : std::vector()); + + auto dlg_btns = new DialogButtons(this, {"OK", "Cancel"}); + + dlg_btns->GetOK()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { + // Publish is always allowed: no settings selected means no settings override. Warn only + // when an enabled mixed filament would ship without the material of one of its + // components; "Publish anyway" accepts that and publishes anyway. + if (const std::vector issues = unpublished_mixed_components(); !issues.empty()) { + const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); + MixedFilamentWarningDialog warn(this, full, issues); + if (warn.ShowModal() != wxID_OK) + return; // Cancel: dismiss the warning and stay in this dialog + } + EndModal(wxID_OK); + }); + dlg_btns->GetCANCEL()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_CANCEL); }); + + // Guide links, bottom-left, sharing the footer row with the OK/Cancel buttons (pushed right). + auto make_link = [this](const wxString& label, const char* url) { + wxStaticText* link = new wxStaticText(this, wxID_ANY, label); + link->SetFont(Label::Body_13); + link->SetForegroundColour(wxColour(0x1F, 0x8E, 0xEA)); + link->SetCursor(wxCURSOR_HAND); + link->Bind(wxEVT_LEFT_DOWN, [url](wxMouseEvent&) { wxLaunchDefaultBrowser(url, wxBROWSER_NEW_WINDOW); }); + return link; + }; + wxBoxSizer* links_sizer = new wxBoxSizer(wxVERTICAL); + links_sizer->Add(make_link(_L("Publish 3MF Wiki"), "https://www.orcaslicer.com/wiki/publishing_3mf/publish_3mf.html"), 0, wxALIGN_LEFT); + links_sizer->Add(make_link(_L("Publish 3MF Video Guide"), "https://www.youtube.com/@OfficialOrcaSlicer/videos"), 0, + wxTOP | wxALIGN_LEFT, FromDIP(4)); + + wxBoxSizer* footer = new wxBoxSizer(wxHORIZONTAL); + footer->Add(links_sizer, 0, wxALIGN_CENTER_VERTICAL); + footer->AddStretchSpacer(); + footer->Add(dlg_btns, 0, wxALIGN_CENTER_VERTICAL); + w_sizer->Add(footer, 0, wxRIGHT | wxLEFT | wxBOTTOM | wxEXPAND, FromDIP(10)); + + SetSizerAndFit(w_sizer); + fit_to_content(); // initial size only; the dialog is resizable + wxGetApp().UpdateDlgDarkUI(this); + // The dark-UI walk re-colours the tab strips after the compositions were baked; rebuild so + // they carry the theme-resolved background (the sidebar resolves its colours at paint time, + // this dialog bakes, so the bake has to happen after the walk). + refresh_mixed_tab_bitmaps(); +} + +// Size the window to its content: width follows the widest tab strip so no filament tab is +// hidden (TabCtrl::relayout hides overflowing buttons), height scales proportionally. Both +// are floored at the 600x500 base and capped at hard DIP limits - deliberately not the whole +// display - with one last-resort clamp so the dialog can never open larger than the screen. +// Also owns the resize floor: the window cannot be resized below what the tabs need, so +// shrinking never re-hides a filament tab. +void PublishSettingsDialog::fit_to_content() +{ + static const wxSize BASE{600, 500}; + static const wxSize CAP{1300, 850}; + + int strip = m_outer_tabs->GetFullSize(); + for (const SectionGroup& section : m_sections) { + strip = std::max(strip, section.tabs->GetFullSize()); + if (section.mixed_tabs != nullptr) + strip = std::max(strip, section.mixed_tabs->GetFullSize()); + } + + // Minimum width: whatever keeps every tab visible (never below the base). strip is device + // pixels (Button min sizes); BASE/CAP are DIP and converted over. + const int min_w = std::max(strip + 2 * FromDIP(10), FromDIP(BASE.x)); + + // Initial size: prefer proportional growth within the caps. + const int w = std::clamp(min_w, FromDIP(BASE.x), FromDIP(CAP.x)); + const double f = double(w) / FromDIP(BASE.x); + const int h = std::clamp(int(FromDIP(BASE.y) * f), FromDIP(BASE.y), FromDIP(CAP.y)); + + const wxRect area = wxDisplay(this).GetClientArea(); + const int max_w = area.width * 9 / 10; + const int max_h = area.height * 9 / 10; + + SetMinSize(wxSize(std::min(min_w, max_w), std::min(FromDIP(BASE.y), max_h))); + SetSize(std::min(w, max_w), std::min(h, max_h)); +} + +PublishSettingsDialog::~PublishSettingsDialog() {} + +void PublishSettingsDialog::build_option_model() +{ + // Structural / non-publishable keys, shared with the published-3MF overlay path. + const std::set& denylist = publish_structural_keys(); + // Base keys already added in the print section (dedup across pages/optgroups). The printer + // section keeps its own printer_added set keyed by the full per-extruder "#N" opt_id, so + // every extruder gets its own row (see Phase 1 below). + std::set added; + + PresetBundle* bundle = wxGetApp().preset_bundle; + DynamicPrintConfig full = bundle->full_config(); + + m_info_nonsel = _L("No selected items..."); + m_info_allsel = _L("All items selected..."); + m_info_empty = _L("No matching items..."); + m_info_mix = _L("Mixed filament - published as a whole when \"Enable\" above is selected"); + + // Tab order differs from Section's enum order (Print, Printer, Material): the dialog + // presents Printer, Filament, Process. + m_sections.reserve(3); + const Section tab_order[] = {Section::Printer, Section::Material, Section::Print}; + for (Section kind : tab_order) + section_group_for(kind); + bind_tab_events(); + + // Shared per-option label/value computation; returns false when the option must be skipped + // (denylisted / unknown / empty label). value is the stringified value; unit the translated + // sidetext (may be empty). + auto option_text = [&denylist, &full](const std::string& opt_id, const std::string& pure_key, wxString& label, wxString& value, + wxString& unit) -> bool { + if (denylist.count(pure_key) > 0) + return false; + const ConfigOptionDef* def = print_config_def.get(pure_key); + if (def == nullptr) + return false; + label = _(def->full_label.empty() ? def->label : def->full_label); + if (label.IsEmpty()) + return false; + value = get_string_value(opt_id, full); + unit = _(def->sidetext); + return true; + }; + + // --- Phase 1: printer per-extruder retraction settings (first, mirroring the sidebar's + // Printer group), from the printer tab's "Extruder"/"Extruder N" pages. One inner tab per + // extruder (e.g. "Left Extruder"/"Right Extruder" via Tab::translate_category), each holding + // that extruder's Retraction and Z-Hop rows with per-extruder "#N" values. + { + size_t g = section_group_for(Section::Printer); + std::set printer_added; + for (Tab* tab : wxGetApp().tabs_list) { + if (tab->m_type != Preset::TYPE_PRINTER) + continue; + for (const PageShp& page : tab->m_pages) { + if (!page->title().StartsWith("Extruder")) + continue; + // The extruder index of this page: its options are appended with the same + // "#N" opt_index (opt.second.second), so derive the tab's index from the first + // allowlisted option; skip the page when none is found (defensive). + int extruder_idx = -1; + for (const ConfigOptionsGroupShp& optgroup : page->m_optgroups) { + if (optgroup->title != "Retraction" && optgroup->title != "Z-Hop") + continue; + for (const auto& opt : optgroup->opt_map()) + if (extruder_idx < 0) + extruder_idx = opt.second.second; + if (extruder_idx >= 0) + break; + } + if (extruder_idx < 0) + continue; + const wxString page_title = Tab::translate_category(page->title(), tab->m_type); + for (const ConfigOptionsGroupShp& optgroup : page->m_optgroups) { + // Allowlist on the untranslated optgroup title; the "Retraction when + // switching material" group is intentionally skipped. + if (optgroup->title != "Retraction" && optgroup->title != "Z-Hop") + continue; + const wxString subcategory = _(optgroup->title); + for (const auto& opt : optgroup->opt_map()) { + const std::string& opt_id = opt.first; + const std::string& pure_key = opt.second.first; + // Rows are keyed by the full per-extruder "#N" opt_id so each extruder + // tab publishes its own value; GetPublishedKeys() emits the checked rows + // as-is. + if (!printer_added.insert(opt_id).second) + continue; + wxString label, value, unit; + if (!option_text(opt_id, pure_key, label, value, unit)) + continue; + size_t cat_index = category_index_for(page_title, Section::Printer, g, size_t(extruder_idx)); + size_t sub_index = subcategory_index_for(cat_index, subcategory, optgroup->icon); + add_row_ui(opt_id, label, value, unit, cat_index, sub_index); + } + } + } + } + } + + // --- Phase 2: per-material sections synthesized from the filament tab's "Setting + // Overrides" page, under the Filament group. + { + size_t g = section_group_for(Section::Material); + Tab* filament_tab = nullptr; + for (Tab* tab : wxGetApp().tabs_list) + if (tab->m_type == Preset::TYPE_FILAMENT) { + filament_tab = tab; + break; + } + if (filament_tab != nullptr) { + const Page* overrides_page = nullptr; + for (const PageShp& page : filament_tab->m_pages) + if (page->title() == "Setting Overrides") { + overrides_page = page.get(); + break; + } + + if (overrides_page != nullptr) { + // Mixed-color slots are virtual: they carry no per-key Material/Retraction + // settings; their "Enable" toggle always embeds the mix definition (components, + // ratios, gradient). Detect them via the project-level flag. + const ConfigOptionBools* is_mixed_opt = full.opt("filament_is_mixed"); + // Only the slots the project actually uses: base/MMU-painted volume extruders, + // support/wall/infill overrides and custom-gcode tool changes, plus the physical + // components of any in-use mixed slot. Unused slots get no section at all (and are + // therefore never published). One section per used slot, disambiguated by its + // colour chip and slot identity while showing the bare name. + const std::set used_slots = project_used_filament_slots(*bundle, full, wxGetApp().plater()->model()); + for (size_t slot = 0; slot < bundle->filament_presets.size(); ++slot) { + if (used_slots.find(slot) == used_slots.end()) + continue; + const bool is_mixed = is_mixed_opt != nullptr && slot < is_mixed_opt->size() && is_mixed_opt->values[slot]; + const PublishMaterialIdentity identity = material_identity(slot, full); + // A mixed slot's title is its component composition (e.g. "1 (60%) + 2 + // (40%)"), not the cloned preset's name shown in the main GUI. + const wxString title = is_mixed ? mixed_filament_label(full, slot) : material_title(slot, bundle, full); + const size_t category_index = category_index_for(title, Section::Material, g, slot, identity, is_mixed); + + if (is_mixed) { + // A mixed slot publishes as one unit (its definition); nothing to select + // per-key. Its component filaments are auto-enabled + Full Published when + // "Enable" is checked (see on_enable_toggle). Its page still shows what + // would be published: a ratio bar, or the gradient graph. + add_mixed_visual(category_index, make_mixed_visual_spec(full, slot)); + continue; + } + + // Material requirement rows: an optional filament colour and/or a + // vendor-agnostic material type for this slot, in their own optgroup so they + // stay visually separated from the setting rows. + { + const size_t req_sub = subcategory_index_for(category_index, _L("Material"), "custom-gcode_filament"); + add_row_ui("filament_colour", _L("Color"), from_u8(filament_color_hex(full, slot)), wxString(), category_index, + req_sub, RowKind::Color); + std::string type; + if (const auto* types = full.opt("filament_type")) + if (slot < types->size()) + type = types->get_at(slot); + add_row_ui("filament_type", _L("Type"), from_u8(normalize_filament_type(type)), wxString(), category_index, req_sub, + RowKind::Type); + } + + // A material section must not repeat a key; the same key may appear in other + // material sections - that is intended. + std::set material_added; + + for (const ConfigOptionsGroupShp& optgroup : overrides_page->m_optgroups) { + // Allowlist on the untranslated optgroup title; "Ironing" is skipped. + if (optgroup->title != "Retraction" && optgroup->title != "Retraction when switching material") + continue; + for (const auto& opt : optgroup->opt_map()) { + // Row keys are base keys; the load side applies them positionally. + const std::string& opt_id = opt.first; + std::string base = publish_base_key(opt_id); + if (!material_added.insert(base).second) + continue; + // Show the value of this slot; fall back to slot 0 if out of range. + std::string value_opt_id = base + "#" + std::to_string(slot); + if (const ConfigOption* opt_cfg = full.option(base)) + if (const auto* vec = dynamic_cast(opt_cfg)) + if (vec->size() > 0 && slot >= vec->size()) + value_opt_id = base + "#0"; + wxString label, value, unit; + if (!option_text(value_opt_id, base, label, value, unit)) + continue; + size_t sub_index = subcategory_index_for(category_index, _(optgroup->title), optgroup->icon); + add_row_ui(base, label, value, unit, category_index, sub_index); + } + } + } + } + } + } + + // --- Phase 3: process (print) settings; grouping matches the process tab. + { + size_t g = section_group_for(Section::Print); + for (Tab* tab : wxGetApp().tabs_list) { + if (tab->m_type != Preset::TYPE_PRINT) + continue; + size_t page_index = 0; + for (const PageShp& page : tab->m_pages) { + wxString category = Tab::translate_category(page->title(), tab->m_type); + const size_t category_index = category_index_for(category, Section::Print, g, page_index); + + for (const ConfigOptionsGroupShp& optgroup : page->m_optgroups) { + for (const auto& opt : optgroup->opt_map()) { + // opt_map() key is the opt_id (may carry "#N"); the value is + // (pure_opt_key, opt_index). + const std::string& opt_id = opt.first; + const std::string& pure_key = opt.second.first; + // A key may appear in more than one page/group; keep the first. + if (!added.insert(pure_key).second) + continue; + wxString label, value, unit; + if (!option_text(opt_id, pure_key, label, value, unit)) + continue; + size_t sub_index = subcategory_index_for(category_index, _(optgroup->title), optgroup->icon); + add_row_ui(opt_id, label, value, unit, category_index, sub_index); + } + } + ++page_index; + } + } + } + + // Pre-check the dirty (modified) settings and mark them bold (per-slot match, across all + // sections; collect_dirty_settings_keys unions the prints, printers and filaments). + // Dirty keys carry a "#N" per-extruder/per-slot suffix (deep_diff), so the base key alone + // cannot distinguish which extruder/filament slot changed: match the row's exact key, and + // for material rows (base key + per-slot value) the base key plus the section's slot. + std::set dirty_keys; + for (const std::string& key : collect_dirty_settings_keys(*wxGetApp().preset_bundle)) + dirty_keys.insert(key); + for (Row& row : m_rows) { + // The Color/Type requirement rows are not "dirty overrides": never auto-checked. + if (row.kind != RowKind::Setting) + continue; + bool dirty = dirty_keys.count(row.key) != 0; + if (!dirty && row.section == Section::Material) + dirty = dirty_keys.count(publish_base_key(row.key) + "#" + std::to_string(m_categories[row.inner_index].filament_slot)) != 0; + row.dirty = dirty; + if (row.dirty) { + row.check->SetValue(true); + set_row_bold(row, true); + } + } + + // Wire the "Enable" checkboxes: toggling one reveals/hides the slot's settings below the + // header (and, for a mixed slot, auto-selects its components). Bind by index so the lambda + // stays valid even if the vector is reallocated later. + for (size_t c = 0; c < m_categories.size(); ++c) + if (m_categories[c].enable_check != nullptr) + m_categories[c].enable_check->Bind(wxEVT_CHECKBOX, [this, c](wxCommandEvent&) { on_enable_toggle(c); }); + + // Wire the "Full Publish" checkboxes (physical slots): toggling one disables/enables the + // material's rows. + for (size_t c = 0; c < m_categories.size(); ++c) + if (m_categories[c].full_check != nullptr) + m_categories[c].full_check->Bind(wxEVT_CHECKBOX, [this, c](wxCommandEvent&) { on_full_toggle(c); }); + + // No filter is active at startup: every row matches until the user types. + for (Row& row : m_rows) + row.matches_filter = true; + apply_visibility(); + + for (Category& category : m_categories) { + category.scroll->FitInside(); + category.list_sizer->Layout(); + } + for (SectionGroup& section : m_sections) + if (!section.categories.empty()) + section.tabs->SelectItem(0); + if (!m_sections.empty()) { + m_outer_tabs->SelectItem(0); + show_outer_page(0); + } + refresh_tab_indicators(); +} + +size_t PublishSettingsDialog::section_group_for(Section kind) +{ + for (size_t i = 0; i < m_sections.size(); ++i) + if (m_sections[i].kind == kind) + return i; + + SectionGroup section; + section.kind = kind; + const size_t new_index = m_sections.size(); + switch (kind) { + case Section::Printer: + section.title = _L("Printer"); + section.icon_name = "printer"; + break; + case Section::Material: + section.title = _L("Filament"); + section.icon_name = "filament"; + break; + case Section::Print: + section.title = _L("Process"); + section.icon_name = "process"; + break; + } + section.icon_bmp = ScalableBitmap(this, section.icon_name, 16); + + section.page = new wxPanel(m_outer_host, wxID_ANY); + section.page->SetBackgroundColour(GetBackgroundColour()); + auto* page_sizer = new wxBoxSizer(wxVERTICAL); + section.tabs = new TabCtrl(section.page, wxID_ANY, wxDefaultPosition, wxDefaultSize, s_tab_style); + section.tabs->SetFont(Label::Body_14); + section.tabs->SetBackgroundColour(GetBackgroundColour()); + page_sizer->Add(section.tabs, 0, wxEXPAND); + // Mixed-color filament slots get a second tab strip below the physical filament tabs; only + // the Material section has them. + if (kind == Section::Material) { + section.mixed_tabs = new TabCtrl(section.page, wxID_ANY, wxDefaultPosition, wxDefaultSize, s_tab_style); + section.mixed_tabs->SetFont(Label::Body_14); + section.mixed_tabs->SetBackgroundColour(GetBackgroundColour()); + // The mixed tabs carry full swatch compositions: give them a touch more room than the + // filament tabs so neighbouring compositions stay distinguishable (must precede AppendItem). + section.mixed_tabs->SetItemSpace(FromDIP(3)); + page_sizer->Add(section.mixed_tabs, 0, wxEXPAND | wxTOP, FromDIP(2)); + section.mixed_tabs->Hide(); + } + section.page_host = new wxPanel(section.page, wxID_ANY); + section.page_host->SetBackgroundColour(GetBackgroundColour()); + section.page_host_sizer = new wxBoxSizer(wxVERTICAL); + section.page_host->SetSizer(section.page_host_sizer); + page_sizer->Add(section.page_host, 1, wxEXPAND | wxTOP, FromDIP(4)); + section.page->SetSizer(page_sizer); + + if (section.icon_bmp.bmp().IsOk()) + m_outer_tabs->AppendItem(section.title, section.icon_bmp.bmp()); + else + m_outer_tabs->AppendItem(section.title); + m_outer_host_sizer->Add(section.page, 1, wxEXPAND); + section.page->Hide(); + m_sections.push_back(std::move(section)); + return new_index; +} + +size_t PublishSettingsDialog::category_index_for( + const wxString& title, Section section, size_t group, size_t source_index, const PublishMaterialIdentity& identity, bool is_mixed) +{ + // Dedup across both tab rows (physical + mixed); mixed slots are never duplicated anyway. + auto match = [&](const Category& existing) { + return existing.title == title && existing.section == section && existing.source_index == source_index && + existing.filament_id == identity.id && existing.filament_type == identity.type && + existing.filament_vendor == identity.vendor; + }; + for (size_t i : m_sections[group].categories) + if (match(m_categories[i])) + return i; + for (size_t i : m_sections[group].mixed_categories) + if (match(m_categories[i])) + return i; + + Category category; + category.title = title; + category.section = section; + category.group = group; + category.source_index = source_index; + category.filament_type = identity.type; + category.filament_vendor = identity.vendor; + category.filament_id = identity.id; + category.filament_slot = source_index; + category.is_mixed = is_mixed; + category.page = new wxPanel(m_sections[group].page_host, wxID_ANY); + category.page->SetBackgroundColour(GetBackgroundColour()); + auto* page_sizer = new wxBoxSizer(wxVERTICAL); + + // The physical slot's colour chip decorates the page header and the inner tab; it carries + // the 1-based slot number, mirroring the main GUI's filament swatches. A mixed slot has no + // header at all: its page is just the Enable toggle above the (always visible) definition + // preview - the identification lives in the tab strip's composition bitmap. + std::string hex; + std::string chip_label; + if (section == Section::Material && !is_mixed) { + const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); + hex = filament_color_hex(full, source_index); + chip_label = std::to_string(source_index + 1); + } + + if (section == Section::Material) { + if (is_mixed) { + // No chip/title: the lone "Enable" checkbox tops the page. + auto* enable_sizer = new wxBoxSizer(wxHORIZONTAL); + category.enable_check = new wxCheckBox(category.page, wxID_ANY, _L("Enable")); + category.enable_check->SetFont(Label::Body_13); + category.enable_check->SetToolTip(_L("Publish this mixed filament and enable + Full Publish its component filaments")); + enable_sizer->Add(category.enable_check, 0, wxALIGN_CENTER_VERTICAL); + page_sizer->Add(enable_sizer, 0, wxEXPAND | wxTOP | wxLEFT | wxRIGHT, FromDIP(6)); + } else { + // Line 1: [chip] [title] [Enable]. The Enable checkbox gates the whole slot: while + // it is unchecked nothing below the title is shown and nothing of it is published. + auto* header_sizer = new wxBoxSizer(wxHORIZONTAL); + if (wxBitmap* chip = get_extruder_color_icon(hex, chip_label, FromDIP(20), FromDIP(20))) { + category.filament_color_chip = new wxStaticBitmap(category.page, wxID_ANY, *chip); + header_sizer->Add(category.filament_color_chip, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); + } + category.title_label = new wxStaticText(category.page, wxID_ANY, title); + category.title_label->SetFont(Label::Head_14); + header_sizer->Add(category.title_label, 0, wxALIGN_CENTER_VERTICAL); + category.enable_check = new wxCheckBox(category.page, wxID_ANY, _L("Enable")); + category.enable_check->SetFont(Label::Body_13); + category.enable_check->SetToolTip(_L("Publish this filament slot in the 3MF file")); + header_sizer->Add(category.enable_check, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(10)); + page_sizer->Add(header_sizer, 0, wxEXPAND | wxTOP | wxLEFT | wxRIGHT, FromDIP(6)); + + // Line 2: the "Full Publish" toggle, on its own line below the title (hidden until + // the slot is enabled), aligned with the colour chip above it. + auto* full_sizer = new wxBoxSizer(wxHORIZONTAL); + category.full_check = new wxCheckBox(category.page, wxID_ANY, _L("Full Publish")); + category.full_check->SetFont(Label::Body_13); + category.full_check->SetToolTip(_L("Embed the entire filament of this slot in the 3MF file")); + full_sizer->Add(category.full_check, 0, wxALIGN_CENTER_VERTICAL); + category.full_line_item = page_sizer->Add(full_sizer, 0, wxEXPAND | wxTOP | wxLEFT | wxRIGHT, FromDIP(6)); + } + } + + category.scroll = new wxScrolledWindow(category.page, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxVSCROLL); + category.scroll->SetScrollRate(0, 10); + category.scroll->SetBackgroundColour(GetBackgroundColour()); + category.list_sizer = new wxBoxSizer(wxVERTICAL); + category.scroll->SetSizer(category.list_sizer); + category.scroll->DisableFocusFromKeyboard(); + category.scroll->Bind(wxEVT_RIGHT_DOWN, &PublishSettingsDialog::show_menu, this); + category.info = new wxStaticText(category.scroll, wxID_ANY, is_mixed ? m_info_mix : m_info_empty); + category.info->SetFont(Label::Body_13); + category.list_sizer->Add(category.info, 1, wxALIGN_CENTER_HORIZONTAL | wxALL, FromDIP(10)); + category.info->Hide(); + page_sizer->Add(category.scroll, 1, wxEXPAND | wxALL, FromDIP(4)); + // A material slot starts disabled: its rows (and its Full Publish line) stay hidden until + // "Enable" is checked. + if (section == Section::Material) + category.scroll->Hide(); + category.page->SetSizer(page_sizer); + category.page->Hide(); + + const size_t category_index = m_categories.size(); + m_categories.push_back(std::move(category)); + // Mixed slots live in a second tab row below the physical filament tabs. + if (is_mixed) + m_sections[group].mixed_categories.push_back(category_index); + else + m_sections[group].categories.push_back(category_index); + if (section == Section::Material) { + TabCtrl* target = is_mixed ? m_sections[group].mixed_tabs : m_sections[group].tabs; + if (is_mixed) { + // The tab's whole composition (mix chip + components + percents) lives in one + // bitmap; the text is empty because a TabCtrl item cannot interleave images into + // its text. + const DynamicPrintConfig full_cfg = wxGetApp().preset_bundle->full_config(); + const wxBitmap tab_bmp = mixed_filament_tab_bitmap(full_cfg, source_index, FromDIP(20), target->GetBackgroundColour()); + if (tab_bmp.IsOk()) + target->AppendItem(wxString(), tab_bmp); + else + target->AppendItem(title); + } else if (wxBitmap* chip = get_extruder_color_icon(hex, chip_label, FromDIP(20), FromDIP(20))) { + target->AppendItem(title, *chip); + } else { + target->AppendItem(title); + } + } else { + m_sections[group].tabs->AppendItem(title); + } + m_sections[group].page_host_sizer->Add(m_categories[category_index].page, 1, wxEXPAND); + if (is_mixed) { + if (m_sections[group].selected_mixed < 0) + m_sections[group].selected_mixed = 0; + m_sections[group].mixed_tabs->Show(); + } else if (m_sections[group].selected_inner < 0) { + m_sections[group].selected_inner = 0; + } + return category_index; +} + +size_t PublishSettingsDialog::subcategory_index_for(size_t category_index, const wxString& title, const wxString& icon) +{ + Category& category = m_categories[category_index]; + for (size_t i = 0; i < category.subs.size(); ++i) + if (category.subs[i].title == title) + return i; + + Subcategory sub; + sub.title = title; + if (!title.IsEmpty()) { + sub.header = new ::StaticLine(category.scroll, false, title, icon); + sub.header->SetFont(Label::Head_14); + sub.header->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#363636"))); + auto* wrap = new wxBoxSizer(wxVERTICAL); + wrap->Add(sub.header, 0, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(6)); + sub.item = category.list_sizer->Add(wrap, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(22)); + } + category.subs.push_back(std::move(sub)); + return category.subs.size() - 1; +} + +void PublishSettingsDialog::add_row_ui(const std::string& key, + const wxString& label, + const wxString& value, + const wxString& unit, + size_t category_index, + size_t subcategory_index, + RowKind kind) +{ + Category& category = m_categories[category_index]; + Row row; + row.key = key; + row.label = label; + row.value = value; + row.unit = unit; + row.kind = kind; + row.category = category.title; + row.subcategory = category.subs[subcategory_index].title; + row.section = category.section; + row.section_title = m_sections[category.group].title; + row.outer_index = category.group; + row.inner_index = category_index; + const size_t row_index = m_rows.size(); + m_rows.push_back(std::move(row)); + Row& current = m_rows[row_index]; + current.check = new wxCheckBox(category.scroll, wxID_ANY, label); + current.check->SetFont(Label::Body_13); + current.check->Bind(wxEVT_CHECKBOX, [this](wxCommandEvent&) { refresh_tab_indicators(); }); + auto* row_sizer = new wxBoxSizer(wxHORIZONTAL); + row_sizer->Add(current.check, 0, wxALIGN_CENTER_VERTICAL); + // The value is read-only text (incl. the Type row: the published type is the slot's + // normalized type, not author-editable). + current.value_label = new wxStaticText(category.scroll, wxID_ANY, value, wxDefaultPosition, wxDefaultSize, wxST_ELLIPSIZE_END); + current.value_label->SetFont(Label::Body_13); + current.value_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#262E30"))); + current.value_label->SetToolTip(unit.IsEmpty() ? value : value + " " + unit); + if (kind == RowKind::Color && !value.IsEmpty()) { + // The colour swatch carries this slot's 1-based number, like the main GUI swatches. + const std::string chip_label = std::to_string(category.filament_slot + 1); + if (wxBitmap* chip = get_extruder_color_icon(value.ToStdString(), chip_label, FromDIP(20), FromDIP(20))) { + current.color_chip = new wxStaticBitmap(category.scroll, wxID_ANY, *chip); + row_sizer->Add(current.color_chip, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); + } + } + row_sizer->Add(current.value_label, 1, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); + if (!unit.IsEmpty()) { + current.unit_label = new wxStaticText(category.scroll, wxID_ANY, unit); + current.unit_label->SetFont(Label::Body_13); + current.unit_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#6B6B6B"))); + row_sizer->Add(current.unit_label, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(4)); + } + current.item = category.list_sizer->Add(row_sizer, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(38)); + category.rows.push_back(row_index); + category.subs[subcategory_index].rows.push_back(row_index); +} + +void PublishSettingsDialog::on_full_toggle(size_t category_index) +{ + Category& cat = m_categories[category_index]; + const bool full = cat.full_check->GetValue(); + for (size_t r : cat.rows) + m_rows[r].check->Enable(!full); + refresh_tab_indicators(); +} + +void PublishSettingsDialog::on_enable_toggle(size_t category_index) +{ + Category& cat = m_categories[category_index]; + const bool enabled = cat.enable_check->GetValue(); + + // A published mixed filament needs its component filaments published too: mark the slots it + // uses as "Enable"d and Full Published so the mix's physical components always ship their + // identities. + if (cat.is_mixed && enabled) { + const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); + const std::vector components = mixed_slot_components(full, cat.filament_slot); + for (const unsigned int component : components) { + // Components are 1-based physical filament indices. + const size_t component_slot = size_t(component) - 1; + for (size_t c = 0; c < m_categories.size(); ++c) { + Category& comp_cat = m_categories[c]; + if (comp_cat.section != Section::Material || comp_cat.is_mixed || comp_cat.filament_slot != component_slot) + continue; + if (comp_cat.enable_check != nullptr) + comp_cat.enable_check->SetValue(true); + if (comp_cat.full_check != nullptr) + comp_cat.full_check->SetValue(true); + on_full_toggle(c); + } + } + } + + // Reveal/hide everything below the slot's header (rows, info, Full Publish line) and refresh + // the visibility of the auto-selected component slots. + apply_visibility(); + if (cat.page != nullptr) + cat.page->GetSizer()->Layout(); + refresh_tab_indicators(); +} + +void PublishSettingsDialog::add_mixed_visual(size_t category_index, const MixedVisualSpec& spec) +{ + if (category_index >= m_categories.size() || !spec.valid) + return; + Category& category = m_categories[category_index]; + if (category.page == nullptr || category.page->GetSizer() == nullptr || category.scroll == nullptr || spec.component_colours.empty()) + return; + + auto* viz = new wxPanel(category.page, wxID_ANY); + viz->SetBackgroundStyle(wxBG_STYLE_PAINT); + // Theme colours and DIP metrics are resolved inside the paint handler so dark-mode toggles + // and DPI changes are picked up on the next repaint without any explicit listener. The + // ternary fill cache lives inside the shared triangle painter (FilamentBitmapUtils). + viz->Bind(wxEVT_PAINT, [this, panel = viz, spec](wxPaintEvent&) { + const wxColour bg = StateColor::darkModeColorFor(*wxWHITE); + wxBufferedPaintDC pdc(panel); + pdc.SetBackground(wxBrush(bg)); + pdc.Clear(); + const wxRect rc = panel->GetClientRect(); + if (rc.width <= 0 || rc.height <= 0) + return; + + const size_t n = spec.component_colours.size(); + + if (spec.tri_weights.size() == 3 && n == 3 && !spec.is_gradient) { + // Ternary mix: read-only miniature of the MixedFilamentDialog's triangle picker. + const MixedTriangleTheme tri_theme{StateColor::darkModeColorFor(*wxWHITE), StateColor::darkModeColorFor(wxColour("#CECECE")), + StateColor::darkModeColorFor(wxColour("#262E30")), + StateColor::darkModeColorFor(wxColour(107, 107, 107))}; + draw_mixed_triangle_picker(pdc, rc.GetSize(), {spec.component_colours[0], spec.component_colours[1], spec.component_colours[2]}, + {spec.tri_weights[0], spec.tri_weights[1], spec.tri_weights[2]}, tri_theme); + draw_mixed_triangle_labels(pdc, rc.GetSize(), {spec.tri_weights[0], spec.tri_weights[1], spec.tri_weights[2]}, tri_theme); + } else if (!spec.is_gradient) { + // Ratio bar. A 2-component slot matches the MixedFilamentDialog's continuous blend + + // divider (with its two end labels); wider non-gradient mixes (rare) fall back to one + // solid segment per component. + if (n == 2) { + const int bar_h = FromDIP(27); + draw_mixed_ratio_blend_bar(pdc, wxRect(rc.x, rc.y, rc.width, bar_h), spec.component_colours[0], spec.component_colours[1], + spec.ratios[1]); + // Read-only twin of the dialog's left/right percentage labels. + pdc.SetFont(::Label::Body_12); + pdc.SetTextForeground(StateColor::darkModeColorFor(wxColour(107, 107, 107))); + const wxString la = wxString::Format("%d%%", int(std::lround(spec.ratios[0] * 100.0))); + const wxString lb = wxString::Format("%d%%", int(std::lround(spec.ratios[1] * 100.0))); + const int lab_y = rc.y + bar_h + FromDIP(2); + pdc.DrawText(la, rc.x, lab_y); + pdc.DrawText(lb, rc.x + rc.width - pdc.GetTextExtent(lb).GetWidth(), lab_y); + } else { + draw_mixed_ratio_segments(pdc, rc, spec.component_colours, spec.ratios); + // Percent label centred in each segment wide enough to hold it. + std::vector shares = spec.ratios; + double total = 0.0; + for (double r : shares) + total += r; + if (total <= 0.0) { + shares.assign(n, 1.0 / n); + total = 1.0; + } + pdc.SetFont(::Label::Body_12); + int x0 = rc.x; + for (size_t i = 0; i < n; ++i) { + const int x1 = (i + 1 < n) ? rc.x + int(std::lround(std::accumulate(shares.begin(), shares.begin() + i + 1, 0.0) / + total * double(rc.width))) : + rc.x + rc.width; + const int w = std::max(1, x1 - x0); + const wxString text = wxString::Format("%d%%", int(std::lround(shares[i] / total * 100.0))); + const wxSize tsz = pdc.GetTextExtent(text); + if (tsz.GetWidth() + FromDIP(4) <= w) { + const wxColour& c = spec.component_colours[i]; + const double lum = 0.299 * c.Red() + 0.587 * c.Green() + 0.114 * c.Blue(); + pdc.SetTextForeground(lum > 140 ? wxColour("#262E30") : *wxWHITE); + pdc.DrawText(text, x0 + (w - tsz.GetWidth()) / 2, rc.y + (rc.height - tsz.GetHeight()) / 2); + } + x0 = x1; + } + } + } else { + // Gradient: read-only miniature of the GradientCurveEditor plot, drawn through the + // shared painter so it anti-aliases and keeps the square proportions and labels. + const MixedGradientTheme grad_theme{StateColor::darkModeColorFor(*wxWHITE), + StateColor::darkModeColorFor(wxColour(238, 238, 238)), + StateColor::darkModeColorFor(wxColour(107, 107, 107)), + StateColor::darkModeColorFor(wxColour(107, 107, 107)), + StateColor::darkModeColorFor(wxColour(38, 46, 48)), + StateColor::darkModeColorFor(wxColour(172, 172, 172)), + StateColor::darkModeColorFor(*wxWHITE)}; + std::vector curves; + std::vector anchors; + if (spec.gradient_samples.size() >= 2 && n >= 2) { + const wxRect plot = mixed_gradient_plot_rect(rc.GetSize()); + auto lift_alpha = [](wxColour c) { + if (c.Alpha() == 0) + c.Set(c.Red(), c.Green(), c.Blue(), 150); + return c; + }; + // First component's ratio solid, its mirror twin for the other. + const wxColour col_a = lift_alpha(spec.component_colours[0]); + const wxColour col_b = lift_alpha(spec.component_colours[1]); + std::vector pts_a, pts_b; + pts_a.reserve(spec.gradient_samples.size()); + pts_b.reserve(spec.gradient_samples.size()); + for (const auto& [t, r] : spec.gradient_samples) { + pts_a.push_back({plot.x + t * plot.width, plot.y + (1.0 - r) * plot.height}); + pts_b.push_back({plot.x + t * plot.width, plot.y + r * plot.height}); + } + // Control-point anchors of the stored curve on the first component's line. + anchors.reserve(spec.gradient_anchors.size()); + for (const auto& [t, r] : spec.gradient_anchors) + anchors.push_back({plot.x + t * plot.width, plot.y + (1.0 - r) * plot.height}); + curves.push_back({std::move(pts_a), col_a, 4}); + curves.push_back({std::move(pts_b), col_b, 2}); + } + draw_mixed_gradient_plot(pdc, rc.GetSize(), curves, anchors, grad_theme); + } + }); + + // Fixed DIP size, left-aligned: the visualization keeps its proportions no matter how the + // dialog is resized (the paint handler draws into whatever client rect the panel ends up + // with, so nothing else has to change). Sizes mirror the MixedFilamentDialog controls: the + // gradient plot and triangle picker match the editor's 260x200 / 160x160, the ratio bar is + // the dialog's 27px bar plus its label line. + int viz_h; + int viz_w; + if (spec.is_gradient) { + viz_w = 260; + viz_h = 200; + } else if (spec.tri_weights.size() == 3 && spec.component_colours.size() == 3) { + viz_w = 160; + viz_h = 160; + } else { + viz_w = 240; + viz_h = spec.component_colours.size() == 2 ? 50 : 30; + } + const wxSize viz_sz(FromDIP(viz_w), FromDIP(viz_h)); + viz->SetMinSize(viz_sz); + viz->SetMaxSize(viz_sz); + // Parented to the page right above the scroll area, so it is always shown with the tab: + // the "Enable" toggle keeps gating only the rows/info below, never this preview. + wxSizer* page_sizer = category.page->GetSizer(); + int scroll_idx = -1; + for (size_t i = 0; i < page_sizer->GetChildren().size(); ++i) + if (page_sizer->GetChildren()[i]->GetWindow() == category.scroll) { + scroll_idx = int(i); + break; + } + if (scroll_idx < 0) + page_sizer->Add(viz, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(10)); // defensive: no scroll item + else + page_sizer->Insert(scroll_idx, viz, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(10)); +} + +void PublishSettingsDialog::set_row_bold(Row& row, bool bold) +{ + // Rebase on the dialog's body font so clearing bold restores the exact original font. + row.check->SetFont(bold ? Label::Body_13.Bold() : Label::Body_13); +} + +void PublishSettingsDialog::save_scroll_position(Category& category) +{ + if (category.scroll != nullptr) + category.scroll->GetViewStart(&category.scroll_pos.x, &category.scroll_pos.y); +} + +void PublishSettingsDialog::show_outer_page(size_t section_index) +{ + if (section_index >= m_sections.size()) + return; + if (m_selected_outer >= 0 && m_selected_outer < static_cast(m_sections.size())) { + SectionGroup& old_section = m_sections[m_selected_outer]; + if (old_section.selected_inner >= 0 && old_section.selected_inner < static_cast(old_section.categories.size())) + save_scroll_position(m_categories[old_section.categories[old_section.selected_inner]]); + if (old_section.selected_mixed >= 0 && old_section.selected_mixed < static_cast(old_section.mixed_categories.size())) + save_scroll_position(m_categories[old_section.mixed_categories[old_section.selected_mixed]]); + m_sections[m_selected_outer].page->Hide(); + } + m_selected_outer = static_cast(section_index); + SectionGroup& section = m_sections[section_index]; + section.page->Show(); + // Restore whichever tab row (physical or mixed) was active. + if (section.selected_inner >= 0) + show_inner_page(section_index, section.selected_inner); + else if (section.selected_mixed >= 0) + show_mixed_page(section_index, section.selected_mixed); + m_outer_host_sizer->Layout(); +} + +void PublishSettingsDialog::show_inner_page(size_t section_index, int inner_index) +{ + if (section_index >= m_sections.size()) + return; + SectionGroup& section = m_sections[section_index]; + if (inner_index < 0 || inner_index >= static_cast(section.categories.size())) + return; + if (section.selected_inner >= 0 && section.selected_inner < static_cast(section.categories.size())) { + save_scroll_position(m_categories[section.categories[section.selected_inner]]); + m_categories[section.categories[section.selected_inner]].page->Hide(); + } + if (section.selected_mixed >= 0 && section.selected_mixed < static_cast(section.mixed_categories.size())) { + save_scroll_position(m_categories[section.mixed_categories[section.selected_mixed]]); + m_categories[section.mixed_categories[section.selected_mixed]].page->Hide(); + section.selected_mixed = -1; + } + section.selected_inner = inner_index; + Category& category = m_categories[section.categories[inner_index]]; + category.page->Show(); + category.scroll->FitInside(); + category.scroll->Scroll(category.scroll_pos.x, category.scroll_pos.y); + if (section.mixed_tabs != nullptr) + section.mixed_tabs->Unselect(); + section.page_host_sizer->Layout(); +} + +void PublishSettingsDialog::show_mixed_page(size_t section_index, int mixed_index) +{ + if (section_index >= m_sections.size()) + return; + SectionGroup& section = m_sections[section_index]; + if (mixed_index < 0 || mixed_index >= static_cast(section.mixed_categories.size())) + return; + if (section.selected_mixed >= 0 && section.selected_mixed < static_cast(section.mixed_categories.size())) { + save_scroll_position(m_categories[section.mixed_categories[section.selected_mixed]]); + m_categories[section.mixed_categories[section.selected_mixed]].page->Hide(); + } + if (section.selected_inner >= 0 && section.selected_inner < static_cast(section.categories.size())) { + save_scroll_position(m_categories[section.categories[section.selected_inner]]); + m_categories[section.categories[section.selected_inner]].page->Hide(); + section.selected_inner = -1; + } + section.selected_mixed = mixed_index; + Category& category = m_categories[section.mixed_categories[mixed_index]]; + category.page->Show(); + category.scroll->FitInside(); + category.scroll->Scroll(category.scroll_pos.x, category.scroll_pos.y); + section.tabs->Unselect(); + section.page_host_sizer->Layout(); +} + +void PublishSettingsDialog::on_outer_tab_changed(wxCommandEvent& event) +{ + const int selection = event.GetInt(); + if (selection >= 0 && selection < static_cast(m_sections.size())) + show_outer_page(static_cast(selection)); +} + +void PublishSettingsDialog::on_inner_tab_changed(size_t section_index, wxCommandEvent& event) +{ + const int selection = event.GetInt(); + if (section_index < m_sections.size() && selection >= 0 && selection < static_cast(m_sections[section_index].categories.size())) + show_inner_page(section_index, selection); +} + +void PublishSettingsDialog::on_mixed_tab_changed(size_t section_index, wxCommandEvent& event) +{ + const int selection = event.GetInt(); + if (section_index < m_sections.size() && selection >= 0 && + selection < static_cast(m_sections[section_index].mixed_categories.size())) + show_mixed_page(section_index, selection); +} + +void PublishSettingsDialog::bind_tab_events() +{ + m_outer_tabs->Bind(wxEVT_TAB_SEL_CHANGED, &PublishSettingsDialog::on_outer_tab_changed, this); + for (size_t section_index = 0; section_index < m_sections.size(); ++section_index) { + m_sections[section_index].tabs->Bind(wxEVT_TAB_SEL_CHANGED, + [this, section_index](wxCommandEvent& event) { on_inner_tab_changed(section_index, event); }); + if (m_sections[section_index].mixed_tabs != nullptr) + m_sections[section_index].mixed_tabs->Bind(wxEVT_TAB_SEL_CHANGED, [this, section_index](wxCommandEvent& event) { + on_mixed_tab_changed(section_index, event); + }); + } +} + +void PublishSettingsDialog::apply_filter(const wxString& filter_text) +{ + m_filter_mode = FilterMode::Text; + refresh_filter(filter_text.Lower()); +} + +void PublishSettingsDialog::apply_pseudo_filter(bool selected_only) +{ + m_filter_mode = selected_only ? FilterMode::SelectedOnly : FilterMode::UnselectedOnly; + refresh_filter(wxString()); // the pseudo modes ignore the search text +} + +void PublishSettingsDialog::refresh_filter(const wxString& filter) +{ + Freeze(); + const bool pseudo = m_filter_mode != FilterMode::Text; + const bool want_checked = m_filter_mode == FilterMode::SelectedOnly; + m_fb_sizer->Show(!pseudo); + if (pseudo) { + // "×" marks the chip as a dismissible filter state (U+00D7, present in all UI fonts). + m_pseudo_chip->SetLabel((want_checked ? _L("Filter selected") : _L("Filter non-selected")) + " " + wxString::FromUTF8("\u00d7")); + } + m_pseudo_chip->Show(pseudo); + + // Row matches are computed first; page and optgroup visibility is applied below. + if (pseudo) { + for (Row& row : m_rows) + row.matches_filter = row.check->IsEnabled() && row.check->GetValue() == want_checked; + } else { + const bool clear = filter.IsEmpty(); + for (Row& row : m_rows) { + row.matches_filter = clear || (row.section_title + " " + row.category + " " + row.subcategory + " " + row.label + " " + + row.value + " " + row.unit) + .Lower() + .Contains(filter); + } + } + + size_t first_outer = 0; + int first_inner = -1; + bool active_has_match = false; + for (size_t s = 0; s < m_sections.size(); ++s) { + for (size_t inner = 0; inner < m_sections[s].categories.size(); ++inner) { + Category& category = m_categories[m_sections[s].categories[inner]]; + bool has_match = false; + for (size_t r : category.rows) + has_match = has_match || m_rows[r].matches_filter; + category.info->Show(!has_match); + if (!has_match) + // A mixed slot has no selectable rows: always explain it is published whole. + category.info->SetLabel(category.is_mixed ? m_info_mix : + (pseudo ? (want_checked ? m_info_nonsel : m_info_allsel) : m_info_empty)); + if (has_match && first_inner < 0) { + first_outer = s; + first_inner = static_cast(inner); + } + if (static_cast(s) == m_selected_outer && static_cast(inner) == m_sections[s].selected_inner) + active_has_match = has_match; + } + // Mixed slots have no rows: they can never match a filter, so they always fall back to + // their explanatory hint (shown once the slot is enabled). + for (size_t mixed : m_sections[s].mixed_categories) { + Category& category = m_categories[mixed]; + category.info->Show(true); + category.info->SetLabel(m_info_mix); + } + } + if (!active_has_match && first_inner >= 0 && + (m_selected_outer != static_cast(first_outer) || m_sections[first_outer].selected_inner != first_inner)) { + if (m_selected_outer != static_cast(first_outer)) { + m_outer_tabs->SelectItem(static_cast(first_outer)); + show_outer_page(first_outer); + } + m_sections[first_outer].tabs->SelectItem(first_inner); + show_inner_page(first_outer, first_inner); + } + + apply_visibility(); + for (Category& category : m_categories) { + save_scroll_position(category); + category.scroll->FitInside(); + category.list_sizer->Layout(); + category.scroll->Scroll(category.scroll_pos.x, category.scroll_pos.y); + } + Layout(); + Thaw(); +} + +void PublishSettingsDialog::apply_visibility() +{ + Freeze(); + for (Category& category : m_categories) { + // A disabled material slot hides everything below its header (rows, info and the Full + // Publish line); enable it first to reveal its settings. + const bool enabled = category.section != Section::Material || + (category.enable_check != nullptr && category.enable_check->GetValue()); + if (category.scroll != nullptr) + category.scroll->Show(enabled); + if (category.full_line_item != nullptr) + category.full_line_item->Show(enabled); + bool category_any = false; + for (size_t r : category.rows) + category_any = category_any || m_rows[r].matches_filter; + category.info->Show(enabled && !category_any); + for (Subcategory& sub : category.subs) { + bool sub_any = false; + for (size_t r : sub.rows) + sub_any = sub_any || m_rows[r].matches_filter; + if (sub.header != nullptr) + sub.item->Show(enabled && sub_any); + for (size_t r : sub.rows) + m_rows[r].item->Show(enabled && m_rows[r].matches_filter); + } + category.list_sizer->Layout(); + category.scroll->FitInside(); + } + Thaw(); +} + +void PublishSettingsDialog::select_all(bool value) +{ + // "All" skips disabled (gated) rows; "None" leaves a gated row's preserved value. + for (Row& row : m_rows) + if (row.check->IsEnabled()) + row.check->SetValue(value); + // "All" also enables every material slot (so its rows/Full Publish become visible and the + // selection is actually exported); "None" disables them all again. + for (Category& cat : m_categories) + if (cat.section == Section::Material && cat.enable_check != nullptr) + cat.enable_check->SetValue(value); + // wxCheckBox::SetValue does not emit wxEVT_CHECKBOX, so re-run the enable handlers to + // propagate mixed-slot components and refresh visibility as if the user had clicked. + for (size_t c = 0; c < m_categories.size(); ++c) + if (m_categories[c].section == Section::Material) + on_enable_toggle(c); + apply_visibility(); + refresh_tab_indicators(); +} + +bool PublishSettingsDialog::row_is_visible(const Row& row) const +{ + if (m_selected_outer < 0 || m_selected_outer >= static_cast(m_sections.size()) || + row.outer_index != static_cast(m_selected_outer) || m_sections[m_selected_outer].selected_inner < 0 || + m_sections[m_selected_outer].selected_inner >= static_cast(m_sections[m_selected_outer].categories.size()) || + row.inner_index != m_sections[m_selected_outer].categories[m_sections[m_selected_outer].selected_inner] || !row.matches_filter || + !row.check->IsEnabled()) + return false; + return row.item->IsShown(); +} + +void PublishSettingsDialog::select_visible(bool value) +{ + // In a pseudo-filter view the rows being toggled would all disappear; drop the filter + // afterwards so the result stays visible. + const bool clear_pseudo = (m_filter_mode == FilterMode::UnselectedOnly && !value) || + (m_filter_mode == FilterMode::SelectedOnly && value); + + // Toggle the rows visible under the *current* filter. + for (Row& row : m_rows) + if (row_is_visible(row)) + row.check->SetValue(value); + + if (clear_pseudo) { + // Note: SetValue() may fire wxEVT_TEXT on some platforms, re-entering apply_filter() - + // that is fine; the rows above were already toggled and the trailing call is idempotent. + m_filter_ctrl->ChangeValue(""); + apply_filter(""); // resync visibility and the All/None bar + } + refresh_tab_indicators(); +} + +void PublishSettingsDialog::show_menu(wxMouseEvent& evt) +{ + bool filtering = !m_filter_ctrl->GetValue().IsEmpty() || m_filter_mode != FilterMode::Text; + bool list_empty = true; + if (m_selected_outer >= 0) { + for (const Row& row : m_rows) + if (row_is_visible(row)) { + list_empty = false; + break; + } + } + + wxMenu m; + m.Append(kPublishSelectAll, _L("Select All"))->Enable(!filtering); + m.Append(kPublishDeselectAll, _L("Deselect All"))->Enable(!filtering); + m.AppendSeparator(); + m.Append(kPublishSelectVisible, _L("Select visible"))->Enable(!list_empty && filtering); + m.Append(kPublishDeselectVisible, _L("Deselect visible"))->Enable(!list_empty && filtering); + m.AppendSeparator(); + m.AppendCheckItem(kPublishFilterSelected, _L("Filter selected")); + m.AppendCheckItem(kPublishFilterNonSelected, _L("Filter non-selected")); + m.Check(kPublishFilterSelected, m_filter_mode == FilterMode::SelectedOnly); + m.Check(kPublishFilterNonSelected, m_filter_mode == FilterMode::UnselectedOnly); + + m.Bind( + wxEVT_MENU, + [this](wxCommandEvent& e) { + switch (e.GetId()) { + case kPublishSelectAll: select_all(true); break; + case kPublishDeselectAll: select_all(false); break; + case kPublishSelectVisible: select_visible(true); break; + case kPublishDeselectVisible: select_visible(false); break; + case kPublishFilterSelected: + // Clicking the active entry again clears the pseudo filter. + if (m_filter_mode == FilterMode::SelectedOnly) + apply_filter(m_filter_ctrl->GetValue()); + else + apply_pseudo_filter(true); + break; + case kPublishFilterNonSelected: + if (m_filter_mode == FilterMode::UnselectedOnly) + apply_filter(m_filter_ctrl->GetValue()); + else + apply_pseudo_filter(false); + break; + default: break; + } + }, + kPublishSelectAll, kPublishFilterNonSelected); + + wxWindow* src = dynamic_cast(evt.GetEventObject()); + if (!src) + return; + wxPoint screen_pos = src->ClientToScreen(evt.GetPosition()); + wxPoint local_pos = ScreenToClient(screen_pos); + PopupMenu(&m, local_pos); +} + +void PublishSettingsDialog::apply_selection(const std::vector& published_keys, + const std::vector& material_keys) +{ + // The supplied selection is authoritative: clear the dirty-default pre-check first so a + // dirty key the user deselected stays off, then re-select exactly what the selection names. + for (Row& row : m_rows) + if (row.check != nullptr) + row.check->SetValue(false); + for (Category& cat : m_categories) { + if (cat.section != Section::Material) + continue; + if (cat.enable_check != nullptr) + cat.enable_check->SetValue(false); + if (cat.full_check != nullptr) + cat.full_check->SetValue(false); + } + + // Print / printer rows: match by exact row key (the full "#N" opt_id where present). + for (const std::string& key : published_keys) + for (Row& row : m_rows) + if ((row.section == Section::Print || row.section == Section::Printer) && row.check != nullptr && row.key == key) { + row.check->SetValue(true); + break; + } + + // Per-slot material selections, applied positionally by slot. + for (const Slic3r::PublishedMaterialEntry& entry : material_keys) { + if (entry.slot < 0) + continue; + bool entry_mixed = false; + for (const std::string& k : entry.keys) + if (publish_mixed_keys().count(publish_base_key(k)) != 0) { + entry_mixed = true; + break; + } + size_t cat_idx = size_t(-1); + for (size_t c = 0; c < m_categories.size(); ++c) { + const Category& cat = m_categories[c]; + if (cat.section != Section::Material || cat.is_mixed != entry_mixed || cat.filament_slot != size_t(entry.slot)) + continue; + cat_idx = c; + break; + } + if (cat_idx == size_t(-1)) + continue; // slot not present in the receiver (out of range / skipped) + Category& cat = m_categories[cat_idx]; + if (cat.enable_check != nullptr) { + cat.enable_check->SetValue(true); + on_enable_toggle(cat_idx); + } + if (entry.full && cat.full_check != nullptr) { + cat.full_check->SetValue(true); + on_full_toggle(cat_idx); + } else { + // Setting rows are keyed by the base key. + for (const std::string& key : entry.keys) { + const std::string base = publish_base_key(key); + for (const size_t r : cat.rows) { + Row& row = m_rows[r]; + if (row.kind == RowKind::Setting && row.key == base) { + row.check->SetValue(true); + break; + } + } + } + if (entry.publish_type && !entry.publish_type_value.empty()) + for (const size_t r : cat.rows) + if (m_rows[r].kind == RowKind::Type && m_rows[r].check != nullptr) { + m_rows[r].check->SetValue(true); + break; + } + if (entry.publish_color && !entry.color.empty()) + for (const size_t r : cat.rows) + if (m_rows[r].kind == RowKind::Color && m_rows[r].check != nullptr) { + m_rows[r].check->SetValue(true); + break; + } + } + } + + apply_visibility(); + refresh_tab_indicators(); +} + +std::vector PublishSettingsDialog::GetPublishedKeys() const +{ + std::vector out; + // Process and printer sections both travel through published_keys (the load-side overlay + // applies process keys to the prints edited preset and the allowlisted printer keys to the + // printers edited preset); material keys use a separate API. Printer rows carry the full + // per-extruder "#N" opt_id (built in Phase 1), so a checked row publishes exactly that + // extruder's value - per-extruder selection is independent. + for (const Row& row : m_rows) { + if ((row.section != Section::Print && row.section != Section::Printer) || !row.check->GetValue()) + continue; + out.push_back(row.key); + } + return out; +} + +std::vector PublishSettingsDialog::unpublished_mixed_components() const +{ + std::vector out; + for (const Category& cat : m_categories) { + if (!cat.is_mixed || cat.section != Section::Material) + continue; + // Only enabled mixed slots depend on their components being published. + if (cat.enable_check == nullptr || !cat.enable_check->GetValue()) + continue; + const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); + // Components are 1-based physical filament indices. + for (const unsigned int component : mixed_slot_components(full, cat.filament_slot)) { + const size_t component_slot = size_t(component) - 1; + // Find the component slot's material category (one exists per physical slot). + const Category* comp_cat = nullptr; + for (const Category& other : m_categories) { + if (other.section == Section::Material && !other.is_mixed && other.filament_slot == component_slot) { + comp_cat = &other; + break; + } + } + if (comp_cat == nullptr || comp_cat->enable_check == nullptr) + continue; + // Missing when the component's "Enable" is off, or it is enabled with neither + // "Full Publish" nor the "Type" requirement row checked. Colour never counts: + // the receiver renders the mix from its own components' colours. + if (!comp_cat->enable_check->GetValue()) { + out.push_back({cat.filament_slot, component_slot, MixedDependencyIssue::Reason::Disabled}); + continue; + } + if (comp_cat->full_check != nullptr && comp_cat->full_check->GetValue()) + continue; + bool type_checked = false; + for (const size_t r : comp_cat->rows) { + const Row& row = m_rows[r]; + if (row.kind == RowKind::Type && row.check->GetValue()) { + type_checked = true; + break; + } + } + if (!type_checked) + out.push_back({cat.filament_slot, component_slot, MixedDependencyIssue::Reason::MaterialNotPublished}); + } + } + // Deterministic order for the warning rows: by mixed slot, then component slot. The same + // (mix, component) pair cannot repeat: each mix's components come from a config list. + std::sort(out.begin(), out.end(), [](const MixedDependencyIssue& a, const MixedDependencyIssue& b) { + return std::tie(a.mixed_slot, a.component_slot) < std::tie(b.mixed_slot, b.component_slot); + }); + return out; +} + +std::vector PublishSettingsDialog::GetPublishedMaterialKeys() const +{ + std::vector out; + for (const Category& cat : m_categories) { + if (cat.section != Section::Material) + continue; + // A slot that is not "Enable"d publishes nothing at all. + if (cat.enable_check != nullptr && !cat.enable_check->GetValue()) + continue; + Slic3r::PublishedMaterialEntry entry; + entry.filament_type = cat.filament_type; + entry.filament_vendor = cat.filament_vendor; + entry.filament_id = cat.filament_id; + entry.slot = static_cast(cat.filament_slot); + // The author's preset id distinguishes exact variants that share filament_id + // ("Generic PLA" vs "Generic PLA Matte"), so the receiver can match precisely; the + // preset name is the most direct identity and is matched first on load. + PresetBundle* bundle = wxGetApp().preset_bundle; + if (bundle != nullptr && cat.filament_slot < bundle->filament_presets.size()) { + if (const Preset* preset = bundle->filaments.find_preset(bundle->filament_presets[cat.filament_slot], false, true)) { + entry.setting_id = preset->setting_id; + entry.preset_name = preset->name; + } + } + // A mixed slot publishes as one unit: its definition (components, ratios, gradient) + // always travels (its Enable implies this), and the component filaments are enabled + + // Full Published by on_enable_toggle into their own entries. + if (cat.is_mixed) { + Slic3r::PublishedMaterialEntry mixed_entry; + mixed_entry.filament_type = cat.filament_type; + mixed_entry.filament_vendor = cat.filament_vendor; + mixed_entry.filament_id = cat.filament_id; + mixed_entry.slot = static_cast(cat.filament_slot); + // The mix's own colour (blended) is a property of the definition, not a + // requirement row; carry it so the receiver renders the swatch. + const DynamicPrintConfig full_cfg = wxGetApp().preset_bundle->full_config(); + const std::string mix_color = filament_color_hex(full_cfg, cat.filament_slot); + if (!mix_color.empty()) { + mixed_entry.publish_color = true; + mixed_entry.color = mix_color; + } + for (const std::string& key : publish_mixed_keys()) + mixed_entry.keys.emplace_back(key); + out.push_back(std::move(mixed_entry)); + continue; + } + // "Full Publish": the whole filament preset is embedded; type and colour are implicitly + // published, and the per-key rows are disabled / their state ignored. + if (cat.full_check != nullptr && cat.full_check->GetValue()) { + entry.full = true; + entry.full_keys = full_keys_for_slot(); + entry.publish_type = true; + entry.publish_type_value = normalize_filament_type(cat.filament_type); + for (size_t r : cat.rows) { + const Row& row = m_rows[r]; + if (row.kind == RowKind::Color && !row.value.IsEmpty()) { + entry.publish_color = true; + entry.color = row.value.ToStdString(); + } + } + out.push_back(std::move(entry)); + continue; + } + for (size_t r : cat.rows) { + const Row& row = m_rows[r]; + if (!row.check->GetValue()) + continue; + if (row.kind == RowKind::Color) { + entry.publish_color = true; + entry.color = row.value.ToStdString(); + } else if (row.kind == RowKind::Type) { + entry.publish_type = true; + entry.publish_type_value = row.value.ToStdString(); + } else { + entry.keys.push_back(row.key); + } + } + // Nothing checked at all -> nothing to write. + if (!entry.keys.empty() || entry.publish_type || entry.publish_color) + out.push_back(std::move(entry)); + } + return out; +} + +bool PublishSettingsDialog::category_has_selection(const Category& cat) const +{ + // A material slot publishes (or not) as a whole, gated by "Enable". + if (cat.section == Section::Material) + return cat.enable_check != nullptr && cat.enable_check->GetValue(); + // Print/Printer categories have no gate: any checked row counts. + for (const size_t r : cat.rows) + if (m_rows[r].check->GetValue()) + return true; + return false; +} + +void PublishSettingsDialog::refresh_tab_indicators() +{ + for (size_t s = 0; s < m_sections.size(); ++s) { + SectionGroup& section = m_sections[s]; + bool any = false; + for (size_t i = 0; i < section.categories.size(); ++i) { + const bool on = category_has_selection(m_categories[section.categories[i]]); + section.tabs->SetItemIndicator(static_cast(i), on); + any = any || on; + } + if (section.mixed_tabs != nullptr) + for (size_t i = 0; i < section.mixed_categories.size(); ++i) { + const bool on = category_has_selection(m_categories[section.mixed_categories[i]]); + section.mixed_tabs->SetItemIndicator(static_cast(i), on); + any = any || on; + } + m_outer_tabs->SetItemIndicator(static_cast(s), any); + } +} + +std::vector PublishSettingsDialog::full_keys_for_slot() const +{ + // The canonical filament preset keys minus the structural keys the published overlay must + // never touch (inherits, compatibility, *_settings_id, ...), plus filament_colour (not a + // member of Preset::filament_options). Values travel in the exported config, masked to + // this slot, and are applied on load onto the receiver's slot. + const std::set& denylist = publish_structural_keys(); + std::vector keys; + for (const std::string& key : Preset::filament_options()) + if (denylist.count(key) == 0) + keys.emplace_back(key); + keys.emplace_back("filament_colour"); + return keys; +} + +void PublishSettingsDialog::on_dpi_changed(const wxRect& suggested_rect) +{ + // Rescale toolbar bitmaps and icons; collapse chevrons are vector-drawn and repaint. + m_search.msw_rescale(); + m_menu.msw_rescale(); + m_filter_box->SetIcon(m_search.bmp()); + m_menu_button->SetBitmap(m_menu.bmp()); + m_outer_tabs->Rescale(); + + const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); + + for (Category& cat : m_categories) { + if (cat.full_check != nullptr) + cat.full_check->Refresh(); + if (cat.enable_check != nullptr) + cat.enable_check->Refresh(); + if (cat.title_label != nullptr) + cat.title_label->Refresh(); + if (cat.filament_color_chip != nullptr) { + // Mixed pages have no header chip; this only ever fires for physical slots. + if (wxBitmap* chip = get_extruder_color_icon(filament_color_hex(full, cat.filament_slot), std::to_string(cat.filament_slot + 1), + FromDIP(20), FromDIP(20))) { + cat.filament_color_chip->SetBitmap(*chip); + } + } + cat.scroll->FitInside(); + cat.list_sizer->Layout(); + } + + for (size_t s = 0; s < m_sections.size(); ++s) { + SectionGroup& section = m_sections[s]; + section.icon_bmp.msw_rescale(); + if (section.icon_bmp.bmp().IsOk()) + m_outer_tabs->SetItemBitmap(s, section.icon_bmp.bmp()); + section.tabs->Rescale(); + if (section.mixed_tabs != nullptr) + section.mixed_tabs->Rescale(); + } + + // Refresh the per-row Color chips at the new DPI (they carry the slot number too). + for (Row& row : m_rows) { + if (row.color_chip != nullptr && !row.value.IsEmpty()) { + const std::string chip_label = (row.inner_index < m_categories.size()) ? + std::to_string(m_categories[row.inner_index].filament_slot + 1) : + ""; + if (wxBitmap* chip = get_extruder_color_icon(row.value.ToStdString(), chip_label, FromDIP(20), FromDIP(20))) + row.color_chip->SetBitmap(*chip); + } + } + + for (size_t category_index = 0; category_index < m_categories.size(); ++category_index) { + const Category& category = m_categories[category_index]; + if (category.section != Section::Material || category.is_mixed) + continue; + if (wxBitmap* chip = get_extruder_color_icon(filament_color_hex(full, category.filament_slot), + std::to_string(category.filament_slot + 1), FromDIP(20), FromDIP(20))) { + const SectionGroup& section = m_sections[category.group]; + const auto iter = std::find(section.categories.begin(), section.categories.end(), category_index); + if (iter != section.categories.end()) + m_sections[category.group].tabs->SetItemBitmap(static_cast(iter - section.categories.begin()), *chip); + } + } + refresh_mixed_tab_bitmaps(); + + fit_to_content(); // tab buttons' min widths grew with the DPI: re-fit (incl. resize floor) + Refresh(); +} + +void PublishSettingsDialog::refresh_mixed_tab_bitmaps() +{ + // The mixed-tab composition bitmaps bake the tab strip's background colour and DPI-scaled + // text, so both a theme flip and a DPI change have to rebuild them. + const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); + for (SectionGroup& section : m_sections) { + if (section.mixed_tabs == nullptr) + continue; + const wxColour tab_bg = section.mixed_tabs->GetBackgroundColour(); + for (size_t i = 0; i < section.mixed_categories.size(); ++i) { + const Category& category = m_categories[section.mixed_categories[i]]; + const wxBitmap tab_bmp = mixed_filament_tab_bitmap(full, category.filament_slot, FromDIP(20), tab_bg); + if (tab_bmp.IsOk()) + section.mixed_tabs->SetItemBitmap(static_cast(i), tab_bmp); + } + } +} + +void PublishSettingsDialog::on_sys_color_changed() +{ + // The mixed-tab compositions bake the tab strip's background colour: rebuild them when the + // theme changes (on_dpi_changed covers rescales). + refresh_mixed_tab_bitmaps(); + Refresh(); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/PublishSettingsDialog.hpp b/src/slic3r/GUI/PublishSettingsDialog.hpp new file mode 100644 index 0000000000..f41b3e73a3 --- /dev/null +++ b/src/slic3r/GUI/PublishSettingsDialog.hpp @@ -0,0 +1,286 @@ +#pragma once + +#include "GUI_Utils.hpp" +#include "wxExtensions.hpp" +#include "Widgets/TabCtrl.hpp" + +#include "libslic3r/PublishSettings.hpp" + +#include +#include +#include +#include +#include +#include +#include + +// Forward declarations (all are global classes, see Widgets/TextInput.hpp and +// Widgets/StaticLine.hpp). +class TextInput; +class StaticLine; + +namespace Slic3r { namespace GUI { + +struct PublishMaterialIdentity +{ + std::string type; + std::string vendor; + std::string id; +}; + +// One unmet dependency of an enabled mixed-filament slot: the component filament the mix uses +// would ship without its material (either the component slot is not enabled at all, or it is +// enabled with neither "Full Publish" nor the "Type" requirement checked). Slots are 0-based. +struct MixedDependencyIssue +{ + enum class Reason { Disabled, MaterialNotPublished }; + size_t mixed_slot{0}; + size_t component_slot{0}; + Reason reason{Reason::Disabled}; +}; + +// Dialog letting a model author select which settings get embedded in a 3MF. Nested tab layout +// mirroring the Process settings (Printer / Filament / Process outer tabs, category or material +// tabs inside each). Dirty settings are pre-checked and shown bold; on OK the print rows become +// "orca_published_keys" and the material rows become "orca_published_material_keys". +class PublishSettingsDialog : public DPIDialog +{ +public: + // Optional published selection (Feature A/B): when the caller supplies one (either a + // remembered session selection or the payload of a freshly loaded published 3MF) the dialog + // is seeded from it, overriding the dirty-default pre-check. A non-null pointer to an empty + // selection means "publish nothing" (an intentional empty state); a null pointer means "no + // remembered selection" (keep the dirty defaults). + PublishSettingsDialog(wxWindow* parent = nullptr, + const std::vector* published_keys = nullptr, + const std::vector* material_keys = nullptr); + ~PublishSettingsDialog(); + + // The selected print/printer setting keys (in display order); printer keys carry a '#N' + // per-extruder suffix. + std::vector GetPublishedKeys() const; + + // The selected keys grouped per material section (base keys, no '#N' suffix). + std::vector GetPublishedMaterialKeys() const; + +protected: + void on_dpi_changed(const wxRect& suggested_rect) override; + void on_sys_color_changed() override; + +private: + void fit_to_content(); + void refresh_mixed_tab_bitmaps(); + + // Which part of the settings the row/category came from. + enum class Section { Print, Printer, Material }; + + // One selectable setting row: a checkbox (setting name) plus a value label and an optional + // grey unit label. key is the full config key, possibly with a "#N" variant suffix + // (print/printer rows); material rows carry the base key. + enum class RowKind { + Setting, // a regular setting key + Color, // material colour requirement (filament_colour) + Type, // material type requirement (read-only text) + }; + struct Row + { + std::string key; + wxString category; + wxString subcategory; + wxString label; + wxString value; + wxString unit; + wxString section_title; // outer tab title, for filter matching + Section section{Section::Print}; + RowKind kind{RowKind::Setting}; + size_t outer_index{0}; + size_t inner_index{0}; + bool dirty{false}; // matches a dirty base key: pre-checked + bold + bool matches_filter{false}; // survives the active filter (computed by apply_filter) + wxCheckBox* check{nullptr}; + wxStaticText* value_label{nullptr}; + wxStaticText* unit_label{nullptr}; + wxStaticBitmap* color_chip{nullptr}; // Color rows only; swatch next to the value + wxSizerItem* item{nullptr}; // sizer item of this row's h-sizer in its tab list sizer + }; + + // An optgroup heading. Rows store indices into m_rows. + struct Subcategory + { + wxString title; + ::StaticLine* header{nullptr}; // null when the title is empty + wxSizerItem* item{nullptr}; + std::vector rows; + }; + + // An inner TabCtrl page with its material controls and content. + struct Category + { + wxString title; + Section section{Section::Print}; + size_t group{0}; // index into m_sections / outer page + size_t source_index{0}; // stable source page or material slot index + wxPanel* page{nullptr}; + wxScrolledWindow* scroll{nullptr}; + wxBoxSizer* list_sizer{nullptr}; + wxStaticText* info{nullptr}; + wxPoint scroll_pos{0, 0}; + wxStaticBitmap* filament_color_chip{nullptr}; + wxStaticText* title_label{nullptr}; // material title (static text; Full Publish carries the label elsewhere) + // "Enable": while unchecked nothing of this slot is exported and everything below the + // header row is hidden. For physical slots the Full Publish toggle sits on a second + // line (full_line_item) visible only when enabled; for mixed slots Enable alone implies + // publishing the mix definition, so no Full Publish widget exists at all. + wxCheckBox* enable_check{nullptr}; + wxSizerItem* full_line_item{nullptr}; // sizer item of the Full Publish line (physical slots only) + // "Full Publish": while checked, the whole slot preset is serialized and its rows + // (incl. Color/Type) are disabled. + wxCheckBox* full_check{nullptr}; + // True for a mixed-color filament slot: no Material/Retraction rows; Enable publishes + // the slot's gradient/ratio definition as a whole. + bool is_mixed{false}; + // Material identity, only for Section::Material categories. + std::string filament_type; + std::string filament_vendor; + std::string filament_id; + // The author's 0-based filament slot this material section represents. + size_t filament_slot{0}; + std::vector subs; + std::vector rows; // flattened rows of this category + }; + + // Frozen snapshot of a mixed filament slot's definition for the read-only visualization + // painted on the slot's page. Plain data only: the paint handler must never touch the + // config. For gradient slots the curve is pre-sampled (t, ratio) pairs, where ratio is the + // first component's share over model height; anchors carry the raw control points. + struct MixedVisualSpec + { + bool valid{false}; + bool is_gradient{false}; + std::vector component_colours; // colour per component, in config order + std::vector ratios; // sublayer shares summing to ~1 (non-gradient) + std::vector tri_weights; // 3-component mixes: barycentric shares + std::vector> gradient_samples; + std::vector> gradient_anchors; + }; + + // One outer TabCtrl page. Category entries are its inner tabs. + struct SectionGroup + { + wxString title; // _L("Printer") / _L("Filament") / _L("Process") + Section kind{Section::Print}; // maps 1:1 to the display group + std::string icon_name; // "printer" / "filament" / "process" + ScalableBitmap icon_bmp; // tab icon next to the title; rescaled on DPI change + wxPanel* page{nullptr}; + TabCtrl* tabs{nullptr}; + // Second tab strip, below the main one, listing only the mixed-color filament slots. + // Present on the Material section only (null elsewhere). + TabCtrl* mixed_tabs{nullptr}; + wxPanel* page_host{nullptr}; + wxBoxSizer* page_host_sizer{nullptr}; + int selected_inner{-1}; + // Selected mixed tab (index into mixed_categories), valid while a mixed slot page is shown. + int selected_mixed{-1}; + std::vector categories; // indices into m_categories (physical slots) + std::vector mixed_categories; // indices into m_categories (mixed slots) + }; + + void build_option_model(); + // Seed the dialog from a published selection (print/printer keys + per-slot material keys): + // the supplied selection is authoritative - it is applied after the dirty pre-check and + // overrides it, so deselected dirty keys stay off. Rows/slots not present in the selection + // are left unselected. Unknown or out-of-range entries are skipped gracefully. + void apply_selection(const std::vector& published_keys, + const std::vector& material_keys); + // Frozen snapshot of a mixed slot's definition for the page visualization, resolved from + // the full config once at dialog-build time. Gradient slots pre-sample exactly what the + // slicer will print: the custom curve wins over the gradient_range endpoints over the + // 0.10 -> 0.90 default (the resolution FilamentBitmapUtils::mixed_gradient_curve mirrors). + static MixedVisualSpec make_mixed_visual_spec(const Slic3r::DynamicPrintConfig& full, size_t slot); + void apply_filter(const wxString& filter_text); + // Menu-only pseudo filters: show only the checked ("Filter selected") or only the + // unchecked ("Filter non-selected") rows. The search box keeps the user's text. + void apply_pseudo_filter(bool selected_only); + // Recompute row matches and visibility for the active filter mode. filter is the lowered + // search text; it is ignored by the pseudo modes. + void refresh_filter(const wxString& filter); + void select_all(bool value); + void select_visible(bool value); + void show_menu(wxMouseEvent& evt); + void set_row_bold(Row& row, bool bold); + // "Full Publish" toggled: disables/enables the material's rows. + void on_full_toggle(size_t category_index); + // "Enable" toggled on a material slot: reveals/hides everything below the header and, for a + // mixed slot, auto-selects its component filaments' "Enable" + "Full Publish" toggles. + void on_enable_toggle(size_t category_index); + // Whether a category currently publishes something, driving its tab's indicator dot. + // Print/Printer: any row checked. Material: the slot's "Enable" is on. + bool category_has_selection(const Category& cat) const; + // Recompute the indicator dot on every outer/inner tab from the current selection state. + void refresh_tab_indicators(); + // Unmet dependencies of enabled mixed-filament slots, one record per (mix, component) pair: + // "Enable" not checked on the component, or enabled with neither "Full Publish" nor the + // "Type" requirement row checked. Colour is deliberately ignored (the receiver renders the + // mix from its own components' colours). Sorted by mixed slot, then component slot. + std::vector unpublished_mixed_components() const; + // Read-only visualization of a mixed slot's definition (a stacked ratio bar, or the + // Material Ratio vs Model Height graph for a gradient), inserted above the info hint + // inside the category's scroll area. + void add_mixed_visual(size_t category_index, const MixedVisualSpec& spec); + // Return/create the fixed outer page for a Section kind. + size_t section_group_for(Section kind); + size_t category_index_for(const wxString& title, + Section section, + size_t group, + size_t source_index, + const PublishMaterialIdentity& identity = PublishMaterialIdentity(), + bool is_mixed = false); + size_t subcategory_index_for(size_t category_index, const wxString& title, const wxString& icon); + void add_row_ui(const std::string& key, + const wxString& label, + const wxString& value, + const wxString& unit, + size_t category_index, + size_t subcategory_index, + RowKind kind = RowKind::Setting); + // The non-structural filament keys of a slot's preset, for a "Full Publish" entry. + std::vector full_keys_for_slot() const; + void save_scroll_position(Category& category); + void show_outer_page(size_t section_index); + void show_inner_page(size_t section_index, int inner_index); + void show_mixed_page(size_t section_index, int mixed_index); + void on_outer_tab_changed(wxCommandEvent& event); + void on_inner_tab_changed(size_t section_index, wxCommandEvent& event); + void on_mixed_tab_changed(size_t section_index, wxCommandEvent& event); + bool row_is_visible(const Row& row) const; + void apply_visibility(); + void bind_tab_events(); + + TabCtrl* m_outer_tabs{nullptr}; + wxPanel* m_outer_host{nullptr}; + wxBoxSizer* m_outer_host_sizer{nullptr}; + int m_selected_outer{-1}; + wxBoxSizer* m_fb_sizer{nullptr}; // "All"/"None" buttons sizer + // Active filter mode: free text from the search box, or one of the menu's pseudo filters. + enum class FilterMode { Text, SelectedOnly, UnselectedOnly }; + FilterMode m_filter_mode{FilterMode::Text}; + TextInput* m_filter_box{nullptr}; + wxTextCtrl* m_filter_ctrl{nullptr}; + wxStaticBitmap* m_menu_button{nullptr}; + // Shown while a pseudo filter is active (the search box keeps the user's text, so the chip + // carries the visible state); clicking it returns to text filtering. + wxStaticText* m_pseudo_chip{nullptr}; + wxString m_info_nonsel; + wxString m_info_allsel; + wxString m_info_empty; + wxString m_info_mix; // body hint shown for a mixed slot (published as a whole) + + ScalableBitmap m_search; + ScalableBitmap m_menu; + + std::vector m_rows; + std::vector m_categories; + std::vector m_sections; +}; + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/SendMultiMachinePage.cpp b/src/slic3r/GUI/SendMultiMachinePage.cpp index 3a52caec3f..2d1b713264 100644 --- a/src/slic3r/GUI/SendMultiMachinePage.cpp +++ b/src/slic3r/GUI/SendMultiMachinePage.cpp @@ -814,13 +814,13 @@ wxBoxSizer* SendMultiMachinePage::create_item_title(wxString title, wxWindow* pa wxBoxSizer* m_sizer_title = new wxBoxSizer(wxHORIZONTAL); auto m_title = new wxStaticText(parent, wxID_ANY, title, wxDefaultPosition, wxDefaultSize, 0); - m_title->SetForegroundColour(DESIGN_GRAY800_COLOR); + m_title->SetForegroundColour(SEND_DESIGN_GRAY800_COLOR); m_title->SetFont(::Label::Head_13); m_title->Wrap(-1); m_title->SetToolTip(tooltip); auto m_line = new wxPanel(parent, wxID_ANY, wxDefaultPosition, wxSize(-1, 1), wxTAB_TRAVERSAL); - m_line->SetBackgroundColour(DESIGN_GRAY400_COLOR); + m_line->SetBackgroundColour(SEND_DESIGN_GRAY400_COLOR); m_sizer_title->Add(m_title, 0, wxALIGN_CENTER | wxALL, 3); m_sizer_title->Add(0, 0, 0, wxLEFT, 9); @@ -843,7 +843,7 @@ wxBoxSizer* SendMultiMachinePage::create_item_checkbox(wxString title, wxWindow* m_sizer_checkbox->Add(0, 0, 0, wxEXPAND | wxLEFT, 8); auto checkbox_title = new wxStaticText(parent, wxID_ANY, title, wxDefaultPosition, wxDefaultSize, 0); - checkbox_title->SetForegroundColour(DESIGN_GRAY900_COLOR); + checkbox_title->SetForegroundColour(SEND_DESIGN_GRAY900_COLOR); checkbox_title->SetFont(::Label::Body_13); auto size = checkbox_title->GetTextExtent(title); @@ -867,12 +867,12 @@ wxBoxSizer* SendMultiMachinePage::create_item_input(wxString str_before, wxStrin { wxBoxSizer* sizer_input = new wxBoxSizer(wxHORIZONTAL); auto input_title = new wxStaticText(parent, wxID_ANY, str_before); - input_title->SetForegroundColour(DESIGN_GRAY900_COLOR); + input_title->SetForegroundColour(SEND_DESIGN_GRAY900_COLOR); input_title->SetFont(::Label::Body_13); input_title->SetToolTip(tooltip); input_title->Wrap(-1); - auto input = new ::TextInput(parent, wxEmptyString, wxEmptyString, wxEmptyString, wxDefaultPosition, DESIGN_INPUT_SIZE, wxTE_PROCESS_ENTER); + auto input = new ::TextInput(parent, wxEmptyString, wxEmptyString, wxEmptyString, wxDefaultPosition, SEND_DESIGN_INPUT_SIZE, wxTE_PROCESS_ENTER); StateColor input_bg(std::pair(wxColour("#F0F0F1"), StateColor::Disabled), std::pair(*wxWHITE, StateColor::Enabled)); input->SetBackgroundColor(input_bg); input->GetTextCtrl()->SetValue(app_config->get(param)); @@ -880,7 +880,7 @@ wxBoxSizer* SendMultiMachinePage::create_item_input(wxString str_before, wxStrin input->GetTextCtrl()->SetValidator(validator); auto second_title = new wxStaticText(parent, wxID_ANY, str_after, wxDefaultPosition, wxDefaultSize, wxST_ELLIPSIZE_END); - second_title->SetForegroundColour(DESIGN_GRAY900_COLOR); + second_title->SetForegroundColour(SEND_DESIGN_GRAY900_COLOR); second_title->SetFont(::Label::Body_13); second_title->SetToolTip(tooltip); second_title->Wrap(-1); @@ -1337,7 +1337,7 @@ wxPanel* SendMultiMachinePage::create_page() m_tip_text->SetMinSize(wxSize(FromDIP(DEVICE_ITEM_MAX_WIDTH), -1)); m_tip_text->SetMaxSize(wxSize(FromDIP(DEVICE_ITEM_MAX_WIDTH), -1)); m_tip_text->SetLabel(_L("Please select the devices you would like to manage here (up to 6 devices)")); - m_tip_text->SetForegroundColour(DESIGN_GRAY800_COLOR); + m_tip_text->SetForegroundColour(SEND_DESIGN_GRAY800_COLOR); m_tip_text->SetFont(::Label::Head_20); m_tip_text->Wrap(-1); diff --git a/src/slic3r/GUI/SendMultiMachinePage.hpp b/src/slic3r/GUI/SendMultiMachinePage.hpp index 7d77849bf3..a63bc51bb0 100644 --- a/src/slic3r/GUI/SendMultiMachinePage.hpp +++ b/src/slic3r/GUI/SendMultiMachinePage.hpp @@ -22,15 +22,15 @@ namespace GUI { #define SEND_LEFT_DEV_STATUS 250 #define SEND_LEFT_TAKS_STATUS 180 -#define DESIGN_SELECTOR_NOMORE_COLOR wxColour(248, 248, 248) -#define DESIGN_GRAY900_COLOR wxColour(38, 46, 48) -#define DESIGN_GRAY800_COLOR wxColour(50, 58, 61) -#define DESIGN_GRAY600_COLOR wxColour(144, 144, 144) -#define DESIGN_GRAY400_COLOR wxColour(166, 169, 170) -#define DESIGN_RESOUTION_PREFERENCES wxSize(FromDIP(540), -1) -#define DESIGN_COMBOBOX_SIZE wxSize(FromDIP(140), -1) -#define DESIGN_LARGE_COMBOBOX_SIZE wxSize(FromDIP(160), -1) -#define DESIGN_INPUT_SIZE wxSize(FromDIP(50), -1) +#define SEND_DESIGN_SELECTOR_NOMORE_COLOR wxColour(248, 248, 248) +#define SEND_DESIGN_GRAY900_COLOR wxColour(38, 46, 48) +#define SEND_DESIGN_GRAY800_COLOR wxColour(50, 58, 61) +#define SEND_DESIGN_GRAY600_COLOR wxColour(144, 144, 144) +#define SEND_DESIGN_GRAY400_COLOR wxColour(166, 169, 170) +#define SEND_DESIGN_RESOUTION_PREFERENCES wxSize(FromDIP(540), -1) +#define SEND_DESIGN_COMBOBOX_SIZE wxSize(FromDIP(140), -1) +#define SEND_DESIGN_LARGE_COMBOBOX_SIZE wxSize(FromDIP(160), -1) +#define SEND_DESIGN_INPUT_SIZE wxSize(FromDIP(50), -1) diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index e650c45126..2dedb23365 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -7,6 +7,7 @@ #include "libslic3r/FilamentMixer.hpp" #include "libslic3r/Utils.hpp" #include "libslic3r/Model.hpp" +#include "libslic3r/PublishSettings.hpp" #include "libslic3r/GCode/GCodeProcessor.hpp" #include "Search.hpp" @@ -5699,26 +5700,16 @@ if (is_marlin_flavor) optgroup->append_single_option_line("extruder_offset", "printer_extruder_basic_information#extruder-offset-position", extruder_idx); //BBS: don't show retract related config menu in machine page + // These optgroups are built from publishable_printer_retraction/z_hop_options() so the + // published-3MF printer allowlist (their union in libslic3r/PublishSettings.hpp) can + // never drift from what the machine page actually shows. optgroup = page->new_optgroup(L("Retraction"), L"param_retraction"); - optgroup->append_single_option_line("retraction_length", "printer_extruder_retraction#length", extruder_idx); - optgroup->append_single_option_line("retract_restart_extra", "printer_extruder_retraction#extra-length-on-restart", extruder_idx); - optgroup->append_single_option_line("retraction_speed", "printer_extruder_retraction#retraction-speed", extruder_idx); - optgroup->append_single_option_line("deretraction_speed", "printer_extruder_retraction#deretraction-speed", extruder_idx); - optgroup->append_single_option_line("retraction_minimum_travel", "printer_extruder_retraction#travel-distance-threshold", extruder_idx); - optgroup->append_single_option_line("retract_when_changing_layer", "printer_extruder_retraction#retract-on-layer-change", extruder_idx); - optgroup->append_single_option_line("wipe", "printer_extruder_retraction#wipe-while-retracting", extruder_idx); - optgroup->append_single_option_line("wipe_distance", "printer_extruder_retraction#wipe-distance", extruder_idx); - optgroup->append_single_option_line("retract_before_wipe", "printer_extruder_retraction#retract-amount-before-wipe", extruder_idx); - // Orca - optgroup->append_single_option_line("retract_after_wipe", "printer_extruder_retraction#retract-amount-after-wipe", extruder_idx); + for (const PublishablePrinterOption& opt : publishable_printer_retraction_options()) + optgroup->append_single_option_line(opt.key, opt.icon, extruder_idx); optgroup = page->new_optgroup(L("Z-Hop"), L"param_extruder_lift_enforcement"); - optgroup->append_single_option_line("retract_lift_enforce", "printer_extruder_z_hop#on-surfaces", extruder_idx); - optgroup->append_single_option_line("z_hop_types", "printer_extruder_z_hop#z-hop-type", extruder_idx); - optgroup->append_single_option_line("z_hop", "printer_extruder_z_hop#z-hop-height", extruder_idx); - optgroup->append_single_option_line("travel_slope", "printer_extruder_z_hop#traveling-angle", extruder_idx); - optgroup->append_single_option_line("retract_lift_above", "printer_extruder_z_hop#only-lift-z-above", extruder_idx); - optgroup->append_single_option_line("retract_lift_below", "printer_extruder_z_hop#only-lift-z-below", extruder_idx); + for (const PublishablePrinterOption& opt : publishable_printer_z_hop_options()) + optgroup->append_single_option_line(opt.key, opt.icon, extruder_idx); optgroup = page->new_optgroup(L("Retraction when switching material"), L"param_retraction_material_change"); optgroup->append_single_option_line("retract_length_toolchange", "printer_extruder_retraction#retraction-when-switching-materials", extruder_idx); diff --git a/src/slic3r/GUI/Tab.hpp b/src/slic3r/GUI/Tab.hpp index 4212c4a599..30a06b4eb5 100644 --- a/src/slic3r/GUI/Tab.hpp +++ b/src/slic3r/GUI/Tab.hpp @@ -30,7 +30,6 @@ #include //#include "BedShapeDialog.hpp" -#include "Event.hpp" #include "wxExtensions.hpp" #include "ConfigManipulation.hpp" #include "OptionsGroup.hpp" @@ -38,7 +37,6 @@ //BBS: GUI refactor #include "Notebook.hpp" #include "ParamsPanel.hpp" -#include "Widgets/RoundedRectangle.hpp" #include "Widgets/TextInput.hpp" #include "Widgets/CheckBox.hpp" // ORCA @@ -472,6 +470,7 @@ protected: std::string m_last_sparse_infill_rotate_template_value; ConfigManipulation get_config_manipulation(); friend class EditGCodeDialog; + friend class PublishSettingsDialog; }; class TabPrint : public Tab diff --git a/src/slic3r/GUI/UnsavedChangesDialog.cpp b/src/slic3r/GUI/UnsavedChangesDialog.cpp index 0cb35fb139..65b678953d 100644 --- a/src/slic3r/GUI/UnsavedChangesDialog.cpp +++ b/src/slic3r/GUI/UnsavedChangesDialog.cpp @@ -12,6 +12,7 @@ #include "libslic3r/PresetBundle.hpp" #include "libslic3r/Color.hpp" #include "format.hpp" +#include "ConfigValueFormatter.hpp" #include "GUI_App.hpp" #include "Plater.hpp" #include "Tab.hpp" @@ -22,7 +23,6 @@ #include "MsgDialog.hpp" #include "PresetComboBoxes.hpp" -#include "Widgets/RoundedRectangle.hpp" #include "Widgets/CheckBox.hpp" #include "Widgets/DialogButtons.hpp" #include "Widgets/HyperLink.hpp" @@ -571,14 +571,6 @@ void DiffModel::Clear() } -static std::string get_pure_opt_key(std::string opt_key) -{ - const int pos = opt_key.find("#"); - if (pos > 0) - boost::erase_tail(opt_key, opt_key.size() - pos); - return opt_key; -} - // ---------------------------------------------------------------------------- // DiffViewCtrl // ---------------------------------------------------------------------------- @@ -1212,32 +1204,6 @@ bool UnsavedChangesDialog::save(PresetCollection* dependent_presets, bool show_s return true; } -wxString get_string_from_enum(const std::string& opt_key, const DynamicPrintConfig& config, bool is_infill = false, int idx = -1) -{ - const ConfigOptionDef& def = config.def()->options.at(opt_key); - const std::vector& names = def.enum_labels;//ConfigOptionEnum::get_enum_names(); - int val = 0; - - if (idx >= 0) - val = dynamic_cast(config.option(opt_key))->get_at(idx); - else - val = config.option(opt_key)->getInt(); - - // Each infill doesn't use all list of infill declared in PrintConfig.hpp. - // So we should "convert" val to the correct one - if (is_infill) { - for (auto key_val : *def.enum_keys_map) - if (int(key_val.second) == val) { - auto it = std::find(def.enum_values.begin(), def.enum_values.end(), key_val.first); - if (it == def.enum_values.end()) - return ""; - return from_u8(_utf8(names[it - def.enum_values.begin()])); - } - return _L("Undefined"); - } - return from_u8(_utf8(names[val])); -} - // BBS #if 0 static size_t get_id_from_opt_key(std::string opt_key) @@ -1251,194 +1217,6 @@ static size_t get_id_from_opt_key(std::string opt_key) } #endif -static wxString get_full_label(std::string opt_key, const DynamicPrintConfig& config) -{ - opt_key = get_pure_opt_key(opt_key); - auto option = config.option(opt_key); - - if (!option || option->is_nil()) - return _L("N/A"); - - const ConfigOptionDef* opt = config.def()->get(opt_key); - return opt->full_label.empty() ? opt->label : opt->full_label; -} - -static wxString get_string_value(std::string opt_key, const DynamicPrintConfig& config) -{ - int orig_opt_idx = -1; - int opt_idx = -1; - int pos = opt_key.find("#"); - std::string temp_str = opt_key; - if (pos > 0) { - boost::erase_head(temp_str, pos + 1); - orig_opt_idx = static_cast(atoi(temp_str.c_str())); - } - opt_idx = orig_opt_idx >= 0 ? orig_opt_idx : 0; - opt_key = get_pure_opt_key(opt_key); - auto option = config.option(opt_key); - if (!option) { - return _L("N/A"); - } - auto opt_vector = dynamic_cast(option); - - if ((option->is_scalar() && config.option(opt_key)->is_nil()) || - (option->is_vector() && opt_vector && opt_idx >= 0 && opt_idx < opt_vector->size() && opt_vector->is_nil(opt_idx))) - return _L("N/A"); - - wxString out; - - const ConfigOptionDef* opt = config.def()->get(opt_key); - bool is_nullable = opt->nullable; - - switch (opt->type) { - case coInt: - return from_u8((boost::format("%1%") % config.opt_int(opt_key)).str()); - case coInts: { - if (is_nullable) { - auto values = config.opt(opt_key); - if (opt_idx < values->size()) - return from_u8((boost::format("%1%") % values->get_at(opt_idx)).str()); - } - else { - auto values = config.opt(opt_key); - if (orig_opt_idx >= 0 && orig_opt_idx < values->size()) { - return from_u8((boost::format("%1%") % values->get_at(opt_idx)).str()); - } - else { - std::string value_str; - for (int i = 0; i < values->size(); i++) { - value_str += std::to_string(values->get_at(i)); - if (i != values->size() - 1) { - value_str += ","; - } - } - return from_u8(value_str); - } - } - return _L("Undefined"); - } - case coBool: - return config.opt_bool(opt_key) ? "true" : "false"; - case coBools: { - if (is_nullable) { - auto values = config.opt(opt_key); - if (opt_idx < values->size()) - return values->get_at(opt_idx) ? "true" : "false"; - } - else { - auto values = config.opt(opt_key); - if (opt_idx < values->size()) - return values->get_at(opt_idx) ? "true" : "false"; - } - return _L("Undefined"); - } - case coPercent: - return from_u8((boost::format("%1%%%") % int(config.optptr(opt_key)->getFloat())).str()); - case coPercents: { - if (is_nullable) { - auto values = config.opt(opt_key); - if (opt_idx < values->size()) - return from_u8((boost::format("%1%%%") % values->get_at(opt_idx)).str()); - } - else { - auto values = config.opt(opt_key); - if (opt_idx < values->size()) - return from_u8((boost::format("%1%%%") % values->get_at(opt_idx)).str()); - } - return _L("Undefined"); - } - case coFloat: - return double_to_string(config.opt_float(opt_key)); - case coFloats: { - if (is_nullable) { - auto values = config.opt(opt_key); - if (opt_idx < values->size()) - return double_to_string(values->get_at(opt_idx)); - } - else { - auto values = config.opt(opt_key); - if (values && opt_idx < values->size()) - return double_to_string(values->get_at(opt_idx)); - } - return _L("Undefined"); - } - case coString: - return from_u8(config.opt_string(opt_key)); - case coStrings: { - const ConfigOptionStrings* strings = config.opt(opt_key); - if (strings) { - if (opt_key == "compatible_printers" || opt_key == "compatible_prints") { - if (strings->empty()) - return _L("All"); - for (size_t id = 0; id < strings->size(); id++) - out += from_u8(strings->get_at(id)) + "\n"; - out.RemoveLast(1); - return out; - } - if (!strings->empty() && opt_idx < strings->values.size()) - return from_u8(strings->get_at(opt_idx)); - } - break; - } - case coFloatOrPercent: { - const ConfigOptionFloatOrPercent* opt = config.opt(opt_key); - if (opt) - out = double_to_string(opt->value) + (opt->percent ? "%" : ""); - return out; - } - case coEnum: { - return get_string_from_enum(opt_key, config, - opt_key == "top_surface_pattern" || - opt_key == "bottom_surface_pattern" || - opt_key == "internal_solid_infill_pattern" || - opt_key == "sparse_infill_pattern" || - opt_key == "ironing_pattern" || - opt_key == "support_ironing_pattern" || - opt_key == "support_pattern" || - opt_key == "support_interface_pattern") - ; - } - case coEnums: { - return get_string_from_enum(opt_key, config, - opt_key == "top_surface_pattern" || - opt_key == "bottom_surface_pattern" || - opt_key == "internal_solid_infill_pattern" || - opt_key == "sparse_infill_pattern" || - opt_key == "ironing_pattern" || - opt_key == "support_ironing_pattern" || - opt_key == "support_pattern" || - opt_key == "support_interface_pattern" - , opt_idx); - } - case coPoint: { - Vec2d val = config.opt(opt_key)->value; - return from_u8((boost::format("[%1%]") % ConfigOptionPoint(val).serialize()).str()); - } - case coPoints: { - //BBS: add bed_exclude_area - if (opt_key == "printable_area" || opt_key == "thumbnails") { - ConfigOptionPoints points = *config.option(opt_key); - //BuildVolume build_volume = {points.values, 0.}; - return get_thumbnails_string(points.values); - } - else if (opt_key == "bed_exclude_area") { - return get_thumbnails_string(config.option(opt_key)->values); - } - else if (opt_key == "head_wrap_detect_zone") { - return get_thumbnails_string(config.option(opt_key)->values); - } - else if (opt_key == "wrapping_exclude_area") { - return get_thumbnails_string(config.option(opt_key)->values); - } - Vec2d val = config.opt(opt_key)->get_at(opt_idx); - return from_u8((boost::format("[%1%]") % ConfigOptionPoint(val).serialize()).str()); - } - default: - break; - } - return out; -} - void UnsavedChangesDialog::update(Preset::Type type, PresetCollection* dependent_presets, const std::string& new_selected_preset, const wxString& header) { PresetCollection* presets = dependent_presets; diff --git a/src/slic3r/GUI/WebGuideDialog.cpp b/src/slic3r/GUI/WebGuideDialog.cpp index ae259f23d1..78030c68da 100644 --- a/src/slic3r/GUI/WebGuideDialog.cpp +++ b/src/slic3r/GUI/WebGuideDialog.cpp @@ -23,6 +23,7 @@ #include #include +#include #include #include #include @@ -562,6 +563,78 @@ void GuideFrame::OnScriptMessage(wxWebViewEvent &evt) m_ProfileJson["filament"][fName]["selected"] = 1; } } + else if (strCmd == "check_for_new_printers") { + json response = json::object(); + response["command"] = "check_new_printers_result"; + // Guide pages currently send sequence_id as a number, while older + // pages may send it as a string. Preserve the value without + // forcing either representation. + if (j.contains("sequence_id")) + response["sequence_id"] = j["sequence_id"]; + else + response["sequence_id"] = ""; + + if (!m_MainPtr->preset_updater) { + response["error"] = "Printer update service is unavailable."; + wxString strJS = wxString::Format("HandleStudio(%s)", response.dump(-1, ' ', true)); + wxGetApp().CallAfter([this, strJS] { RunScript(strJS); }); + } else { + // Orca: enumerate vendors directly from disk rather than from m_ProfileJson["model"] + // — a vendor with no machine models (e.g. a filament-only bundle, or a test fixture + // like "test123" with an empty machine_model_list) never gets a "vendor" entry + // pushed into "model" by LoadProfileFamily(), so it would be invisible to the + // request body and get endlessly re-offered by the server. Scan both the system dir + // (already-installed vendors) and the bundled resources dir (shipped-but-not-yet- + // installed vendors), same as LoadProfileData() does when building loaded_vendors. + std::set system_vendors; + for (const auto& dir : {vendor_dir, rsrc_vendor_dir}) { + if (!boost::filesystem::exists(dir)) + continue; + for (const auto& entry : boost::filesystem::directory_iterator(dir)) { + if (!boost::filesystem::is_directory(entry) && boost::iequals(entry.path().extension().string(), ".json")) + system_vendors.insert(entry.path().stem().string()); + } + } + // Orca: check_new_vendors() is async (network + confirmation dialog + download + // all happen off the calling thread apart from the dialog itself); guard against + // this dialog being closed before the callback fires. + wxWeakRef weak_this(this); + try { + m_MainPtr->preset_updater->check_new_vendors( + system_vendors, [weak_this, response](std::vector installed_vendors, bool declined) mutable { + if (!weak_this) + return; + + // Orca: append the newly installed vendor(s) into the in-memory + // profile data (instead of a full LoadProfileData() rescan of every + // vendor) and push the refreshed list to the webview, the same way + // request_userguide_profile does, so the printer list picks them up + // without needing to reopen the guide. + for (const auto& vendor_id : installed_vendors) { + weak_this->LoadProfileFamily(vendor_id, (weak_this->vendor_dir / (vendor_id + ".json")).string()); + } + if (!installed_vendors.empty()) { + json profile_response = json::object(); + profile_response["command"] = "response_userguide_profile"; + profile_response["sequence_id"] = "10001"; + profile_response["response"] = weak_this->m_ProfileJson; + wxString profileJS = wxString::Format("HandleStudio(%s)", profile_response.dump(-1, ' ', true)); + weak_this->RunScript(profileJS); + } + + response["vendors"] = installed_vendors; + response["declined"] = declined; + wxString strJS = wxString::Format("HandleStudio(%s)", response.dump(-1, ' ', true)); + weak_this->RunScript(strJS); + }); + } catch (const std::exception &e) { + BOOST_LOG_TRIVIAL(warning) << "Failed to check for new printers: " << e.what(); + response["error"] = "Failed to check for new printers."; + wxString strJS = wxString::Format("HandleStudio(%s)", response.dump(-1, ' ', true)); + wxGetApp().CallAfter([this, strJS] { RunScript(strJS); }); + } + } + } else if (strCmd == "user_guide_finish") { SaveProfile(); @@ -1644,13 +1717,15 @@ int GuideFrame::LoadProfileFamily(std::string strVendor, std::string strFilePath OneModel["materials"] = pm["default_materials"]; // wxString strCoverPath = wxString::Format("%s\\%s\\%s_cover.png", strFolder, strVendor, std::string(s1.mb_str())); - std::string cover_file = s1 + "_cover.png"; - boost::filesystem::path cover_path = boost::filesystem::absolute(boost::filesystem::path(resources_dir()) / "/profiles/" / strVendor / cover_file).make_preferred(); + std::string cover_file = s1 + "_cover.png"; + boost::filesystem::path cover_path = boost::filesystem::absolute(vendor_dir / cover_file).make_preferred(); + BOOST_LOG_TRIVIAL(info) << "[WebGuideDialog] " << cover_path; if (!boost::filesystem::exists(cover_path)) { - cover_path = - (boost::filesystem::absolute(boost::filesystem::path(resources_dir()) / "/web/image/printer/") / - cover_file) - .make_preferred(); + cover_path = boost::filesystem::absolute(boost::filesystem::path(resources_dir()) / "/profiles/" / strVendor / cover_file) + .make_preferred(); + if (!boost::filesystem::exists(cover_path)) + cover_path = (boost::filesystem::absolute(boost::filesystem::path(resources_dir()) / "/web/image/printer/") / cover_file) + .make_preferred(); } OneModel["cover"] = cover_path.string(); diff --git a/src/slic3r/GUI/Widgets/Button.cpp b/src/slic3r/GUI/Widgets/Button.cpp index 5a5bd89403..22b1c34cab 100644 --- a/src/slic3r/GUI/Widgets/Button.cpp +++ b/src/slic3r/GUI/Widgets/Button.cpp @@ -162,6 +162,15 @@ void Button::SetCenter(bool isCenter) { this->isCenter = isCenter; } +void Button::SetIndicator(bool on) +{ + if (m_show_indicator == on) + return; + m_show_indicator = on; + messureSize(); + Refresh(); +} + void Button::SetVertical(bool vertical) { this->vertical = vertical; @@ -324,6 +333,13 @@ void Button::render(wxDC& dc) szContent.x -= d; } } + if (m_show_indicator) { + const int dot = FromDIP(6); + if (vertical) + szContent.y += dot + FromDIP(6); + else + szContent.x += dot + FromDIP(6); + } // move to center wxRect rcContent = { {0, 0}, size }; if (isCenter) { @@ -364,6 +380,17 @@ void Button::render(wxDC& dc) #endif dc.DrawText(text, pt); } + if (m_show_indicator) { + const int dot = FromDIP(6); // diameter + wxPoint dot_pt; + dot_pt.x = pt.x + (text.IsEmpty() ? 0 : textSize.x) + FromDIP(6) + dot / 2; + // Centre on the content vertically; a bitmap-only (empty-label) tab has no text row. + dot_pt.y = text.IsEmpty() ? rcContent.y + rcContent.height / 2 : pt.y + textSize.y / 2; + const wxColour c = StateColor::darkModeColorFor(m_indicator_color); + dc.SetBrush(wxBrush(c)); + dc.SetPen(wxPen(c)); + dc.DrawCircle(dot_pt, dot / 2); + } } void Button::messureSize() @@ -388,6 +415,14 @@ void Button::messureSize() if (szIcon.y > szContent.y) szContent.y = szIcon.y; } } + if (m_show_indicator) { + // Indicator dot sits to the right of the label: its diameter plus the gap from the text. + const int dot = FromDIP(6); + if (vertical) + szContent.y += dot + FromDIP(6); + else + szContent.x += dot + FromDIP(6); + } wxSize size = szContent + paddingSize * 2; if (minSize.GetHeight() > 0) size.SetHeight(minSize.GetHeight()); diff --git a/src/slic3r/GUI/Widgets/Button.hpp b/src/slic3r/GUI/Widgets/Button.hpp index 2991edd425..758a9a4009 100644 --- a/src/slic3r/GUI/Widgets/Button.hpp +++ b/src/slic3r/GUI/Widgets/Button.hpp @@ -4,28 +4,30 @@ #include "../wxExtensions.hpp" #include "StaticBox.hpp" #include +#include class ButtonProps { public: - static int ChoiceButtonGap(){return 10;}; - static int WindowButtonGap(){return 10;}; + static int ChoiceButtonGap() { return 10; }; + static int WindowButtonGap() { return 10; }; }; -enum class ButtonStyle{ +enum class ButtonStyle { Regular, Confirm, Alert, Disabled, }; -enum class ButtonType{ - Compact , // Font10 FullyRounded For spaces with less areas - Window , // Font12 FullyRounded For regular buttons in windows and not related with parameter boxes - Choice , // Font14 Semi-Rounded For dialog/window choice buttons +enum class ButtonType { + Compact, // Font10 FullyRounded For spaces with less areas + Window, // Font12 FullyRounded For regular buttons in windows and not related with parameter boxes + Choice, // Font14 Semi-Rounded For dialog/window choice buttons Parameter, // Font14 Semi-Rounded For buttons that near parameter boxes - Icon , // ------ Semi-Rounded For buttons that only has icons. icons should be 16x16 and iconSize has to be defined as 16 while creation of button - Expanded , // Font14 Semi-Rounded For full length buttons. ex. buttons in static box + Icon, // ------ Semi-Rounded For buttons that only has icons. icons should be 16x16 and iconSize has to be defined as 16 while + // creation of button + Expanded, // Font14 Semi-Rounded For full length buttons. ex. buttons in static box }; class Button : public StaticBox @@ -36,15 +38,17 @@ class Button : public StaticBox wxSize paddingSize; ScalableBitmap active_icon; - StateColor text_color; + StateColor text_color; bool pressedDown = false; bool m_selected = true; - bool canFocus = true; + bool canFocus = true; bool isCenter = true; bool vertical = false; + bool m_show_indicator = false; + wxColour m_indicator_color = wxColour("#009688"); - static const int buttonWidth = 200; + static const int buttonWidth = 200; static const int buttonHeight = 50; public: @@ -68,19 +72,23 @@ public: void SetStyle(const ButtonStyle style /*= ButtonStyle::Regular*/, const ButtonType type /*= ButtonType::None*/); - void SetTextColor(StateColor const &color); + void SetTextColor(StateColor const& color); - void SetTextColorNormal(wxColor const &color); + void SetTextColorNormal(wxColor const& color); void SetSelected(bool selected = true) { m_selected = selected; } + // Show a small coloured dot to the right of the label (used by TabCtrl tabs to flag that + // the tab's category has a selected/toggled setting). + void SetIndicator(bool on); + // Only meant to be used by inspector, not public API ButtonStyle GetStyle() const { return m_style; } - ButtonType GetType() const { return m_type; } - bool IsSelected() const { return m_selected; } + ButtonType GetType() const { return m_type; } + bool IsSelected() const { return m_selected; } bool Enable(bool enable = true) override; - void EnableTooltipEvenDisabled();// The tip will be shown even if the button is disabled + void EnableTooltipEvenDisabled(); // The tip will be shown even if the button is disabled void SetCanFocus(bool canFocus) override; @@ -104,7 +112,7 @@ protected: private: bool m_has_style = false; ButtonStyle m_style; - ButtonType m_type; + ButtonType m_type; void paintEvent(wxPaintEvent& evt); @@ -115,10 +123,10 @@ private: // some useful events void mouseDown(wxMouseEvent& event); void mouseReleased(wxMouseEvent& event); - void mouseCaptureLost(wxMouseCaptureLostEvent &event); - void keyDownUp(wxKeyEvent &event); + void mouseCaptureLost(wxMouseCaptureLostEvent& event); + void keyDownUp(wxKeyEvent& event); - // + // void sendButtonEvent(); // parent motion diff --git a/src/slic3r/GUI/Widgets/StaticBox.cpp b/src/slic3r/GUI/Widgets/StaticBox.cpp index 6391c1a8c4..f6e7ca67f6 100644 --- a/src/slic3r/GUI/Widgets/StaticBox.cpp +++ b/src/slic3r/GUI/Widgets/StaticBox.cpp @@ -7,6 +7,7 @@ BEGIN_EVENT_TABLE(StaticBox, wxWindow) // catch paint events //EVT_ERASE_BACKGROUND(StaticBox::eraseEvent) +EVT_SIZE(StaticBox::sizeEvent) EVT_PAINT(StaticBox::paintEvent) END_EVENT_TABLE() @@ -140,6 +141,12 @@ void StaticBox::eraseEvent(wxEraseEvent& evt) #endif } +void StaticBox::sizeEvent(wxSizeEvent& evt) +{ + Refresh(); + evt.Skip(); +} + void StaticBox::paintEvent(wxPaintEvent& evt) { // depending on your system you may need to look at double-buffered dcs diff --git a/src/slic3r/GUI/Widgets/StaticBox.hpp b/src/slic3r/GUI/Widgets/StaticBox.hpp index b7cdee34ef..363d431c8e 100644 --- a/src/slic3r/GUI/Widgets/StaticBox.hpp +++ b/src/slic3r/GUI/Widgets/StaticBox.hpp @@ -46,6 +46,8 @@ public: protected: void eraseEvent(wxEraseEvent& evt); + void sizeEvent(wxSizeEvent& evt); + void paintEvent(wxPaintEvent& evt); void render(wxDC& dc); diff --git a/src/slic3r/GUI/Widgets/TabCtrl.cpp b/src/slic3r/GUI/Widgets/TabCtrl.cpp index 882d2f6897..34de109b8f 100644 --- a/src/slic3r/GUI/Widgets/TabCtrl.cpp +++ b/src/slic3r/GUI/Widgets/TabCtrl.cpp @@ -2,8 +2,8 @@ #include -wxDEFINE_EVENT( wxEVT_TAB_SEL_CHANGING, wxCommandEvent ); -wxDEFINE_EVENT( wxEVT_TAB_SEL_CHANGED, wxCommandEvent ); +wxDEFINE_EVENT(wxEVT_TAB_SEL_CHANGING, wxCommandEvent); +wxDEFINE_EVENT(wxEVT_TAB_SEL_CHANGED, wxCommandEvent); BEGIN_EVENT_TABLE(TabCtrl, StaticBox) @@ -22,11 +22,7 @@ END_EVENT_TABLE() #define TAB_BUTTON_PADDING_Y 2 #define TAB_BUTTON_PADDING TAB_BUTTON_PADDING_X, TAB_BUTTON_PADDING_Y -TabCtrl::TabCtrl(wxWindow * parent, - wxWindowID id, - const wxPoint & pos, - const wxSize & size, - long style) +TabCtrl::TabCtrl(wxWindow* parent, wxWindowID id, const wxPoint& pos, const wxSize& size, long style) : StaticBox(parent, id, pos, size, style) { #if 0 @@ -42,14 +38,11 @@ TabCtrl::TabCtrl(wxWindow * parent, hsizer->Add(sizer, 0, wxEXPAND | wxBOTTOM, border_width * 4); SetSizer(hsizer); Bind(wxEVT_COMMAND_BUTTON_CLICKED, &TabCtrl::buttonClicked, this); - //wxString reason; - //IsTransparentBackgroundSupported(&reason); + // wxString reason; + // IsTransparentBackgroundSupported(&reason); } -TabCtrl::~TabCtrl() -{ - delete images; -} +TabCtrl::~TabCtrl() { delete images; } int TabCtrl::GetSelection() const { return sel; } @@ -75,15 +68,13 @@ void TabCtrl::SelectItem(int item) Refresh(); } -void TabCtrl::Unselect() -{ - SelectItem(-1); -} +void TabCtrl::Unselect() { SelectItem(-1); } void TabCtrl::Rescale() { - for (auto & b : btns) + for (auto& b : btns) b->Rescale(); + relayout(); } bool TabCtrl::SetFont(wxFont const& font) @@ -95,28 +86,32 @@ bool TabCtrl::SetFont(wxFont const& font) return true; } -int TabCtrl::AppendItem(const wxString &item, - int image, int selImage, - void * clientData) +int TabCtrl::AppendItem(const wxString& item, int image, int selImage, void* clientData) { - Button * btn = new Button(); + Button* btn = new Button(); btn->Create(this, item, "", wxBORDER_NONE); btn->SetFont(GetFont()); - btn->SetTextColor(StateColor( - std::make_pair(0x6B6B6C, (int) StateColor::NotChecked), - std::make_pair(*wxLIGHT_GREY, (int) StateColor::Normal))); + btn->SetTextColor( + StateColor(std::make_pair(0x6B6B6C, (int) StateColor::NotChecked), std::make_pair(wxColour("#262E30"), (int) StateColor::Normal))); btn->SetBackgroundColor(StateColor()); btn->SetCornerRadius(0); btn->SetPaddingSize({TAB_BUTTON_PADDING}); btns.push_back(btn); if (btns.size() > 1) sizer->GetItem(sizer->GetItemCount() - 1)->SetMinSize({0, 0}); - sizer->Add(btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, TAB_BUTTON_SPACE); + sizer->Add(btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, item_space); sizer->AddStretchSpacer(1); relayout(); return btns.size() - 1; } +int TabCtrl::AppendItem(const wxString& item, const wxBitmap& bitmap, void* clientData) +{ + const int index = AppendItem(item, -1, -1, clientData); + SetItemBitmap(index, bitmap); + return index; +} + bool TabCtrl::DeleteItem(int item) { if (item < 0 || item >= btns.size()) { @@ -136,7 +131,7 @@ bool TabCtrl::DeleteItem(int item) sizer->GetItem(sizer->GetItemCount() - 1)->SetMinSize({0, 0}); if (selection_changed) { - sel--; // `relayout()` uses `sel` so we need to update this before calling `relayout()` + sel--; // `relayout()` uses `sel` so we need to update this before calling `relayout()` } relayout(); if (selection_changed) { @@ -159,75 +154,87 @@ void TabCtrl::DeleteAllItems() unsigned int TabCtrl::GetCount() const { return btns.size(); } -wxString TabCtrl::GetItemText(unsigned int item) const +wxString TabCtrl::GetItemText(unsigned int item) const { return item < btns.size() ? btns[item]->GetLabel() : wxString{}; } + +void TabCtrl::SetItemText(unsigned int item, wxString const& value) { - return item < btns.size() ? btns[item]->GetLabel() : wxString{}; + if (item >= btns.size()) + return; + btns[item]->SetLabel(value); } -void TabCtrl::SetItemText(unsigned int item, wxString const &value) +void TabCtrl::SetItemBitmap(unsigned int item, const wxBitmap& bitmap) { - if (item >= btns.size()) return; - btns[item]->SetLabel(value); + if (item >= btns.size()) + return; + btns[item]->SetIcon(bitmap); + relayout(); +} + +void TabCtrl::SetItemIndicator(unsigned int item, bool on) +{ + if (item >= btns.size()) + return; + btns[item]->SetIndicator(on); + relayout(); } bool TabCtrl::GetItemBold(unsigned int item) const { - if (item >= btns.size()) return false; + if (item >= btns.size()) + return false; return btns[item]->GetFont() == bold; } void TabCtrl::SetItemBold(unsigned int item, bool bold) { - if (item >= btns.size()) return; + if (item >= btns.size()) + return; btns[item]->SetFont(bold ? this->bold : GetFont()); btns[item]->Rescale(); } void* TabCtrl::GetItemData(unsigned int item) const { - if (item >= btns.size()) return nullptr; + if (item >= btns.size()) + return nullptr; return btns[item]->GetClientData(); } void TabCtrl::SetItemData(unsigned int item, void* clientData) { - if (item >= btns.size()) return; + if (item >= btns.size()) + return; btns[item]->SetClientData(clientData); } void TabCtrl::AssignImageList(wxImageList* imageList) { - if (images == imageList) return; + if (images == imageList) + return; delete images; images = imageList; } -void TabCtrl::SetItemTextColour(unsigned int item, const StateColor &col) +void TabCtrl::SetItemTextColour(unsigned int item, const StateColor& col) { - if (item >= btns.size()) return; + if (item >= btns.size()) + return; btns[item]->SetTextColor(col); } -int TabCtrl::GetFirstVisibleItem() const -{ - return btns.size() == 0 ? -1 : 0; -} +int TabCtrl::GetFirstVisibleItem() const { return btns.size() == 0 ? -1 : 0; } -int TabCtrl::GetNextVisible(int item) const -{ - return ++item < btns.size() ? item : -1; -} +int TabCtrl::GetNextVisible(int item) const { return ++item < btns.size() ? item : -1; } -bool TabCtrl::IsVisible(unsigned int item) const -{ - return true; -} +bool TabCtrl::IsVisible(unsigned int item) const { return true; } void TabCtrl::DoSetSize(int x, int y, int width, int height, int sizeFlags) { auto size = GetSize(); wxWindow::DoSetSize(x, y, width, height, sizeFlags); - if (size == GetSize()) return; + if (size == GetSize()) + return; relayout(); } @@ -235,7 +242,9 @@ void TabCtrl::DoSetSize(int x, int y, int width, int height, int sizeFlags) WXLRESULT TabCtrl::MSWWindowProc(WXUINT nMsg, WXWPARAM wParam, WXLPARAM lParam) { - if (nMsg == WM_GETDLGCODE) { return DLGC_WANTARROWS; } + if (nMsg == WM_GETDLGCODE) { + return DLGC_WANTARROWS; + } return wxWindow::MSWWindowProc(nMsg, wParam, lParam); } @@ -244,15 +253,15 @@ WXLRESULT TabCtrl::MSWWindowProc(WXUINT nMsg, WXWPARAM wParam, WXLPARAM lParam) void TabCtrl::relayout() { int offset = 10; - int item = sel + 1; - int first = 0; + int item = sel + 1; + int first = 0; for (int i = 0; i < item; ++i) - offset += btns[i]->GetMinSize().x + TAB_BUTTON_SPACE * 2; + offset += btns[i]->GetMinSize().x + item_space * 2; if (item < btns.size()) - offset += btns[item]->GetMinSize().x + TAB_BUTTON_SPACE * 2; - int width = GetSize().x; + offset += btns[item]->GetMinSize().x + item_space * 2; + int width = GetSize().x; for (int i = 0; i < btns.size(); ++i) { - auto size = btns[i]->GetMinSize().x + TAB_BUTTON_SPACE * 2; + auto size = btns[i]->GetMinSize().x + item_space * 2; if (i < sel && offset > width) { sizer->Show(i * 2 + 1, false); sizer->Show(i * 2 + 2, false); @@ -273,14 +282,32 @@ void TabCtrl::relayout() sizer->GetItem(i * 2 + 2)->SetMinSize({0, 0}); } if (item >= btns.size()) - -- item; + --item; // Keep spacing 2 ~ 10 TAB_BUTTON_SPACE - int b = GetSize().x - offset - 10 - (item + 1 - first) * TAB_BUTTON_SPACE * 8; + int b = GetSize().x - offset - 10 - (item + 1 - first) * item_space * 8; sizer->GetItem(item * 2 + 2)->SetMinSize({b > 0 ? b : 0, 0}); Layout(); } -void TabCtrl::buttonClicked(wxCommandEvent &event) +void TabCtrl::SetItemSpace(int space) +{ + if (space < 0 || space == item_space) + return; + item_space = space; + relayout(); + Refresh(); +} + +int TabCtrl::GetFullSize() const +{ + // Mirrors relayout(): a 10px leading spacer plus every button's min width and spacing. + int width = 10; + for (const Button* btn : btns) + width += btn->GetMinSize().x + item_space * 2; + return width; +} + +void TabCtrl::buttonClicked(wxCommandEvent& event) { SetFocus(); auto btn = event.GetEventObject(); @@ -288,7 +315,7 @@ void TabCtrl::buttonClicked(wxCommandEvent &event) SelectItem(iter == btns.end() ? -1 : iter - btns.begin()); } -void TabCtrl::keyDown(wxKeyEvent &event) +void TabCtrl::keyDown(wxKeyEvent& event) { switch (event.GetKeyCode()) { case WXK_UP: @@ -307,11 +334,13 @@ void TabCtrl::keyDown(wxKeyEvent &event) void TabCtrl::doRender(wxDC& dc) { wxSize size = GetSize(); - int states = state_handler.states(); - if (sel < 0) { return; } + int states = state_handler.states(); + if (sel < 0) { + return; + } - auto x1 = btns[sel]->GetPosition().x; - auto x2 = x1 + btns[sel]->GetSize().x; + auto x1 = btns[sel]->GetPosition().x; + auto x2 = x1 + btns[sel]->GetSize().x; const int BS2 = (1 + border_width) / 2; #if 0 const int BS = border_width / 2; diff --git a/src/slic3r/GUI/Widgets/TabCtrl.hpp b/src/slic3r/GUI/Widgets/TabCtrl.hpp index 04b5b8e24e..493c4edee5 100644 --- a/src/slic3r/GUI/Widgets/TabCtrl.hpp +++ b/src/slic3r/GUI/Widgets/TabCtrl.hpp @@ -3,32 +3,30 @@ #include "Button.hpp" -wxDECLARE_EVENT( wxEVT_TAB_SEL_CHANGING, wxCommandEvent ); -wxDECLARE_EVENT( wxEVT_TAB_SEL_CHANGED, wxCommandEvent ); +wxDECLARE_EVENT(wxEVT_TAB_SEL_CHANGING, wxCommandEvent); +wxDECLARE_EVENT(wxEVT_TAB_SEL_CHANGED, wxCommandEvent); class TabCtrl : public StaticBox { std::vector btns; wxImageList* images = nullptr; - wxBoxSizer * sizer = nullptr; + wxBoxSizer* sizer = nullptr; int sel = -1; wxFont bold; + int item_space = 2; // space around each button, both sides (SetItemSpace) public: - TabCtrl(wxWindow * parent, - wxWindowID id, - const wxPoint & pos = wxDefaultPosition, - const wxSize & size = wxDefaultSize, - long style = 0); + TabCtrl(wxWindow* parent, wxWindowID id, const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize, long style = 0); ~TabCtrl(); public: - virtual bool SetFont(wxFont const & font) override; + virtual bool SetFont(wxFont const& font) override; public: - int AppendItem(const wxString &item, int image = -1, int selImage = -1, void *clientData = nullptr); + int AppendItem(const wxString& item, int image = -1, int selImage = -1, void* clientData = nullptr); + int AppendItem(const wxString& item, const wxBitmap& bitmap, void* clientData = nullptr); bool DeleteItem(int item); @@ -36,7 +34,7 @@ public: unsigned int GetCount() const; - int GetSelection() const; + int GetSelection() const; void SelectItem(int item); @@ -45,15 +43,19 @@ public: virtual void Rescale(); wxString GetItemText(unsigned int item) const; - void SetItemText(unsigned int item, wxString const &value); + void SetItemText(unsigned int item, wxString const& value); + void SetItemBitmap(unsigned int item, const wxBitmap& bitmap); - bool GetItemBold(unsigned int item) const; - void SetItemBold(unsigned int item, bool bold); + // Show/hide the small "has selection" dot next to a tab's text. + void SetItemIndicator(unsigned int item, bool on); - void* GetItemData(unsigned int item) const; - void SetItemData(unsigned int item, void *clientData); - - void AssignImageList(wxImageList *imageList); + bool GetItemBold(unsigned int item) const; + void SetItemBold(unsigned int item, bool bold); + + void* GetItemData(unsigned int item) const; + void SetItemData(unsigned int item, void* clientData); + + void AssignImageList(wxImageList* imageList); void SetItemTextColour(unsigned int item, const StateColor& col); @@ -62,6 +64,12 @@ public: int GetNextVisible(int item) const; bool IsVisible(unsigned int item) const; + // Extra space around each tab button (in px on both sides). Defaults to the control-wide + // standard; call before appending items so every button picks it up. + void SetItemSpace(int space); + + int GetFullSize() const; + private: virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO) override; @@ -71,10 +79,10 @@ private: void relayout(); - void buttonClicked(wxCommandEvent & event); - void keyDown(wxKeyEvent &event); + void buttonClicked(wxCommandEvent& event); + void keyDown(wxKeyEvent& event); - void doRender(wxDC & dc) override; + void doRender(wxDC& dc) override; // some useful events bool sendTabCtrlEvent(bool changing = false); diff --git a/src/slic3r/Utils/MacDarkMode.mm b/src/slic3r/Utils/MacDarkMode.mm index cecd90044b..2bce7835e8 100644 --- a/src/slic3r/Utils/MacDarkMode.mm +++ b/src/slic3r/Utils/MacDarkMode.mm @@ -57,7 +57,7 @@ void set_miniaturizable(void * window) while(viewObject = (NSView *)[viewEnum nextObject]) { if([viewObject class] == [NSTextField self]) { //[(NSTextField*)viewObject setTextColor : NSColor.whiteColor]; - mainframe_text_field = viewObject; + mainframe_text_field = (NSTextField*)viewObject; } } } @@ -74,7 +74,7 @@ void set_title_colour_after_set_title(void * window) while(viewObject = (NSView *)[viewEnum nextObject]) { if([viewObject class] == [NSTextField self]) { [(NSTextField*)viewObject setTextColor : NSColor.whiteColor]; - mainframe_text_field = viewObject; + mainframe_text_field = (NSTextField*)viewObject; } } diff --git a/src/slic3r/Utils/PresetUpdater.cpp b/src/slic3r/Utils/PresetUpdater.cpp index 663a4ce8c3..23957f6500 100644 --- a/src/slic3r/Utils/PresetUpdater.cpp +++ b/src/slic3r/Utils/PresetUpdater.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -29,6 +30,7 @@ #include "libslic3r/format.hpp" #include "libslic3r/Utils.hpp" #include "libslic3r/PresetBundle.hpp" +#include "libslic3r/PresetCacheFormat.hpp" #include "libslic3r_version.h" #include "slic3r/GUI/GUI.hpp" #include "slic3r/GUI/GUI_App.hpp" @@ -96,6 +98,8 @@ struct Update bool forced_update; //BBS: add directory support bool is_directory {false}; + // Orca: a vendor update may be the cache-only form. + bool is_opc {false}; Update() {} //BBS: add directory support @@ -126,13 +130,24 @@ struct Update //BBS: add directory support void install() const { - if (is_directory) { + if (is_directory) { copy_directory_recursively(source, target, file_filter); - } - else { + } else { copy_file_fix(source, target); + + // A vendor must be installed in exactly one form. Remove the + // representation that would otherwise be stale or shadow this one. + boost::system::error_code ec; + if (is_opc) { + fs::remove(target.parent_path() / (vendor + ".json"), ec); + ec.clear(); + fs::remove_all(target.parent_path() / vendor, ec); + } + else { + fs::remove(target.parent_path() / (vendor + ".opc"), ec); + } } - } + } friend std::ostream& operator<<(std::ostream& os, const Update &self) { @@ -180,6 +195,8 @@ struct Updates std::vector updates; }; +static bool reload_configs_update_gui(); + wxDEFINE_EVENT(EVT_SLIC3R_VERSION_ONLINE, wxCommandEvent); wxDEFINE_EVENT(EVT_SLIC3R_EXPERIMENTAL_VERSION_ONLINE, wxCommandEvent); @@ -207,6 +224,10 @@ struct PresetUpdater::priv // Per-vendor update checking std::set checked_vendors; + // Orca (PR #130): changelog text for each vendor, captured in memory during + // sync_vendor_config()/check_new_vendors() instead of written beside the cache. + std::unordered_map vendor_changelogs; + mutable std::mutex vendor_changelogs_mutex; std::vector vendor_check_threads; std::atomic vendor_check_cancel{false}; @@ -231,6 +252,8 @@ struct PresetUpdater::priv void parse_version_string(const std::string& body) const; void sync_resources(std::string http_url, std::map &resources, bool check_patch = false, std::string current_version="", std::string changelog_file=""); void sync_vendor_config(const std::string& vendor_id); + void check_new_vendors(const std::set& system_vendors, + std::function, bool)> callback); void sync_tooltip(std::string http_url, std::string language); void sync_plugins(std::string http_url, std::string plugin_version); void sync_printer_config(std::string http_url); @@ -268,7 +291,7 @@ void PresetUpdater::priv::set_download_prefs(AppConfig *app_config) version_check_url = app_config->version_check_url(); auto profile_update_url = app_config->profile_update_url(); - if (!profile_update_url.empty()) + if (!profile_update_url.empty() && app_config->get_bool("enable_ota")) enabled_config_update = true; else enabled_config_update = false; @@ -671,6 +694,9 @@ void PresetUpdater::priv::sync_vendor_config(const std::string& vendor_id) std::string online_version_str; // this represents the PROFILE VERSION, not ORCA VERSION std::string download_url_str; + std::string changelog; + + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] fetching vendor update status from " << url; Http::get(url) .timeout_connect(5) @@ -683,9 +709,12 @@ void PresetUpdater::priv::sync_vendor_config(const std::string& vendor_id) if (http_status != 200) return; try { json j = json::parse(body); + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] url: " << url << " returned:" << body; + if (j.contains("vendor_version") && j.contains("download_url")) { online_version_str = j["vendor_version"].get(); download_url_str = j["download_url"].get(); + changelog = j.value("changelog", std::string()); } } catch (const std::exception& e) { BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] vendor check JSON parse failed: " << e.what(); @@ -707,6 +736,10 @@ void PresetUpdater::priv::sync_vendor_config(const std::string& vendor_id) boost::system::error_code ec; fs::remove_all(cache_profile_path / vendor_id, ec); fs::remove(cache_profile_path / (vendor_id + ".json"), ec); + // Orca: the OPC cache is the vendor's whole installation in one file; clear it too. + fs::remove(cache_profile_path / (vendor_id + ".opc"), ec); + // Best-effort cleanup of the legacy on-disk changelog written by older builds + // (changelogs are now kept in memory - see vendor_changelogs). fs::remove(cache_profile_path / (vendor_id + ".changelog"), ec); // Download the zip @@ -735,7 +768,7 @@ void PresetUpdater::priv::sync_vendor_config(const std::string& vendor_id) if (!download_ok || cancel || vendor_check_cancel) return; // Extract vendor profile bundles under ota/profiles. The downloaded zip contains - // the vendor json/folder at its root. + // either the vendor json/folder or the vendor cache at its root. BOOST_LOG_TRIVIAL(info) << "[Orca Updater] extracting update for " << vendor_id; if (!extract_file(download_file, cache_profile_path)) { BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] extraction failed for " << vendor_id; @@ -745,6 +778,27 @@ void PresetUpdater::priv::sync_vendor_config(const std::string& vendor_id) if (cancel || vendor_check_cancel) return; + const fs::path cached_vendor_json = cache_profile_path / (vendor_id + ".json"); + const fs::path cached_vendor_folder = cache_profile_path / vendor_id; + + const fs::path cached_vendor_opc = cache_profile_path / (vendor_id + ".opc"); + + bool is_json_update = fs::is_regular_file(cached_vendor_json) && fs::is_directory(cached_vendor_folder) && !fs::is_empty(cached_vendor_folder); + bool is_opc_update = fs::is_regular_file(cached_vendor_opc); + if (!is_json_update && !is_opc_update) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] rejected update for " << vendor_id + << ": expected " << vendor_id << ".json and a non-empty " + << vendor_id << " directory, or OPC update format."; + fs::remove_all(cached_vendor_folder, ec); + fs::remove(cached_vendor_json, ec); + return; + } + + { + std::lock_guard lock(vendor_changelogs_mutex); + vendor_changelogs[vendor_id] = std::move(changelog); + } + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] vendor " << vendor_id << " update cached, notifying UI"; GUI::wxGetApp().CallAfter([] { GUI::wxGetApp().check_config_updates_from_updater(); @@ -1039,6 +1093,7 @@ void PresetUpdater::priv::check_installed_vendor_profiles() const BOOST_LOG_TRIVIAL(info) << "[Orca Updater]:Checking whether the profile from resource is newer"; AppConfig *app_config = GUI::wxGetApp().app_config; + const auto enabled_vendors = app_config->vendors(); std::set bundles; @@ -1051,8 +1106,8 @@ void PresetUpdater::priv::check_installed_vendor_profiles() const const auto is_vendor_enabled = (vendor_name == PresetBundle::ORCA_DEFAULT_BUNDLE) // always update configs from resource to vendor for ORCA_DEFAULT_BUNDLE || (enabled_vendors.find(vendor_name) != enabled_vendors.end()); - if (enabled_config_update) { - if (is_vendor_installed(vendor_name)) { + if (is_vendor_installed(vendor_name)) { + if (enabled_config_update) { if (is_vendor_enabled) { // Orca: whichever form of the vendor resources ships at the newer // version is the one installing lays down, and the one to judge @@ -1067,17 +1122,12 @@ void PresetUpdater::priv::check_installed_vendor_profiles() const << resource_ver.to_string() << " from resource, old version " << vendor_ver.to_string(); bundles.insert(vendor_name); } - } - else { - //need to be removed because not installed + } else { + // need to be removed because not installed remove_installed_vendor(vendor_name); } } - else if (is_vendor_enabled) { - bundles.insert(vendor_name); - } - } - else if (is_vendor_enabled) { + } else if (is_vendor_enabled) { bundles.insert(vendor_name); } } @@ -1147,68 +1197,110 @@ Updates PresetUpdater::priv::get_config_updates(const Semver &old_slic3r_version if (!fs::exists(cache_profile_path)) return updates; - for (auto &dir_entry : boost::filesystem::directory_iterator(cache_profile_path)) { - const auto &path = dir_entry.path(); - std::string file_path = path.string(); - if (is_json_file(file_path)) { - const auto path_in_vendor = vendor_path / path.filename(); - std::string vendor_name = path.filename().string(); - // Remove the .json suffix. - vendor_name.erase(vendor_name.size() - 5); - auto print_in_cache = (cache_profile_path / vendor_name / PRESET_PRINT_NAME); - auto filament_in_cache = (cache_profile_path / vendor_name / PRESET_FILAMENT_NAME); - auto machine_in_cache = (cache_profile_path / vendor_name / PRESET_PRINTER_NAME); + // Orca (PR #130): vendor changelogs are captured in memory during + // sync_vendor_config()/check_new_vendors(), not written beside the cache. + std::unordered_map changelogs; + { + std::lock_guard lock(vendor_changelogs_mutex); + changelogs = vendor_changelogs; + } - if (is_vendor_installed(vendor_name) - || fs::exists(print_in_cache) - || fs::exists(filament_in_cache) - || fs::exists(machine_in_cache)) { - // Orca: a vendor installed as a preset cache carries its version there. - Semver vendor_ver = installed_vendor_version(vendor_name); + for (auto &dir_entry : boost::filesystem::directory_iterator(cache_profile_path)) { + const auto &path = dir_entry.path(); + std::string file_path = path.string(); + const bool is_opc_file = boost::iequals(path.extension().string(), ".opc"); + if (!is_json_file(file_path) && !is_opc_file) + continue; - std::map key_values; - std::vector keys(3); - Semver cache_ver; - keys[0] = BBL_JSON_KEY_VERSION; - keys[1] = BBL_JSON_KEY_DESCRIPTION; - keys[2] = BBL_JSON_KEY_FORCE_UPDATE; - get_values_from_json(file_path, keys, key_values); - std::string description = key_values[BBL_JSON_KEY_DESCRIPTION]; - bool force_update = false; - if (key_values.find(BBL_JSON_KEY_FORCE_UPDATE) != key_values.end()) - force_update = (key_values[BBL_JSON_KEY_FORCE_UPDATE] == "1")?true:false; - auto config_version = Semver::parse(key_values[BBL_JSON_KEY_VERSION]); - if (config_version) - cache_ver = *config_version; + const std::string vendor_name = path.stem().string(); + auto print_in_cache = (cache_profile_path / vendor_name / PRESET_PRINT_NAME); + auto filament_in_cache = (cache_profile_path / vendor_name / PRESET_FILAMENT_NAME); + auto machine_in_cache = (cache_profile_path / vendor_name / PRESET_PRINTER_NAME); - std::string changelog; - std::string changelog_file = (cache_profile_path / (vendor_name + ".changelog")).string(); - boost::nowide::ifstream ifs(changelog_file); - if (ifs) { - std::ostringstream oss; - oss<< ifs.rdbuf(); - changelog = oss.str(); - ifs.close(); - } + // Orca (PR #130): a JSON cache entry is only meaningful next to a non-empty + // / preset directory; a stray or half-downloaded .json is + // skipped. An .opc cache is a single self-contained file (validated below), + // so this check does not apply to it. + if (!is_opc_file) { + const auto vendor_folder_in_cache = cache_profile_path / vendor_name; + if (!fs::is_regular_file(path) || !fs::is_directory(vendor_folder_in_cache) || + fs::is_empty(vendor_folder_in_cache)) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater]:ignoring invalid cached update for " + << vendor_name << ": expected " << vendor_name + << ".json and a non-empty " << vendor_name << " directory"; + continue; + } + } - if (vendor_ver < cache_ver) { - BOOST_LOG_TRIVIAL(info) << "[Orca Updater]:need to update settings from " << vendor_ver.to_string() - << " to newer version " << cache_ver.to_string() << ", app version " << SLIC3R_VERSION; - Version version; - version.config_version = cache_ver; - version.comment = description; - // Orca: update vendor.json - updates.updates.emplace_back(std::move(file_path), path_in_vendor.string(), std::move(version), vendor_name, changelog, "", force_update, false); - //Orca: update vendor folder - updates.updates.emplace_back(cache_profile_path / vendor_name, vendor_path / vendor_name, Version(), vendor_name, "", "", force_update, true); - } else { - BOOST_LOG_TRIVIAL(info) << "[Orca Updater]:cached settings for " << vendor_name - << " are not newer than installed version, installed " << vendor_ver.to_string() - << ", cached " << cache_ver.to_string(); - } - } - } - } + if (is_vendor_installed(vendor_name) + || is_opc_file + || fs::exists(print_in_cache) + || fs::exists(filament_in_cache) + || fs::exists(machine_in_cache)) { + // Orca: a vendor installed as a preset cache carries its version there. + Semver vendor_ver = installed_vendor_version(vendor_name); + + Semver cache_ver; + std::string description; + bool force_update = false; + if (is_opc_file) { + cache_ver = VendorCacheFile::usable_version(file_path, vendor_name); + if (!cache_ver.valid()) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater]:ignoring unreadable vendor cache " << file_path; + continue; + } + } + else { + std::map key_values; + std::vector keys(3); + keys[0] = BBL_JSON_KEY_VERSION; + keys[1] = BBL_JSON_KEY_DESCRIPTION; + keys[2] = BBL_JSON_KEY_FORCE_UPDATE; + get_values_from_json(file_path, keys, key_values); + description = key_values[BBL_JSON_KEY_DESCRIPTION]; + if (key_values.find(BBL_JSON_KEY_FORCE_UPDATE) != key_values.end()) + force_update = (key_values[BBL_JSON_KEY_FORCE_UPDATE] == "1")?true:false; + auto config_version = Semver::parse(key_values[BBL_JSON_KEY_VERSION]); + if (config_version) + cache_ver = *config_version; + } + + // Orca (PR #130): changelog for this vendor was captured in memory at sync time. + const auto changelog_it = changelogs.find(vendor_name); + std::string changelog = changelog_it != changelogs.end() ? changelog_it->second : std::string(); + + if (vendor_ver < cache_ver) { + BOOST_LOG_TRIVIAL(info) << "[Orca Updater]:need to update settings from " << vendor_ver.to_string() + << " to newer version " << cache_ver.to_string() << ", app version " << SLIC3R_VERSION; + Version version; + version.config_version = cache_ver; + version.comment = description; + if (is_opc_file) { + // A cache contains the vendor profile and all presets. + // Install it directly; Update::install removes any + // superseded JSON representation. + auto &update = updates.updates.emplace_back( + std::move(file_path), vendor_path / (vendor_name + ".opc"), + std::move(version), vendor_name, changelog, "", force_update, false); + update.is_opc = true; + } + else { + // JSON profile and its preset directory are installed + // separately, as before. + updates.updates.emplace_back(std::move(file_path), + vendor_path / (vendor_name + ".json"), std::move(version), + vendor_name, changelog, "", force_update, false); + updates.updates.emplace_back(cache_profile_path / vendor_name, + vendor_path / vendor_name, Version(), vendor_name, + "", "", force_update, true); + } + } else { + BOOST_LOG_TRIVIAL(info) << "[Orca Updater]:cached settings for " << vendor_name + << " are not newer than installed version, installed " << vendor_ver.to_string() + << ", cached " << cache_ver.to_string(); + } + } + } return updates; } @@ -1317,6 +1409,9 @@ void PresetUpdater::sync(std::string http_url, std::string language, std::string // after the startup printer preset has been restored. this->p->sync_plugins(http_url, plugin_version); this->p->sync_printer_config(http_url); + // Orca (PR #130): the filament library is always installed, so refresh it + // from the updater on every startup sync rather than deferring to check_vendor_update(). + this->p->sync_vendor_config(PresetBundle::ORCA_FILAMENT_LIBRARY); //if (p->cancel) // return; //remove the tooltip currently @@ -1349,6 +1444,302 @@ void PresetUpdater::check_vendor_update(const std::string& vendor_id) }); } +// Orca: ask the server which vendors from `system_vendors` have a profile bundle available that +// isn't installed yet (or is newer than what's installed). Request body maps vendor id -> currently +// installed profile version (unknown/not-yet-installed vendors report "0.0.0"). Response maps +// vendor id -> {version, download_url, changelog} for each vendor the server has an update for. +// Any such vendor is downloaded and cached under ota/profiles the same way sync_vendor_config() +// does; the caller's check_config_updates_from_updater() -> get_config_updates()/perform_updates() +// flow then installs the cached profiles into data_dir()/system. +// +// Mirrors check_vendor_update()/sync_vendor_config(): the network query and the download/extract +// work run on a background thread (vendor_check_threads), never on the calling (UI) thread. Only +// the confirmation dialog (which must run on the UI thread) and the final callback are marshaled +// back via CallAfter(). +void PresetUpdater::priv::check_new_vendors(const std::set& system_vendors, + std::function, bool)> callback) +{ + vendor_check_threads.emplace_back([this, system_vendors, callback]() { + AppConfig* app_config = GUI::wxGetApp().app_config; + std::string url = app_config->profile_update_url() + "/new?orcaslicer_version=" + Http::url_encode(SoftFever_VERSION); + + auto check_cancel = [this](Http::Progress, bool& cancel_http) { + if (cancel || vendor_check_cancel) + cancel_http = true; + }; + + json request_body = json::object(); + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] checking new vendors for:"; + for (const auto& vendor_id : system_vendors) { + // Orca: installed_vendor_version() reads whichever form the vendor is + // installed as - the .json profile or the .opc preset cache stamp - + // so a cache-only vendor is not reported as version 0.0.0 and then + // endlessly re-offered by the server. + Semver installed_ver = installed_vendor_version(vendor_id); + request_body[vendor_id] = installed_ver.to_string(); + BOOST_LOG_TRIVIAL(info) << vendor_id << " (installed version " << installed_ver.to_string() << ")"; + } + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] new vendor check request url: " << url; + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] new vendor check request body: " << request_body.dump(2); + + json response_json; + bool got_response = false; + + auto post = Http::post(url); + + post.timeout_connect(5); + post.on_progress(check_cancel); + post.header("Content-Type", "application/json"); + post.on_error([](std::string body, std::string error, unsigned http_status) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] new vendor check HTTP error: " << error; + }) + .on_complete([&response_json, &got_response](std::string body, unsigned http_status) { + if (http_status != 200) + return; + try { + json j = json::parse(body); + if (j.is_object()) { + response_json = std::move(j); + got_response = true; + } + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] new vendor check JSON parse failed: " << e.what(); + } + }); + + post.set_post_body(request_body.dump()); + post.perform_sync(); + + if (cancel || vendor_check_cancel) + return; + + if (!got_response) { + GUI::wxGetApp().CallAfter([callback]() { callback({}, false); }); + return; + } + + // Collect candidates before touching the filesystem or network again. + struct NewVendorCandidate + { + std::string vendor_id; + Semver version; + std::string download_url; + std::string changelog; + }; + std::vector candidates; + for (auto it = response_json.begin(); it != response_json.end(); ++it) { + const json& entry = it.value(); + if (!entry.is_object()) + continue; + std::string download_url_str = entry.value("download_url", std::string()); + if (download_url_str.empty()) + continue; + + NewVendorCandidate candidate; + candidate.vendor_id = it.key(); + auto parsed_ver = Semver::parse(entry.value("version", std::string())); + candidate.version = parsed_ver ? *parsed_ver : Semver(); + candidate.download_url = std::move(download_url_str); + candidate.changelog = entry.value("changelog", std::string()); + candidates.push_back(std::move(candidate)); + } + + if (candidates.empty()) { + GUI::wxGetApp().CallAfter([callback]() { callback({}, false); }); + return; + } + + // Orca: the confirmation dialog must run on the UI thread; if confirmed, the actual + // download/install work is dispatched back onto a new background thread from there, + // same as check_vendor_update() does for a single vendor. + GUI::wxGetApp().CallAfter([this, candidates, callback]() { + std::vector updates_msg; + for (const auto& candidate : candidates) + updates_msg.emplace_back(candidate.vendor_id, candidate.version, std::string(), candidate.changelog); + + GUI::MsgUpdateConfig dlg(updates_msg); + if (dlg.ShowModal() != wxID_OK) { + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] user declined installing new vendors"; + callback({}, true); + return; + } + + // Orca: the actual download runs on a background thread below (so it doesn't block the + // UI), but that also means nothing visibly happens for the several seconds it can take + // (longer still if a retry kicks in) — push a notification so it's clear work is + // ongoing rather than looking hung. + { + std::string vendor_list; + for (const auto& candidate : candidates) { + if (!vendor_list.empty()) + vendor_list += ", "; + vendor_list += candidate.vendor_id; + } + GUI::wxGetApp().plater()->get_notification_manager()->push_notification( + _u8L("Downloading new vendor profile(s): ") + vendor_list + _u8L("...")); + } + + vendor_check_threads.emplace_back([this, candidates, callback]() { + auto check_cancel = [this](Http::Progress, bool& cancel_http) { + if (cancel || vendor_check_cancel) + cancel_http = true; + }; + + std::vector new_vendor_ids; + std::vector failed_vendor_ids; + auto cache_profile_path = cache_path / "profiles"; + fs::create_directories(cache_profile_path); + boost::system::error_code ec; + + for (const auto& candidate : candidates) { + if (cancel || vendor_check_cancel) + break; + + const std::string& vendor_id = candidate.vendor_id; + const std::string& download_url_str = candidate.download_url; + std::string changelog = candidate.changelog; + + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] downloading new vendor " << vendor_id << " version " << candidate.version.to_string(); + + // Clear only this vendor's cached data, same as sync_vendor_config(). + fs::remove_all(cache_profile_path / vendor_id, ec); + fs::remove(cache_profile_path / (vendor_id + ".json"), ec); + + fs::path download_file = cache_path / (vendor_id + TMP_EXTENSION); + bool download_ok = false; + + // Orca: same retry pattern as Plater.cpp's project download — a single-shot + // 5s connect timeout against GitHub's redirect chain is prone to transient + // failures (DNS/connect hiccups) that succeed a moment later, so retry a few + // times before giving up rather than failing the whole vendor on one blip. + int retry_count = 0; + const int max_retries = 3; + bool keep_trying = true; + while (keep_trying && retry_count < max_retries) { + retry_count++; + Http::get(download_url_str) + .timeout_connect(5) + .on_progress(check_cancel) + .on_error([&vendor_id, &retry_count, max_retries](std::string body, std::string error, unsigned http_status) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] download failed for new vendor " << vendor_id + << " (attempt " << retry_count << "/" << max_retries << "): " << error; + }) + .on_complete([&](std::string body, unsigned http_status) { + if (http_status != 200) + return; + fs::fstream file(download_file, std::ios::out | std::ios::binary | std::ios::trunc); + if (!file.good()) + return; + file.write(body.c_str(), body.size()); + file.close(); + if (file.good()) + download_ok = true; + }) + .perform_sync(); + + keep_trying = !download_ok && !(cancel || vendor_check_cancel); + } + + if (!download_ok || cancel || vendor_check_cancel) { + if (!download_ok) + failed_vendor_ids.push_back(vendor_id); + continue; + } + + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] extracting new vendor " << vendor_id; + if (!extract_file(download_file, cache_profile_path)) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] extraction failed for new vendor " << vendor_id; + fs::remove(download_file, ec); + failed_vendor_ids.push_back(vendor_id); + continue; + } + fs::remove(download_file, ec); + + const fs::path cached_vendor_json = cache_profile_path / (vendor_id + ".json"); + const fs::path cached_vendor_folder = cache_profile_path / vendor_id; + if (!fs::is_regular_file(cached_vendor_json) || !fs::is_directory(cached_vendor_folder) || + fs::is_empty(cached_vendor_folder)) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] rejected new vendor " << vendor_id << ": expected " << vendor_id + << ".json and a non-empty " << vendor_id << " directory"; + fs::remove_all(cached_vendor_folder, ec); + fs::remove(cached_vendor_json, ec); + failed_vendor_ids.push_back(vendor_id); + continue; + } + + { + std::lock_guard lock(vendor_changelogs_mutex); + vendor_changelogs[vendor_id] = std::move(changelog); + } + + new_vendor_ids.push_back(vendor_id); + } + + if (!new_vendor_ids.empty()) { + // Orca: the user already confirmed via the dialog above, so install right away + // instead of routing through check_config_updates_from_updater(), which only + // queues a passive notification (meant for the silent background per-vendor + // check) requiring yet another click + confirmation before anything is copied + // into data_dir()/system. + GUI::wxGetApp().CallAfter([this, new_vendor_ids] { + AppConfig* app_config = GUI::wxGetApp().app_config; + Updates updates = get_config_updates(app_config->orig_version()); + + // Only install the vendors just confirmed; leave any other unrelated + // pending cached update (from a background sync_vendor_config()) alone, + // still gated behind its own notification/confirmation. + std::set confirmed(new_vendor_ids.begin(), new_vendor_ids.end()); + Updates filtered; + for (auto& update : updates.updates) + if (confirmed.count(update.vendor)) + filtered.updates.push_back(std::move(update)); + + if (filtered.updates.empty()) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] new vendors cached but no updates detected"; + return; + } + + if (!perform_updates(std::move(filtered)) || !reload_configs_update_gui()) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] failed to install new vendors"; + return; + } + + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] new vendors installed"; + for (const auto& vendor_id : new_vendor_ids) { + Semver cur_ver = GUI::wxGetApp().preset_bundle->get_vendor_profile_version(vendor_id); + GUI::wxGetApp().plater()->get_notification_manager()->push_notification( + GUI::NotificationType::PresetUpdateFinished, + GUI::NotificationManager::NotificationLevel::ImportantNotificationLevel, + _u8L("Configuration package: ") + vendor_id + _u8L(" updated to ") + cur_ver.to_string()); + } + }); + } + + if (!failed_vendor_ids.empty()) { + GUI::wxGetApp().CallAfter([failed_vendor_ids] { + std::string vendor_list; + for (const auto& vendor_id : failed_vendor_ids) { + if (!vendor_list.empty()) + vendor_list += ", "; + vendor_list += vendor_id; + } + GUI::wxGetApp().plater()->get_notification_manager()->push_notification( + _u8L("Failed to download vendor profile(s): ") + vendor_list); + }); + } + + GUI::wxGetApp().CallAfter([callback, new_vendor_ids]() { callback(new_vendor_ids, false); }); + }); + }); + }); +} + +void PresetUpdater::check_new_vendors(const std::set& system_vendors, + std::function, bool)> callback) +{ + p->check_new_vendors(system_vendors, std::move(callback)); +} + void PresetUpdater::slic3r_update_notify() { if (! p->enabled_version_check) @@ -1370,10 +1761,9 @@ static bool reload_configs_update_gui() GUI::wxGetApp().load_current_presets(); GUI::wxGetApp().plater()->set_bed_shape(); - return true; + return true; } - PresetUpdater::UpdateResult PresetUpdater::config_update(const Semver& old_slic3r_version, UpdateParams params) const { if (! p->enabled_config_update) { return R_NOOP; } diff --git a/src/slic3r/Utils/PresetUpdater.hpp b/src/slic3r/Utils/PresetUpdater.hpp index 27ec6748f2..ccb0902fd5 100644 --- a/src/slic3r/Utils/PresetUpdater.hpp +++ b/src/slic3r/Utils/PresetUpdater.hpp @@ -1,7 +1,9 @@ #ifndef slic3r_PresetUpdate_hpp_ #define slic3r_PresetUpdate_hpp_ +#include #include +#include #include #include @@ -59,6 +61,12 @@ public: void on_update_notification_confirm(); void do_printer_config_update(); void check_vendor_update(const std::string& vendor_id); + // Orca: async, mirrors check_vendor_update()/sync_vendor_config() — the network query and any + // download/install work happen on a background thread; only the confirmation dialog runs on + // the UI thread. `callback` is invoked on the UI thread with the ids of vendors that were + // installed (empty if none were found, or the user declined) and whether the user declined. + void check_new_vendors(const std::set& system_vendors, + std::function installed_vendors, bool declined)> callback); bool version_check_enabled() const; diff --git a/tests/fff_print/test_fill.cpp b/tests/fff_print/test_fill.cpp index 04e5b61831..aa81570e56 100644 --- a/tests/fff_print/test_fill.cpp +++ b/tests/fff_print/test_fill.cpp @@ -9,6 +9,7 @@ #include #include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/AABBTreeLines.hpp" #include "libslic3r/Fill/Fill.hpp" #include "libslic3r/Flow.hpp" #include "libslic3r/Geometry.hpp" @@ -1229,3 +1230,68 @@ TEST_CASE("Smoothing multiline lightning infill keeps its outlines connected", " REQUIRE(smooth.point_count > sharp.point_count); REQUIRE(smooth.sharp_turns < sharp.sharp_turns); } + +TEST_CASE("Sparse plane-path anchors match the printed infill", "[Fill][InternalBridge][Regression]") +{ + // Orca: Compare generated anchors with actual extrusion across plane-path patterns, + // smoothing, multiline and rotations; an origin shift must not pass as valid support. + const std::string pattern = GENERATE("hilbertcurve", "octagramspiral", "archimedeanchords"); + const std::string smoothing = GENERATE("0%", "100%"); + const int multiline = GENERATE(1, 2); + const bool rotated = GENERATE(false, true); + const bool separated = GENERATE(false, true); + CAPTURE(pattern, smoothing, multiline, rotated, separated); + + auto config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({{"sparse_infill_pattern", pattern}, + {"sparse_infill_density", "15%"}, + {"sparse_infill_smooth_factor", smoothing}, + {"fill_multiline", multiline}, + {"infill_direction", 45}, + {"sparse_infill_rotate_template", rotated ? "0,25,50" : ""}, + {"align_infill_direction_to_model", rotated}, + {"separated_infills", separated}, + {"top_shell_layers", 0}, + {"bottom_shell_layers", 0}, + {"top_shell_thickness", 0}, + {"bottom_shell_thickness", 0}, + {"layer_height", 0.2}, + {"initial_layer_print_height", 0.2}, + {"resolution", 0.012}}); + Print print; + Model model; + TriangleMesh mesh = make_cube(30, 24, 1); + if (separated) { + // Orca: Two disconnected bodies in one object must each use their own infill origin. + TriangleMesh second = make_cube(30, 24, 1); + second.translate(50, 0, 0); + mesh.merge(second); + } + Slic3r::Test::init_print({mesh}, print, model, config, nullptr, false); + if (rotated) { + model.objects.front()->instances.front()->set_rotation(Vec3d(0., 0., Geometry::deg2rad(23.))); + print.apply(model, config); + } + print.process(); + + const Layer &layer = *print.objects().front()->get_layer(4); + Polylines printed; + for (const LayerRegion *region : layer.regions()) + for (const ExtrusionEntity *entity : region->fills.flatten().entities) + if (entity->role() == erInternalInfill) + entity->collect_polylines(printed); + REQUIRE_FALSE(printed.empty()); + const AABBTreeLines::LinesDistancer printed_tree(to_lines(printed)); + + // Orca: Exclude perimeter connections: anchoring and extrusion can trim those differently. + const Polylines anchors = intersection_pl(layer.generate_sparse_infill_polylines_for_anchoring(nullptr, nullptr, nullptr), + shrink(to_polygons(layer.lslices), scale_(3.))); + REQUIRE_FALSE(anchors.empty()); + double max_distance = 0.; + for (const Polyline &path : anchors) + for (const Point &point : path.equally_spaced_points(scale_(0.25))) + max_distance = std::max(max_distance, printed_tree.distance_from_lines(point)); + // Orca: Allow only the configured simplification tolerance; infill-scale offsets + // would hide anchors that no longer coincide with printed lines. + CHECK(unscale(max_distance) <= config.opt_float("resolution")); +} diff --git a/tests/fff_print/test_printobject.cpp b/tests/fff_print/test_printobject.cpp index fb7fe2c1fd..a373a1ad39 100644 --- a/tests/fff_print/test_printobject.cpp +++ b/tests/fff_print/test_printobject.cpp @@ -4,11 +4,18 @@ #include "libslic3r/Print.hpp" #include "libslic3r/Layer.hpp" #include "libslic3r/GCodeReader.hpp" +#include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/AABBTreeLines.hpp" #include "test_helpers.hpp" +#include #include +#include #include +#include +#include +#include using namespace Slic3r; using namespace Slic3r::Test; @@ -130,3 +137,438 @@ TEST_CASE("Initial layer height is honored", "[PrintObject]") REQUIRE_THAT(*layer_zs.begin(), Catch::Matchers::WithinAbs(0.3, 1e-4)); REQUIRE_THAT(*std::next(layer_zs.begin()), Catch::Matchers::WithinAbs(0.5, 1e-4)); } + +static TriangleMesh internal_bridge_step() +{ + // Orca: The smaller tower leaves a shoulder whose solid skin needs internal bridges + // over the sparse infill in the base, without relying on an external model file. + TriangleMesh mesh = make_cube(30, 24, 3); + TriangleMesh tower = make_cube(14, 10, 1); + tower.translate(8, 7, 3); + mesh.merge(tower); + return mesh; +} + +static DynamicPrintConfig internal_bridge_config(const std::string &pattern, int multiline) +{ + auto config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({{"sparse_infill_pattern", pattern}, + {"fill_multiline", multiline}, + {"sparse_infill_density", "15%"}, + {"sparse_infill_smooth_factor", "100%"}, + {"infill_direction", 45}, + {"internal_bridge_angle", 0}, + {"thick_internal_bridges", true}, + {"top_shell_layers", 3}, + {"bottom_shell_layers", 2}, + {"top_shell_thickness", 0}, + {"bottom_shell_thickness", 0}, + {"layer_height", 0.2}, + {"initial_layer_print_height", 0.2}}); + return config; +} + +TEST_CASE("Internal bridge angles follow the lower infill layer and model rotation", "[PrintObject][InternalBridge][Regression]") +{ + const std::string pattern = GENERATE("hilbertcurve", "octagramspiral"); + // Orca: Cover both a central line (odd counts) and offset pairs (even counts). + const int multiline = GENERATE(1, 2, 3); + CAPTURE(multiline); + const double rotation = GENERATE(23., -123.); + const std::vector cycle{10., 30., 70.}; + auto config = internal_bridge_config(pattern, multiline); + config.set_deserialize_strict({{"sparse_infill_rotate_template", "10,30,70"}, + {"align_infill_direction_to_model", true}, + {"separated_infills", false}}); + Print print; + Model model; + init_print({internal_bridge_step()}, print, model, config, nullptr, false); + model.objects.front()->instances.front()->set_rotation(Vec3d(0., 0., Geometry::deg2rad(rotation))); + print.apply(model, config); + print.process(); + const PrintObject &object = *print.objects().front(); + size_t bridges = 0; + for (size_t i = 1; i < object.layer_count(); ++i) { + // Orca: The support is one layer below the bridge. Check the template and model + // rotation together, including normalization when the resulting angle is negative. + double expected = std::fmod(cycle[(i - 1) % cycle.size()] + 90. + rotation, 180.); + if (expected < 0.) expected += 180.; + for (const LayerRegion *region : object.get_layer(i)->regions()) + for (const Surface *surface : region->fill_surfaces.filter_by_type(stInternalBridge)) { + CAPTURE(pattern, rotation, i); + CHECK_THAT(Geometry::rad2deg(surface->bridge_angle), Catch::Matchers::WithinAbs(expected, 0.001)); + ++bridges; + } + } + REQUIRE(bridges > 0); +} + +TEST_CASE("Turning infill does not replace the anchors of another region", "[PrintObject][InternalBridge][Regression]") +{ + // Orca: Keep the right-hand region fixed while changing the left-hand pattern in the + // same object. Its bridge areas must be independent of a previous candidate's anchors. + const int multiline = GENERATE(1, 2, 3); + CAPTURE(multiline); + auto right_bridges = [multiline](const std::string &left_pattern) { + auto config = internal_bridge_config(left_pattern, multiline); + Print print; + Model model; + init_print({internal_bridge_step()}, print, model, config, nullptr, false); + TriangleMesh right = internal_bridge_step(); + right.translate(50, 0, 0); + ModelVolume *volume = model.objects.front()->add_volume(std::move(right)); + volume->config.set_key_value("sparse_infill_pattern", new ConfigOptionEnum(ipRectilinear)); + volume->config.set_key_value("infill_direction", new ConfigOptionFloat(17.)); + print.apply(model, config); + print.process(); + std::map result; + const PrintObject &object = *print.objects().front(); + for (size_t i = 0; i < object.layer_count(); ++i) + for (const LayerRegion *region : object.get_layer(i)->regions()) + if (region->region().config().infill_direction == 17.) + polygons_append(result[i], to_polygons(region->fill_surfaces.filter_by_type(stInternalBridge))); + return result; + }; + const auto baseline = right_bridges("rectilinear"); + const auto actual = right_bridges(GENERATE("hilbertcurve", "octagramspiral")); + REQUIRE(actual.size() == baseline.size()); + double total_area = 0.; + for (const auto &[layer, expected] : baseline) { + CAPTURE(layer); + const auto &polys = actual.at(layer); + CHECK(area(diff(expected, polys)) < scaled(1.) * scaled(1.) * 1e-6); + CHECK(area(diff(polys, expected)) < scaled(1.) * scaled(1.) * 1e-6); + total_area += area(expected); + } + REQUIRE(total_area > 0.); +} + +TEST_CASE("Rounded internal bridges end on printed support", "[PrintObject][InternalBridge][Regression]") +{ + const std::string pattern = GENERATE("hilbertcurve", "octagramspiral"); + const bool separated = GENERATE(false, true); + CAPTURE(pattern, separated); + auto config = internal_bridge_config(pattern, 1); + config.set_deserialize_strict({{"infill_wall_overlap", "0%"}, {"separated_infills", separated}}); + TriangleMesh mesh = internal_bridge_step(); + if (separated) { + TriangleMesh second = internal_bridge_step(); + second.translate(50, 0, 0); + mesh.merge(second); + } + Print print; + Model model; + init_print({mesh}, print, model, config, nullptr, false); + print.process(); + + // Orca: Check final extrusion endpoints after polygon cleanup and fill generation. + // A correct bridge angle and correct sparse anchors alone do not guarantee contact. + const PrintObject &object = *print.objects().front(); + size_t checked = 0; + for (size_t i = 1; i < object.layer_count(); ++i) { + Polygons support; + Polylines walls; + for (const LayerRegion *region : object.get_layer(i - 1)->regions()) { + region->perimeters.polygons_covered_by_width(support, 0.f); + region->fills.polygons_covered_by_width(support, 0.f); + region->perimeters.collect_polylines(walls); + } + REQUIRE_FALSE(support.empty()); + const AABBTreeLines::LinesDistancer support_tree(to_lines(union_(support))); + const AABBTreeLines::LinesDistancer wall_tree(to_lines(walls)); + for (const LayerRegion *region : object.get_layer(i)->regions()) + for (const ExtrusionEntity *entity : region->fills.flatten().entities) { + if (entity->role() != erInternalBridgeInfill) + continue; + const auto *path = dynamic_cast(entity); + REQUIRE(path != nullptr); + for (const Line &line : path->polyline.to_polyline().lines()) { + // Orca: Sample span ends, excluding short connectors and wall overlap. + if (line.length() < scale_(std::max(0.7, 3. * path->width))) + continue; + for (const Point &point : {line.a, line.b}) { + if (wall_tree.distance_from_lines(point) <= scale_(0.5)) + continue; + CAPTURE(i, point.x(), point.y()); + const double gap = unscale(support_tree.distance_from_lines(point)) - 0.5 * path->width; + CHECK(gap <= 0.1); + ++checked; + } + } + } + } + REQUIRE(checked > 0); +} + +TEST_CASE("Enabling separated infill recomputes body origins", "[PrintObject][InternalBridge][Regression]") +{ + const std::string pattern = GENERATE("hilbertcurve", "octagramspiral", "archimedeanchords"); + CAPTURE(pattern); + auto footprint = [&](bool reslice) { + auto config = internal_bridge_config(pattern, 2); + config.set_deserialize_strict({{"separated_infills", !reslice}}); + TriangleMesh mesh = internal_bridge_step(); + TriangleMesh second = internal_bridge_step(); + second.translate(50, 0, 0); + mesh.merge(second); + Print print; + Model model; + init_print({mesh}, print, model, config, nullptr, false); + print.process(); + if (reslice) { + // Orca: Enabling centering after a completed slice must rebuild the body + // origins now shared by bridge preparation and printed infill. + config.set_deserialize_strict({{"separated_infills", true}}); + print.apply(model, config); + print.process(); + } + Polygons result; + for (const LayerRegion *region : print.objects().front()->get_layer(4)->regions()) + region->fills.polygons_covered_by_width(result, 0.f); + return union_(result); + }; + const Polygons fresh = footprint(false); + const Polygons resliced = footprint(true); + REQUIRE_FALSE(fresh.empty()); + CHECK(area(diff(fresh, resliced)) < scaled(1.) * scaled(1.) * 1e-6); + CHECK(area(diff(resliced, fresh)) < scaled(1.) * scaled(1.) * 1e-6); +} + +TEST_CASE("Surface centering survives changes to separated infill settings", "[PrintObject][SurfaceInfill][Regression]") +{ + const std::string pattern = GENERATE("archimedeanchords", "octagramspiral"); + const std::string initial_center = GENERATE("each_surface", "each_model", "each_assembly"); + const std::string final_center = GENERATE("each_surface", "each_model", "each_assembly"); + const bool separated = GENERATE(false, true); + const std::string top_order = GENERATE("default", "outward", "inward"); + const std::string bottom_order = top_order == "outward" ? "inward" : top_order == "inward" ? "outward" : "default"; + const std::string density = GENERATE("80%", "100%"); + const bool change_center = initial_center != final_center; + CAPTURE(pattern, initial_center, final_center, separated, top_order, bottom_order, density); + + auto config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({{"top_surface_pattern", pattern}, + {"bottom_surface_pattern", pattern}, + {"top_surface_fill_order", top_order}, + {"bottom_surface_fill_order", bottom_order}, + {"top_surface_density", density}, + {"bottom_surface_density", density}, + {"center_of_surface_pattern", initial_center}, + {"separated_infills", change_center ? separated : !separated}, + {"sparse_infill_pattern", "rectilinear"}, + {"sparse_infill_density", "15%"}, + {"top_shell_layers", 2}, + {"bottom_shell_layers", 2}, + {"top_shell_thickness", 0}, + {"bottom_shell_thickness", 0}, + {"layer_height", 0.2}, + {"initial_layer_print_height", 0.2}}); + + // Orca: Two disconnected bodies exercise per-body centering. The offset tower also + // makes each-surface and each-model centering differ on the top surfaces. + TriangleMesh mesh = make_cube(30, 24, 2); + TriangleMesh tower = make_cube(12, 10, 1); + tower.translate(4, 3, 2); + mesh.merge(tower); + TriangleMesh second = mesh; + second.translate(50, 0, 0); + mesh.merge(second); + + // Orca: Equal footprints can hide reordered or reversed paths. Retain their point + // sequences and ordering protection to cover the directional surface behavior too. + struct SurfaceFillSnapshot { + std::map> paths; + bool protected_order = true; + }; + auto surface_fills = [](const Print &print) { + std::map, SurfaceFillSnapshot> result; + const PrintObject &object = *print.objects().front(); + for (size_t i = 0; i < object.layer_count(); ++i) { + auto collect = [&](const auto &self, const ExtrusionEntity &entity, bool no_sort) -> void { + if (const auto *collection = dynamic_cast(&entity)) { + for (const ExtrusionEntity *child : collection->entities) + self(self, *child, no_sort || collection->no_sort); + } else if (entity.role() == erTopSolidInfill || entity.role() == erBottomSurface) { + const auto *path = dynamic_cast(&entity); + REQUIRE(path != nullptr); + auto &snapshot = result[{i, entity.role()}]; + // Orca: The centered test model has one body on either side of X=0. + // Their traversal order may vary; preserve path order within each body. + Points points = path->polyline.to_polyline().points; + REQUIRE_FALSE(points.empty()); + snapshot.paths[points.front().x() > 0].push_back(std::move(points)); + snapshot.protected_order &= no_sort && !path->can_reverse(); + } + }; + for (const LayerRegion *region : object.get_layer(i)->regions()) + collect(collect, region->fills, false); + } + return result; + }; + + Print print; + Model model; + init_print({mesh}, print, model, config, nullptr, false); + print.process(); + const auto initial = surface_fills(print); + config.set_deserialize_strict({{"center_of_surface_pattern", final_center}, {"separated_infills", separated}}); + print.apply(model, config); + // Orca: Preparation owns the body origins, and its invalidation must also force + // regeneration of top/bottom extrusion paths, even when sparse infill is unchanged. + CHECK_FALSE(print.objects().front()->is_step_done(posPrepareInfill)); + CHECK_FALSE(print.objects().front()->is_step_done(posInfill)); + print.process(); + const auto resliced = surface_fills(print); + + Print fresh_print; + Model fresh_model; + init_print({mesh}, fresh_print, fresh_model, config, nullptr, false); + fresh_print.process(); + const auto fresh = surface_fills(fresh_print); + REQUIRE_FALSE(fresh.empty()); + REQUIRE(resliced.size() == fresh.size()); + std::set roles; + bool changed_paths = false; + for (const auto &entry : fresh) { + CAPTURE(entry.first.first, entry.first.second); + REQUIRE_FALSE(entry.second.paths.empty()); + roles.insert(entry.first.second); + REQUIRE(resliced.count(entry.first) == 1); + REQUIRE(initial.count(entry.first) == 1); + const auto &actual = resliced.at(entry.first); + const auto &expected = entry.second; + const auto &before = initial.at(entry.first); + CHECK((actual.paths == expected.paths)); + if (!change_center) + CHECK((actual.paths == before.paths)); + if (top_order != "default") { + CHECK(expected.protected_order); + CHECK(actual.protected_order); + CHECK(before.protected_order); + } + changed_paths |= expected.paths != before.paths; + } + CHECK(roles.count(erTopSolidInfill) == 1); + CHECK(roles.count(erBottomSurface) == 1); + // Orca: Guard against a vacuous comparison: changing surface centering must change + // the printed pattern, while toggling separated sparse infill must leave it alone. + CHECK(changed_paths == change_center); +} + +TEST_CASE("Separated infill keeps fragmented and nested bodies independent", "[PrintObject][SurfaceInfill][Regression]") +{ + constexpr size_t grid_size = 8; + TriangleMesh mesh; + auto add_box = [&](double x, double y, double width, double depth) { + TriangleMesh box = make_cube(width, depth, 0.6); + box.translate(x, y, 0); + mesh.merge(box); + }; + // Orca: Many small islands exercise spatial pruning and the tree's original + // island indices. A pillar inside a frame also overlaps its bounding box, + // but must remain a separate body because it lies entirely inside the hole. + for (size_t x = 0; x < grid_size; ++ x) + for (size_t y = 0; y < grid_size; ++ y) + add_box(6 * x, 6 * y, 3, 3); + add_box(54, 0, 20, 4); + add_box(54, 16, 20, 4); + add_box(54, 0, 4, 20); + add_box(70, 0, 4, 20); + add_box(62, 8, 4, 4); + + auto config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({{"separated_infills", true}, + {"center_of_surface_pattern", "each_surface"}, + {"layer_height", 0.2}, + {"initial_layer_print_height", 0.2}, + {"elefant_foot_compensation", 0}, + {"wall_loops", 1}}); + Print print; + Model model; + init_print({mesh}, print, model, config, nullptr, false); + // Orca: Prepare body bounds through the public pipeline, then inspect the object read-only. + print.process(); + const PrintObject &object = *print.objects().front(); + REQUIRE(object.layer_count() > 1); + for (const Layer *layer : object.layers()) { + REQUIRE(layer->lslices.size() == grid_size * grid_size + 2); + REQUIRE(layer->lslices_separated_component_bboxes.size() == layer->lslices.size()); + size_t holes = 0; + for (size_t i = 0; i < layer->lslices.size(); ++ i) { + const BoundingBox &body = layer->lslices_separated_component_bboxes[i]; + const BoundingBox &island = layer->lslices_bboxes[i]; + CHECK(body.min == island.min); + CHECK(body.max == island.max); + holes += layer->lslices[i].holes.size(); + } + CHECK(holes == 1); + } +} + +TEST_CASE("Body centering survives islands merging and splitting between layers", "[PrintObject][SurfaceInfill][Regression]") +{ + const bool separated = GENERATE(false, true); + CAPTURE(separated); + // Orca: Four posts join through horizontal then vertical rails, creating a + // cycle of overlaps before splitting into four islands again. This exercises + // redundant connections and indexing either adjacent layer. A fifth post + // stays separate at every height. + TriangleMesh mesh; + for (int x : {0, 8}) + for (int y : {0, 8}) { + TriangleMesh post = make_cube(4, 4, 1); + post.translate(x, y, 0); + mesh.merge(post); + } + for (int y : {0, 8}) { + TriangleMesh rail = make_cube(12, 4, 0.2); + rail.translate(0, y, 0.2); + mesh.merge(rail); + } + for (int x : {0, 8}) { + TriangleMesh rail = make_cube(4, 12, 0.2); + rail.translate(x, 0, 0.4); + mesh.merge(rail); + } + TriangleMesh isolated = make_cube(4, 4, 1); + isolated.translate(20, 0, 0); + mesh.merge(isolated); + + auto config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({{"separated_infills", separated}, + {"center_of_surface_pattern", separated ? "each_surface" : "each_model"}, + {"layer_height", 0.2}, + {"initial_layer_print_height", 0.2}, + {"elefant_foot_compensation", 0}, + {"wall_loops", 1}}); + Print print; + Model model; + init_print({mesh}, print, model, config, nullptr, false); + // Orca: Prepare body bounds through the public pipeline, then inspect the object read-only. + print.process(); + const PrintObject &object = *print.objects().front(); + REQUIRE(object.layer_count() == 5); + REQUIRE(object.get_layer(0)->lslices.size() == 5); + REQUIRE(object.get_layer(1)->lslices.size() == 3); + REQUIRE(object.get_layer(2)->lslices.size() == 3); + REQUIRE(object.get_layer(4)->lslices.size() == 5); + + BoundingBox isolated_bbox = object.get_layer(0)->lslices_bboxes.front(); + for (const BoundingBox &bbox : object.get_layer(0)->lslices_bboxes) + if (bbox.min.x() > isolated_bbox.min.x()) + isolated_bbox = bbox; + BoundingBox connected_bbox; + for (const Layer *layer : object.layers()) + for (const BoundingBox &bbox : layer->lslices_bboxes) + if (bbox.min.x() < isolated_bbox.min.x()) + connected_bbox.merge(bbox); + for (const Layer *layer : object.layers()) { + REQUIRE(layer->lslices_separated_component_bboxes.size() == layer->lslices.size()); + for (size_t i = 0; i < layer->lslices.size(); ++ i) { + const BoundingBox &expected = layer->lslices_bboxes[i].min.x() < isolated_bbox.min.x() ? connected_bbox : isolated_bbox; + const BoundingBox &actual = layer->lslices_separated_component_bboxes[i]; + CHECK(actual.min == expected.min); + CHECK(actual.max == expected.max); + } + } +} diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index 4c0a09cf3f..5c38859a12 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -1,6 +1,5 @@ #include "libslic3r/Model.hpp" -#include "libslic3r/TriangleSelector.hpp" #include "libslic3r/Format/3mf.hpp" #include "libslic3r/Format/bbs_3mf.hpp" #include "libslic3r/Format/STL.hpp" @@ -9,9 +8,12 @@ #include "libslic3r/Preset.hpp" #include "libslic3r/MultiNozzleUtils.hpp" #include "libslic3r/ProjectTask.hpp" +#include "libslic3r/PublishSettings.hpp" #include "test_utils.hpp" +#include + #include #include @@ -499,7 +501,6 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") { } } - // A mixed-color filament occupies an ordinary filament slot, and painting with it stores an // ordinary extruder state: a project saved by BambuStudio encodes filament 5 of a 5-slot setup // as paint state 5, with the mix described by the parallel filament_mixed_* project arrays. @@ -590,3 +591,639 @@ SCENARIO("Mixed-color filament setup and painting round-trip through a .3mf", "[ } } } +// Locks the serialization contract of the "Publish" metadata: the orca_published flag and the +// orca_published_keys JSON array in model.model_info->metadata_items must survive a store_bbs_3mf -> +// load_bbs_3mf round-trip unchanged. (The full preset-preservation behavior is exercised +// headlessly in test_preset_bundle_loading.cpp.) +SCENARIO("Published 3MF round-trips the published flag and published_keys metadata", "[3mf]") { + GIVEN("a model carrying published metadata") { + Model model; + std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src_file.c_str(), &model)); + model.add_default_instances(); + + model.model_info = std::make_shared(); + model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = "1"; + model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] = R"(["layer_height","wall_thickness"])"; + + // store_bbs_3mf stages project_settings.config through the model's backup path; point + // it at a writable temp dir (the default lives under a read-only root in CI). + ScopedTemporaryDir backup_dir("orca_pub"); + model.set_backup_path(backup_dir.string()); + + WHEN("stored to and reloaded from a .3mf") { + ScopedTemporaryFile temp(".3mf"); + const std::string test_file = temp.string(); + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + StoreParams store_params; + store_params.path = test_file.c_str(); + store_params.model = &model; + store_params.config = &config; + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + REQUIRE(store_bbs_3mf(store_params)); + + Model dst_model; + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector project_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig); + THEN("the published metadata round-trips unchanged") { + REQUIRE(loaded); + REQUIRE(dst_model.model_info != nullptr); + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_TAG] == "1"); + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] == R"(["layer_height","wall_thickness"])"); + + // The orca_published_keys value is a JSON array of setting keys; it must parse back to + // the same keys that were selected. + nlohmann::json keys = nlohmann::json::parse(dst_model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG]); + REQUIRE(keys.is_array()); + REQUIRE(keys.size() == 2); + REQUIRE(keys[0] == "layer_height"); + REQUIRE(keys[1] == "wall_thickness"); + } + release_PlateData_list(dst_plates); + } + } +} + +// A normal 3MF (no Publish metadata) must load identically: the loader must not fabricate a +// "orca_published" flag or orca_published_keys for files that never carried them. +SCENARIO("Legacy 3MF without published metadata loads unchanged", "[3mf]") { + GIVEN("a model without any published metadata") { + Model model; + std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src_file.c_str(), &model)); + model.add_default_instances(); + + ScopedTemporaryDir backup_dir("orca_legacy"); + model.set_backup_path(backup_dir.string()); + + WHEN("stored to and reloaded from a .3mf") { + ScopedTemporaryFile temp(".3mf"); + const std::string test_file = temp.string(); + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + StoreParams store_params; + store_params.path = test_file.c_str(); + store_params.model = &model; + store_params.config = &config; + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + REQUIRE(store_bbs_3mf(store_params)); + + Model dst_model; + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector project_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig); + THEN("no published key is fabricated") { + REQUIRE(loaded); + REQUIRE(dst_model.model_info != nullptr); + REQUIRE(dst_model.model_info->metadata_items.count(ORCA_PUBLISHED_TAG) == 0); + REQUIRE(dst_model.model_info->metadata_items.count(ORCA_PUBLISHED_KEYS_TAG) == 0); + } + release_PlateData_list(dst_plates); + } + } +} + +// Locks the serialization contract of the orca_published_material_keys metadata: the per-entry JSON +// must survive a store_bbs_3mf -> load_bbs_3mf round-trip verbatim, exactly like orca_published_keys. +SCENARIO("Published 3MF round-trips the published_material_keys metadata", "[3mf]") { + GIVEN("a model carrying published material keys metadata") { + Model model; + std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src_file.c_str(), &model)); + model.add_default_instances(); + + const std::string material_keys_json = + R"([{"material":{"filament_type":"PLA","filament_vendor":"Generic","filament_id":"GFL99"},"slot":0,"keys":["filament_retraction_length","filament_z_hop"]}])"; + + model.model_info = std::make_shared(); + model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] = material_keys_json; + + ScopedTemporaryDir backup_dir("orca_pub_mat"); + model.set_backup_path(backup_dir.string()); + + WHEN("stored to and reloaded from a .3mf") { + ScopedTemporaryFile temp(".3mf"); + const std::string test_file = temp.string(); + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + StoreParams store_params; + store_params.path = test_file.c_str(); + store_params.model = &model; + store_params.config = &config; + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + REQUIRE(store_bbs_3mf(store_params)); + + Model dst_model; + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector project_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig); + THEN("the published material keys metadata round-trips unchanged") { + REQUIRE(loaded); + REQUIRE(dst_model.model_info != nullptr); + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json); + + // The value must parse back to one material entry carrying the nested identity + // object, the author slot ordinal and the key list. + nlohmann::json entries = nlohmann::json::parse(material_keys_json); + REQUIRE(entries.is_array()); + REQUIRE(entries.size() == 1); + REQUIRE(entries[0]["material"]["filament_type"] == "PLA"); + REQUIRE(entries[0]["material"]["filament_vendor"] == "Generic"); + REQUIRE(entries[0]["material"]["filament_id"] == "GFL99"); + REQUIRE(entries[0]["slot"] == 0); + REQUIRE(entries[0]["keys"].is_array()); + REQUIRE(entries[0]["keys"].size() == 2); + REQUIRE(entries[0]["keys"][0] == "filament_retraction_length"); + } + release_PlateData_list(dst_plates); + } + } +} + +SCENARIO("Minimal published 3MF omits project config, preset dumps and slicer tags", "[3mf]") { + GIVEN("a multi-instance model carrying published metadata and a published_config payload") { + Model model; + std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src_file.c_str(), &model)); + model.add_default_instances(); + // A second instance: tag-less third-party files get their multi-instance objects split, + // published files must not (the loader recognizes them by their metadata). + model.objects.front()->add_instance(); + + DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config(); + full_cfg.set_key_value("layer_height", new ConfigOptionFloat(0.24)); + full_cfg.set_key_value("retraction_length", new ConfigOptionFloats({ 1.2 })); + + const std::vector published_keys = { "layer_height", "retraction_length" }; + const std::vector material_keys = { + { "PLA", "Generic", "GFL99", "", "Generic PLA", 0, { "filament_retraction_length" } } + }; + + // The payload builder keeps the published and identity keys and drops everything else. + DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, published_keys, material_keys); + REQUIRE(filtered_cfg.option("layer_height") != nullptr); + REQUIRE(filtered_cfg.option("retraction_length") != nullptr); + REQUIRE(filtered_cfg.option("filament_colour") != nullptr); + REQUIRE(filtered_cfg.option("filament_type") != nullptr); + REQUIRE(filtered_cfg.option("wipe_tower_x") != nullptr); + REQUIRE(filtered_cfg.option("sparse_infill_density") == nullptr); + REQUIRE(filtered_cfg.option("machine_start_gcode") == nullptr); + + // Serialize the payload exactly like export_published_3mf does. + std::string payload; + for (const std::string &key : filtered_cfg.keys()) + payload += key + " = " + filtered_cfg.opt_serialize(key) + "\n"; + + model.model_info = std::make_shared(); + model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = "1"; + model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] = R"(["layer_height","retraction_length"])"; + model.model_info->metadata_items[ORCA_PUBLISHED_CONFIG_TAG] = payload; + + ScopedTemporaryDir backup_dir("orca_min_pub"); + model.set_backup_path(backup_dir.string()); + + WHEN("stored using SaveStrategy::MinimalPublished and reloaded") { + ScopedTemporaryFile temp(".3mf"); + const std::string test_file = temp.string(); + + // Create a fake project preset to verify MinimalPublished omits it. + Preset preset(Preset::TYPE_PRINT, "TestPrintPreset"); + preset.config = full_cfg; + std::vector project_presets = { &preset }; + + StoreParams store_params; + store_params.path = test_file.c_str(); + store_params.model = &model; + store_params.config = &filtered_cfg; + store_params.project_presets = project_presets; + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence | SaveStrategy::MinimalPublished; + REQUIRE(store_bbs_3mf(store_params)); + + Model dst_model; + ScopedTemporaryDir loaded_backup_dir("orca_min_pub_loaded"); + dst_model.set_backup_path(loaded_backup_dir.string()); + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector loaded_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &loaded_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig); + THEN("the 3MF loads without project config or embedded presets") { + REQUIRE(loaded); + REQUIRE(dst_config.empty()); + REQUIRE(loaded_presets.empty()); + } + THEN("the file carries no slicer tags and classifies as a generic 3MF") { + REQUIRE_FALSE(is_bbl_3mf); + REQUIRE_FALSE(is_orca_3mf); + // No Application / OrcaSlicer tag: old receivers import the geometry silently + // instead of showing a baked-in, wrong "old version" popup. + REQUIRE_FALSE(file_version.valid()); + } + THEN("the geometry keeps BBS-grade handling: instances are not split") { + REQUIRE(dst_model.objects.size() == 1); + REQUIRE(dst_model.objects.front()->instances.size() == 2); + } + THEN("the published metadata and payload round-trip unchanged") { + REQUIRE(dst_model.model_info != nullptr); + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_TAG] == "1"); + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] == R"(["layer_height","retraction_length"])"); + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_CONFIG_TAG] == payload); + } + THEN("the payload parses back to the published values") { + DynamicPrintConfig parsed_payload; + parsed_payload.load_from_ini_string(dst_model.model_info->metadata_items[ORCA_PUBLISHED_CONFIG_TAG], ForwardCompatibilitySubstitutionRule::Enable); + REQUIRE(parsed_payload.option("layer_height") != nullptr); + REQUIRE_THAT(parsed_payload.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.24, 1e-6)); + REQUIRE(parsed_payload.option("retraction_length") != nullptr); + REQUIRE_THAT(parsed_payload.opt("retraction_length")->get_at(0), Catch::Matchers::WithinAbs(1.2, 1e-6)); + } + release_PlateData_list(dst_plates); + } + } +} + +// A minimal published 3MF must not leak the slicer tags of the source project. The exporter seeds +// metadata_item_map from the input file's metadata_items, so re-publishing a project opened from a +// regular Orca/BBS 3MF (the typical remix flow) must strip the Application / OrcaSlicer tags it +// came with, otherwise old receivers route onto the baked-in "old version" popup. +SCENARIO("MinimalPublished strips slicer tags carried by the source project", "[3mf]") { + GIVEN("a model loaded from a regular Orca/BBS 3MF whose metadata carries the slicer tags") { + Model model; + std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src_file.c_str(), &model)); + model.add_default_instances(); + + model.model_info = std::make_shared(); + model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = "1"; + model.model_info->metadata_items["Application"] = "BambuStudio-2.0.0"; + model.model_info->metadata_items["OrcaSlicer"] = "2.1.0"; + + ScopedTemporaryDir backup_dir("orca_strip_tags"); + model.set_backup_path(backup_dir.string()); + + WHEN("stored using SaveStrategy::MinimalPublished and reloaded") { + ScopedTemporaryFile temp(".3mf"); + const std::string test_file = temp.string(); + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + StoreParams store_params; + store_params.path = test_file.c_str(); + store_params.model = &model; + store_params.config = &config; + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence | SaveStrategy::MinimalPublished; + REQUIRE(store_bbs_3mf(store_params)); + + Model dst_model; + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector loaded_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &loaded_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig); + THEN("the source slicer tags are stripped, not carried through") { + REQUIRE(loaded); + REQUIRE(dst_model.model_info != nullptr); + REQUIRE(dst_model.model_info->metadata_items.count("Application") == 0); + REQUIRE(dst_model.model_info->metadata_items.count("OrcaSlicer") == 0); + // The published marker itself must survive. + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_TAG] == "1"); + } + THEN("the file classifies as a generic 3MF without a version popup") { + REQUIRE_FALSE(is_bbl_3mf); + REQUIRE_FALSE(is_orca_3mf); + REQUIRE_FALSE(file_version.valid()); + } + release_PlateData_list(dst_plates); + } + } +} + +// An entry masks the non-published slots to their defaults so publishing slot 1 never leaks slot +// 0's value into the file. Both a full entry (the whole-slot key list) and a partial entry (a +// per-slot key) go through the same masking path in filter_published_config (keys and full_keys +// are filtered identically), so the two forms are exercised together. +SCENARIO("Published entries mask the other slots to their defaults", "[3mf]") { + const bool full = GENERATE(true, false); + GIVEN("a full print configuration with two filament slots") { + DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config(); + full_cfg.opt("filament_diameter")->values = { 1.75, 1.75 }; + full_cfg.opt("filament_colour")->values = { "#111111", "#222222" }; + // filament_flow_ratio carries a non-empty option default (1.0) of the same type, so the + // mask can restore it on the non-published slot. + full_cfg.opt("filament_flow_ratio", true)->values = { 1.02, 0.98 }; + + WHEN("filtering with a published entry for slot 1") { + PublishedMaterialEntry entry; + entry.slot = 1; + if (full) { + entry.full = true; + entry.full_keys = { "filament_flow_ratio" }; + } else { + entry.keys = { "filament_flow_ratio" }; + } + DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { entry }); + + THEN("the selected key is present with the author's slot value") { + REQUIRE(filtered_cfg.option("filament_flow_ratio") != nullptr); + REQUIRE_THAT(filtered_cfg.opt("filament_flow_ratio")->values[1], Catch::Matchers::WithinAbs(0.98, 1e-6)); + } + THEN("the non-published slot is masked to its default") { + REQUIRE_THAT(filtered_cfg.opt("filament_flow_ratio")->values[0], Catch::Matchers::WithinAbs(1.0, 1e-6)); + } + THEN("the identity keys stay present") { + REQUIRE(filtered_cfg.option("filament_colour") != nullptr); + } + } + } +} + +// A key needing slot masking that cannot be masked (no registered option default of the same +// type) is dropped from the payload entirely instead of shipping the author's whole vector. +SCENARIO("Unmaskable keys are dropped from the published payload instead of leaking", "[3mf]") { + GIVEN("a config carrying a synthetic def-less vector key and a maskable one") { + DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config(); + full_cfg.opt("filament_diameter")->values = { 1.75, 1.75 }; + full_cfg.opt("filament_colour")->values = { "#111111", "#222222" }; + // Not a PrintConfig key: print_config_def has no default to mask with. + full_cfg.set_key_value("orca_synthetic_setting", new ConfigOptionFloats({ 9.9, 8.8 })); + full_cfg.opt("filament_flow_ratio", true)->values = { 1.02, 0.98 }; + + PublishedMaterialEntry partial_entry; + partial_entry.slot = 1; + partial_entry.keys = { "orca_synthetic_setting", "filament_flow_ratio" }; + + WHEN("filtering with a partial entry for slot 1") { + DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { partial_entry }); + + THEN("the unmaskable synthetic key is not published") { + REQUIRE(filtered_cfg.option("orca_synthetic_setting") == nullptr); + } + THEN("the maskable key is present, author slot kept, other slot masked") { + REQUIRE(filtered_cfg.opt("filament_flow_ratio") != nullptr); + REQUIRE_THAT(filtered_cfg.opt("filament_flow_ratio")->values[1], Catch::Matchers::WithinAbs(0.98, 1e-6)); + REQUIRE_THAT(filtered_cfg.opt("filament_flow_ratio")->values[0], Catch::Matchers::WithinAbs(1.0, 1e-6)); + } + THEN("the identity keys stay present") { + REQUIRE(filtered_cfg.option("filament_colour") != nullptr); + } + } + } +} + +// A per-extruder printer key carrying a "#N" variant (e.g. retraction_length#1) must not serialize +// every extruder's value: the base is masked to the author's extruder and the other slots are +// restored to their option default, matching the material-side slot-masking invariant. A bare +// printer base key (no variant) keeps whole-vector serialization. +SCENARIO("Published per-extruder printer keys mask the other extruders to their defaults", "[3mf]") { + GIVEN("a full print configuration with three extruders carrying per-extruder retraction values") { + DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config(); + // Non-default values on the un-selected slots, so a leak is distinguishable from the mask + // restoring the option default (retraction_length defaults to {0.8}). + full_cfg.opt("retraction_length")->values = { 3.0, 1.2, 4.0 }; + + WHEN("filtering with only extruder 1's retraction_length checked") { + DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, { "retraction_length#1" }, {}); + + THEN("the author's extruder value survives") { + REQUIRE_THAT(filtered_cfg.opt("retraction_length")->values[1], Catch::Matchers::WithinAbs(1.2, 1e-6)); + } + THEN("the other extruders are masked to their default") { + REQUIRE_THAT(filtered_cfg.opt("retraction_length")->values[0], Catch::Matchers::WithinAbs(0.8, 1e-6)); + REQUIRE_THAT(filtered_cfg.opt("retraction_length")->values[2], Catch::Matchers::WithinAbs(0.8, 1e-6)); + } + } + WHEN("filtering the bare base key without a '#N' variant") { + DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, { "retraction_length" }, {}); + + THEN("the whole vector is serialized unmasked") { + REQUIRE_THAT(filtered_cfg.opt("retraction_length")->values[0], Catch::Matchers::WithinAbs(3.0, 1e-6)); + REQUIRE_THAT(filtered_cfg.opt("retraction_length")->values[1], Catch::Matchers::WithinAbs(1.2, 1e-6)); + REQUIRE_THAT(filtered_cfg.opt("retraction_length")->values[2], Catch::Matchers::WithinAbs(4.0, 1e-6)); + } + } + } +} + +// The extended per-entry fields (full dump list, published type and colour) travel inside the +// published_material_keys metadata and round-trip unchanged. +SCENARIO("Published 3MF round-trips the extended material metadata", "[3mf]") { + GIVEN("a model carrying extended published material keys metadata") { + Model model; + std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src_file.c_str(), &model)); + model.add_default_instances(); + + const std::string material_keys_json = + R"([{"material":{"filament_type":"PLA","filament_vendor":"Generic","filament_id":"GFL99","setting_id":"RFs9eCKYOMUSmvZf","name":"Generic PLA Matte @System"},"slot":1,"keys":[],"full":true,"full_keys":["filament_retraction_length","filament_colour"],"publish_type":true,"type":"PLA","publish_color":false,"color":""}])"; + + model.model_info = std::make_shared(); + model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] = material_keys_json; + + ScopedTemporaryDir backup_dir("orca_pub_mat2"); + model.set_backup_path(backup_dir.string()); + + WHEN("stored to and reloaded from a .3mf") { + ScopedTemporaryFile temp(".3mf"); + const std::string test_file = temp.string(); + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + StoreParams store_params; + store_params.path = test_file.c_str(); + store_params.model = &model; + store_params.config = &config; + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + REQUIRE(store_bbs_3mf(store_params)); + + Model dst_model; + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector project_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig); + THEN("the extended material metadata round-trips unchanged") { + REQUIRE(loaded); + REQUIRE(dst_model.model_info != nullptr); + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json); + + // The value must parse back with every extended field intact. + nlohmann::json entries = nlohmann::json::parse(material_keys_json); + REQUIRE(entries.is_array()); + REQUIRE(entries.size() == 1); + REQUIRE(entries[0]["full"].get() == true); + REQUIRE(entries[0]["full_keys"].is_array()); + REQUIRE(entries[0]["full_keys"].size() == 2); + REQUIRE(entries[0]["publish_type"].get() == true); + REQUIRE(entries[0]["type"] == "PLA"); + REQUIRE(entries[0]["publish_color"].get() == false); + } + release_PlateData_list(dst_plates); + } + } +} + +// A published mixed filament serializes its whole definition (components, ratios, gradient) +// masked to the author's slot: the mix slot's values survive, the non-published slots reset to +// their defaults, so a partial publish never leaks another slot's mix data. +SCENARIO("Published mixed-filament keys are masked to the author's slot", "[3mf]") { + GIVEN("a full print configuration with three slots, one of them mixed") { + DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config(); + full_cfg.opt("filament_diameter")->values = { 1.75, 1.75, 1.75 }; + full_cfg.opt("filament_colour")->values = { "#111111", "#222222", "#333333" }; + full_cfg.opt("filament_is_mixed")->values = { 0, 0, 1 }; + full_cfg.opt("filament_mixed_components")->values = { "", "", "1,2" }; + full_cfg.opt("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" }; + full_cfg.opt("filament_mixed_gradient")->values = { 0, 0, 1 }; + full_cfg.opt("filament_mixed_gradient_range")->values = { "", "", "0.9,0.1" }; + full_cfg.opt("filament_mixed_gradient_curve")->values = { "", "", "0,0.1|1,0.9" }; + full_cfg.opt("filament_mixed_gradient_per_part")->values = { 0, 0, 1 }; + + PublishedMaterialEntry mix_entry; + mix_entry.slot = 2; + mix_entry.keys = { + "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios", + "filament_mixed_gradient", "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" + }; + + WHEN("filtering with a mixed entry for slot 2") { + DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { mix_entry }); + + THEN("the author's mixed slot keeps its definition") { + REQUIRE(filtered_cfg.option("filament_is_mixed") != nullptr); + REQUIRE(filtered_cfg.opt("filament_is_mixed")->values == std::vector{ 0, 0, 1 }); + const auto& components = filtered_cfg.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 3); + CHECK(components[2] == "1,2"); + CHECK(filtered_cfg.opt("filament_mixed_sublayer_ratios")->values[2] == "0.6,0.4"); + CHECK(filtered_cfg.opt("filament_mixed_gradient_curve")->values[2] == "0,0.1|1,0.9"); + CHECK(filtered_cfg.opt("filament_mixed_gradient")->values[2]); + CHECK(filtered_cfg.opt("filament_mixed_gradient_per_part")->values[2]); + } + THEN("the non-published slots are masked to their defaults") { + CHECK(filtered_cfg.opt("filament_mixed_components")->values[0] == ""); + CHECK(filtered_cfg.opt("filament_mixed_components")->values[1] == ""); + CHECK(filtered_cfg.opt("filament_is_mixed")->values[0] == 0); + CHECK(filtered_cfg.opt("filament_is_mixed")->values[1] == 0); + } + THEN("the identity keys stay present") { + REQUIRE(filtered_cfg.option("filament_colour") != nullptr); + } + } + } +} + +// The published flag is gated on the exact string "1": any other serialized value means "not +// published", so a receiver never treats a file as published on a loose truthiness check. +TEST_CASE("is_published_3mf_flag accepts only the literal \"1\"", "[3mf]") { + CHECK(is_published_3mf_flag("1")); + CHECK_FALSE(is_published_3mf_flag("0")); + CHECK_FALSE(is_published_3mf_flag("false")); + CHECK_FALSE(is_published_3mf_flag("true")); + CHECK_FALSE(is_published_3mf_flag("")); + CHECK_FALSE(is_published_3mf_flag("YES")); +} + +// bbs_3mf_is_published is the lightweight metadata probe used to decide whether a file was +// produced by the publish feature (GUI "recently published" tracking). It must return true only +// for a file whose metadata carries the flag set to "1", and false for legacy files and for a +// file whose flag is present but not "1" (which loads as a normal, non-published 3MF). +SCENARIO("bbs_3mf_is_published detects only genuinely published 3MFs", "[3mf]") { + auto store_model = [](const std::string &path, const std::string &flag_value, const std::string &keys_value) { + Model model; + std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src_file.c_str(), &model)); + model.add_default_instances(); + model.model_info = std::make_shared(); + // An empty flag_value means "don't write the flag at all" (a legacy file). + if (!flag_value.empty()) + model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = flag_value; + model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] = keys_value; + ScopedTemporaryDir backup_dir("orca_is_pub"); + model.set_backup_path(backup_dir.string()); + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + StoreParams store_params; + store_params.path = path.c_str(); + store_params.model = &model; + store_params.config = &config; + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + REQUIRE(store_bbs_3mf(store_params)); + }; + + GIVEN("a minimal published 3MF whose flag is \"1\"") { + ScopedTemporaryFile temp(".3mf"); + store_model(temp.string(), "1", R"(["layer_height"])"); + WHEN("probed by bbs_3mf_is_published") { + THEN("it is recognized as published") { + CHECK(bbs_3mf_is_published(temp.string())); + } + } + } + GIVEN("a legacy 3MF without any published flag") { + ScopedTemporaryFile temp(".3mf"); + store_model(temp.string(), "", R"(["layer_height"])"); + WHEN("probed by bbs_3mf_is_published") { + THEN("it is not recognized as published") { + CHECK_FALSE(bbs_3mf_is_published(temp.string())); + } + } + } + GIVEN("a 3MF carrying the flag set to \"0\"") { + ScopedTemporaryFile temp(".3mf"); + store_model(temp.string(), "0", R"(["layer_height"])"); + WHEN("probed and loaded") { + THEN("it is not recognized as published") { + CHECK_FALSE(bbs_3mf_is_published(temp.string())); + } + THEN("it loads as a normal, non-published 3MF") { + Model dst_model; + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector project_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + REQUIRE(load_bbs_3mf(temp.string().c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig)); + REQUIRE(dst_model.model_info != nullptr); + // The key is present but not "1", so nothing treats the file as published; the + // stored keys still round-trip verbatim. + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_TAG] == "0"); + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] == R"(["layer_height"])"); + release_PlateData_list(dst_plates); + } + } + } +} + diff --git a/tests/libslic3r/test_marchingsquares.cpp b/tests/libslic3r/test_marchingsquares.cpp index 6844ecb6ac..9a11f49faa 100644 --- a/tests/libslic3r/test_marchingsquares.cpp +++ b/tests/libslic3r/test_marchingsquares.cpp @@ -1,4 +1,6 @@ +#ifndef NOMINMAX #define NOMINMAX +#endif #include #include "test_utils.hpp" diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index d01711000a..ecdede7053 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -6,17 +6,37 @@ #include "libslic3r/PresetBundle.hpp" #include "libslic3r/AppConfig.hpp" +#include "libslic3r/Model.hpp" +#include "libslic3r/TriangleMesh.hpp" #include "libslic3r/Utils.hpp" #include "libslic3r/miniz_extension.hpp" #include "test_utils.hpp" +#include +#include +#include + using namespace Slic3r; namespace { namespace fs = boost::filesystem; +// Whether a key is listed in a vector of keys (published_keys / skipped_keys). +bool contains_key(const std::vector &keys, const std::string &key) +{ + return std::find(keys.begin(), keys.end(), key) != keys.end(); +} + +void check_double_vector(const std::vector &actual, std::initializer_list expected) +{ + REQUIRE(actual.size() == expected.size()); + size_t index = 0; + for (double value : expected) + REQUIRE_THAT(actual[index++], Catch::Matchers::WithinAbs(value, 1e-6)); +} + void write_print_preset(const DynamicPrintConfig &default_config, const fs::path &file, const std::string &name, const std::string &inherits = {}) { DynamicPrintConfig config(default_config); @@ -55,6 +75,22 @@ void set_renamed_from(PresetCollection &coll, const std::string &preset_name, st it->renamed_from = std::move(old_names); } +// A single-slot PLA file config for the published-material load tests (mirrors what the GUI +// builds from a 3mf's project settings before load_config_model). +DynamicPrintConfig published_pla_file_config() +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75 }; + config.opt("filament_self_index")->values = { 1 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt("filament_type")->values = { "PLA" }; + config.opt("filament_vendor")->values = { "Generic" }; + config.opt("filament_ids")->values = { "GFL99" }; + config.option("filament_retraction_length", true)->values = { 0.9 }; + return config; +} + // A standalone print preset collection that exposes the protected rename-map builder, so a // renamed_from scenario can be set up without the full system-profile load pipeline. // (PresetCollection is non-copyable - it holds a mutex - so it is constructed directly with @@ -1002,7 +1038,6 @@ TEST_CASE("A printer specific filament supersedes the generic library filament w CHECK(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_c, nullptr))); } - namespace { // One system printer plus the filament presets a machine facing dialog has to choose between: @@ -1223,6 +1258,2807 @@ TEST_CASE("Sizing the filament list to a multi-tool nozzle count keeps mixed slo } } +// A "published" 3MF keeps the user's currently-selected presets and overlays only the +// author-selected process keys onto the edited preset (mirrors the GUI load path: normalize +// before load_config_model, then the overlay in load_config_file_config). +TEST_CASE("Published 3MF overlays only the author-selected process keys onto the edited preset", "[Preset][Bundle][Published]") +{ + // The file config the GUI builds from a .3mf's project settings. + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + // The loader derives the filament count from filament_colour and throws when it is + // empty; a 3mf always carries it. + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt_float("layer_height") = 0.28; // process scalar + config.opt("wiping_volumes_extruders")->values = { 140., 150. }; // matching-size vector + config.opt("post_process")->values = { "script-a", "script-b" }; // mismatched vector + config.opt("nozzle_temperature")->values = { 220 }; // filament key (not applied anywhere) + // Structural (denylisted) key: must be silently ignored. full_print_config() omits the + // *_settings_id keys, so create one explicitly. + config.opt_string("print_settings_id", true) = "file process"; + config.opt("flush_multiplier")->values = { 2., 2. }; // must NOT cross over + config.opt("wipe_tower_x")->values = { 100. }; // plate geometry, does cross over + config.opt("wipe_tower_rotation_angle")->value = 45.; // published-only plate geometry, crosses over + config.option("curr_bed_type")->setInt(BedType::btPC); // must NOT cross over + return config; + }; + + const std::vector published_keys = { + "layer_height", "wiping_volumes_extruders", "post_process", "nozzle_temperature", "print_settings_id" + }; + + PresetBundle bundle; + const std::string pre_load_name = bundle.prints.get_edited_preset().name; + const size_t pre_load_size = bundle.prints.size(); + + // The edited presets are the overlay targets: recognizable pre-load values. + bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1; + bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values = { 10., 20. }; + bundle.prints.get_edited_preset().config.opt("post_process")->values = { "existing-script" }; + bundle.prints.get_edited_preset().config.opt_string("print_settings_id") = "user process"; + // Capture the ctor-seeded project_config values so the assertions below check the load + // leaves them untouched rather than hardcoding the defaults. + const std::vector seed_filament_colour = bundle.project_config.opt("filament_colour")->values; + const std::vector seed_flush_multiplier = bundle.project_config.opt("flush_multiplier")->values; + const int seed_bed_type = bundle.project_config.option("curr_bed_type")->getInt(); + + DynamicPrintConfig config = make_file_config(); + // The GUI normalizes the config before load; mirror that so only the production path runs. + Preset::normalize(config); + + PublishedConfig pub; + pub.published = true; + pub.published_keys = published_keys; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // a) Process scalar overlaid; matching-size vector applied, mismatched one lands in + // skipped_keys; applied keys are not reported. + CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 0.000001)); + check_double_vector(bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values, { 140., 150. }); + CHECK(bundle.prints.get_edited_preset().config.opt("post_process")->values == std::vector{ "existing-script" }); + CHECK(contains_key(pub.skipped_keys, "post_process")); + CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height")); + CHECK_FALSE(contains_key(pub.skipped_keys, "wiping_volumes_extruders")); + + // b) A filament key is never applied anywhere and is reported as skipped. + CHECK(bundle.prints.get_edited_preset().config.option("nozzle_temperature") == nullptr); + CHECK(contains_key(pub.skipped_keys, "nozzle_temperature")); + + // c) A structural key is silently ignored: neither applied nor reported as skipped. + CHECK(bundle.prints.get_edited_preset().config.opt_string("print_settings_id") == "user process"); + CHECK_FALSE(contains_key(pub.skipped_keys, "print_settings_id")); + + // d) Only plate/bed geometry crosses in published mode: filament/purge data and bed type + // stay at the ctor seeds. + CHECK(bundle.project_config.opt("filament_colour")->values == seed_filament_colour); + CHECK(bundle.project_config.opt("flush_multiplier")->values == seed_flush_multiplier); + CHECK(bundle.project_config.option("curr_bed_type")->getInt() == seed_bed_type); + check_double_vector(bundle.project_config.opt("wipe_tower_x")->values, { 100. }); + CHECK_THAT(bundle.project_config.opt("wipe_tower_rotation_angle")->value, Catch::Matchers::WithinAbs(45., 0.000001)); + + // e) The published path keeps the user's currently-selected presets: same preset, same size. + CHECK(bundle.prints.get_edited_preset().name == pre_load_name); + CHECK(bundle.prints.size() == pre_load_size); + + // f) Non-published control: the overlay is disabled, the file's presets are imported + // instead, and no skipped_keys are produced. + PresetBundle control_bundle; + const size_t control_pre_size = control_bundle.prints.size(); + PublishedConfig control_pub; + control_pub.published = false; + control_pub.published_keys = published_keys; + DynamicPrintConfig control_config = make_file_config(); + Preset::normalize(control_config); + control_bundle.load_config_model("test.3mf", std::move(control_config), Semver(), &control_pub); + + CHECK(control_pub.skipped_keys.empty()); + CHECK(control_bundle.prints.size() > control_pre_size); + // The file's layer_height reached the edited preset via the normal import, not the overlay. + CHECK_THAT(control_bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 0.000001)); +} + +// The published printer overlay is restricted to the publishable retraction/z-hop allowlist: +// matching-size vectors apply, mismatched vectors are reported as skipped, and any other +// printer-class key (e.g. machine_start_gcode) is contract-excluded (never applied, never +// reported). +TEST_CASE("Published 3MF overlays only the allowlisted retraction and z-hop keys onto the edited printer preset", "[Preset][Bundle][Published]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + Preset::normalize(config); + config.opt("retraction_length")->values = { 1.4 }; // matching size (1 extruder) + config.opt("retraction_speed")->values = { 45., 55. }; // size 2: mismatched + config.opt_string("machine_start_gcode") = "G28 ; from file"; // outside the allowlist + + PresetBundle bundle; + bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8 }; + // A recognizable non-default value: the skipped mismatch below must leave it untouched + // (asserting the default instead would silently test PrintConfig's retraction_speed). + bundle.printers.get_edited_preset().config.opt("retraction_speed")->values = { 33. }; + bundle.printers.get_edited_preset().config.opt_string("machine_start_gcode") = "G28 ; user"; + + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "retraction_length", "retraction_speed", "machine_start_gcode" }; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // Matching-size retraction vector applied; mismatched vector reported as skipped and the + // receiver's own value survives. + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 1.4 }); + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_speed")->values, { 33. }); + CHECK(contains_key(pub.skipped_keys, "retraction_speed")); + // Contract-excluded printer key: silently ignored, absent from skipped_keys. + CHECK(bundle.printers.get_edited_preset().config.opt_string("machine_start_gcode") == "G28 ; user"); + CHECK_FALSE(contains_key(pub.skipped_keys, "machine_start_gcode")); +} + +// A published 3MF carries per-slot material keys; on load they are applied positionally to the +// receiver's slot N (a key-only entry has no type gate), written onto the slot's stored preset +// in place. +TEST_CASE("Published 3MF applies positional material keys onto the receiver's material presets", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + // Two filament slots; filament_diameter drives the normalized per-slot vector sizes. + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + // Keep the multi-extruder consistency validation happy for a 2-slot config. + config.opt("filament_self_index")->values = { 1, 2 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + config.opt("filament_type")->values = { "PLA", "PETG" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFT99" }; + // Author per-slot retraction values. These are per-filament override keys that are not + // members of the static PrintRegionConfig, so full_print_config() omits them and they + // must be created explicitly (as nullable, matching the real 3MF project config). + config.option("filament_retraction_length", true)->values = { 0.9, 1.2 }; + config.option("filament_z_hop", true)->values = { 0.2, 0.3 }; + return config; + }; + + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.filament_id = "GFL99"; + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt_string("filament_vendor", 0u) = "Generic"; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + pla.config.opt("filament_settings_id")->values = { "receiver-pla" }; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.filament_id = "GFT99"; + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt_string("filament_vendor", 0u) = "Generic"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + petg.config.opt("filament_z_hop", true)->values = { 0.1 }; + bundle.filament_presets = { "My PLA", "My PETG" }; + + PublishedMaterialEntry pla_entry; + pla_entry.filament_id = "GFL99"; + pla_entry.slot = 0; // the author's PLA slot + pla_entry.keys = { "filament_retraction_length", "filament_settings_id" }; + PublishedMaterialEntry petg_entry; + petg_entry.filament_id = "GFT99"; + petg_entry.slot = 1; // the author's PETG slot + petg_entry.keys = { "filament_retraction_length", "filament_z_hop" }; + // A slot-less entry (no slot field, only possible in hand-crafted files): silently skipped. + PublishedMaterialEntry noslot_entry; + noslot_entry.filament_type = "ABS"; + noslot_entry.keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { pla_entry, petg_entry, noslot_entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The author's slot values are written onto the receiver's stored presets in place. + check_double_vector(bundle.filaments.find_preset("My PLA")->config.opt("filament_retraction_length")->values, { 0.9 }); + check_double_vector(bundle.filaments.find_preset("My PETG")->config.opt("filament_retraction_length")->values, { 1.2 }); + check_double_vector(bundle.filaments.find_preset("My PETG")->config.opt("filament_z_hop")->values, { 0.3 }); + // Structural keys inside a material entry are silently ignored: the receiver's own + // filament_settings_id is untouched and nothing is reported for it. + CHECK(bundle.filaments.find_preset("My PLA")->config.opt("filament_settings_id")->values == std::vector{ "receiver-pla" }); + CHECK_FALSE(contains_key(pub.skipped_keys, "material:GFL99 (filament_settings_id)")); + // Everything applied; the slot-less entry produced no skipped entry. + CHECK(pub.skipped_keys.empty()); +} + +// A "full publish" slot serializes the whole filament. On load the slot always receives a +// standalone detached copy of the author's material - created even when the receiver's own +// material matches the published type - and no receiver library preset is ever mutated. +TEST_CASE("Published 3MF full-published slots are imported as standalone detached copies", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + // Two author slots; filament_diameter drives the normalized per-slot vector sizes. + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + config.opt("filament_type")->values = { "PLA", "PETG" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFT99" }; + config.option("filament_retraction_length", true)->values = { 0.9, 1.2 }; + return config; + }; + // The full dump of slot 0, publishing the whole filament as type "ABS". + auto make_full_abs_entry = [] { + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "ABS"; + entry.full_keys = { "filament_retraction_length" }; + return entry; + }; + + SECTION("type match still creates a detached copy instead of mutating the receiver material") { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + bundle.filament_presets = { "My PLA", "My PLA" }; + + PublishedMaterialEntry full = make_full_abs_entry(); + full.publish_type_value = "PLA"; // author requires PLA, receiver slot is PLA + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { full }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The type matches, but the import still detaches: the slot lands on a fresh copy + // named from the published type (no identity fields in this entry), carrying the + // author's values; the receiver's own material is untouched. + CHECK(bundle.filament_presets[0] == "PLA"); + Preset *copy = bundle.filaments.find_preset("PLA", false, true); + REQUIRE(copy != nullptr); + check_double_vector(copy->config.opt("filament_retraction_length")->values, { 0.9 }); + check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + CHECK(pub.skipped_keys.empty()); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PLA -> PLA"); + } + + SECTION("type mismatch also detaches: a fresh same-type copy replaces the slot") { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + Preset &abs = add_inmemory_preset(bundle.filaments, "My ABS"); + abs.config.opt_string("filament_type", 0u) = "ABS"; + abs.config.opt("filament_retraction_length", true)->values = { 0.3 }; + bundle.filament_presets = { "My PLA" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_full_abs_entry() }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 1); + // No substitution search runs: a brand-new copy named after the published type is + // created and both pre-existing presets stay exactly as they were. + CHECK(bundle.filament_presets[0] == "ABS"); + Preset *copy = bundle.filaments.find_preset("ABS", false, true); + REQUIRE(copy != nullptr); + check_double_vector(copy->config.opt("filament_retraction_length")->values, { 0.9 }); + check_double_vector(bundle.filaments.find_preset("My ABS", false, true)->config.opt("filament_retraction_length")->values, { 0.3 }); + check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + CHECK(pub.skipped_keys.empty()); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PLA -> ABS"); + } + + SECTION("the author's identity rides on the copy when the type has no library match") { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + Preset &other = add_inmemory_preset(bundle.filaments, "Other PLA"); + other.config.opt_string("filament_type", 0u) = "PLA"; + other.config.opt("filament_retraction_length", true)->values = { 0.7 }; + bundle.filament_presets = { "My PLA" }; + + PublishedMaterialEntry full = make_full_abs_entry(); + // The dump carries the identity too, so the created copy takes the author's type + // and vendor instead of the baseline clone's. + full.full_keys = { "filament_retraction_length", "filament_type", "filament_vendor" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { full }; + DynamicPrintConfig config = make_file_config(); + // The author's slot 0 really is ABS. + config.opt("filament_type")->values = { "ABS", "PETG" }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // No ABS preset needs to exist in the library: the copy carries the author's values, + // type and vendor included. Neither receiver preset was touched. + CHECK(bundle.filament_presets[0] == "ABS"); + Preset *copy = bundle.filaments.find_preset("ABS", false, true); + REQUIRE(copy != nullptr); + check_double_vector(copy->config.opt("filament_retraction_length")->values, { 0.9 }); + CHECK(copy->config.opt_string("filament_type", 0u) == "ABS"); + CHECK(copy->config.opt_string("filament_vendor", 0u) == "Generic"); + check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt_string("filament_type", 0u) == "PLA"); + check_double_vector(bundle.filaments.find_preset("Other PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.7 }); + CHECK(pub.skipped_keys.empty()); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PLA -> ABS"); + } +} + +// The author's preset name travels in the file and names the created standalone copy (variant +// tail stripped), regardless of what the receiver's library holds: an exact-name library preset +// is never reused nor mutated. +TEST_CASE("Published 3MF imports a full material under the author's stripped name", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75 }; + config.opt("filament_self_index")->values = { 1 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt("filament_type")->values = { "PLA" }; + config.opt("filament_vendor")->values = { "Generic" }; + config.opt("filament_ids")->values = { "OGFL99" }; + config.option("filament_retraction_length", true)->values = { 0.9 }; + return config; + }; + + auto add_pla = [](PresetBundle &bundle, const char *name, const char *id, const char *vendor, const char *setting_id) { + Preset &preset = add_inmemory_preset(bundle.filaments, name); + preset.filament_id = id; + preset.setting_id = setting_id; + preset.config.opt_string("filament_type", 0u) = "PLA"; + preset.config.opt_string("filament_vendor", 0u) = vendor; + preset.config.opt("filament_retraction_length", true)->values = { 0.5 }; + return &preset; + }; + + SECTION("an exact-name library preset exists: a detached copy is created beside it") { + PresetBundle bundle; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + add_pla(bundle, "Generic PLA @System", "OGFL99", "Generic", "RcBNzytWgwRrwXXz"); + add_pla(bundle, "Bambu PLA Basic @System", "OGFA00", "Bambu Lab", "zkc85XTKi4cb6cOw"); + bundle.filament_presets = { "My PETG" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.filament_id = "OGFL99"; + entry.filament_vendor = "Generic"; + entry.setting_id = "RcBNzytWgwRrwXXz"; + entry.preset_name = "Generic PLA @System"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The copy is named after the stripped author name; the receiver's exact-name preset + // keeps its own values. + CHECK(bundle.filament_presets[0] == "Generic PLA"); + check_double_vector(bundle.filaments.find_preset("Generic PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.9 }); + check_double_vector(bundle.filaments.find_preset("Generic PLA @System", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt("filament_retraction_length")->values, { 0.6 }); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Generic PLA"); + CHECK(pub.skipped_keys.empty()); + } + + SECTION("only the name is present (broken/stale ids): the copy is still created") { + PresetBundle bundle; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + add_pla(bundle, "Generic PLA @System", "OGFL99", "Generic", "RcBNzytWgwRrwXXz"); + add_pla(bundle, "Bambu PLA Basic @System", "OGFA00", "Bambu Lab", "zkc85XTKi4cb6cOw"); + bundle.filament_presets = { "My PETG" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.preset_name = "Generic PLA @System"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + CHECK(bundle.filament_presets[0] == "Generic PLA"); + check_double_vector(bundle.filaments.find_preset("Bambu PLA Basic @System", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Generic PLA"); + CHECK(pub.skipped_keys.empty()); + } + + SECTION("grown slot (author slot 1) receives its own detached copy") { + auto two_slot_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#FFFF00" }; + config.opt("filament_type")->values = { "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + config.opt("filament_ids")->values = { "OGFL99", "OGFL99" }; + config.option("filament_retraction_length", true)->values = { 0.9, 0.8 }; + return config; + }; + + PresetBundle bundle; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + add_pla(bundle, "Generic PLA @System", "OGFL99", "Generic", "RcBNzytWgwRrwXXz"); + add_pla(bundle, "Bambu PLA Basic @System", "OGFA00", "Bambu Lab", "zkc85XTKi4cb6cOw"); + bundle.filament_presets = { "My PETG" }; + + PublishedMaterialEntry entry; + entry.slot = 1; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.preset_name = "Generic PLA @System"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = two_slot_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 2); + CHECK(bundle.filament_presets[0] == "My PETG"); + // The grown slot seeds the receiver's last preset ("My PETG"), then the full material + // detaches onto a copy carrying the author's slot-1 value; "Generic PLA @System" is + // left untouched. + CHECK(bundle.filament_presets[1] == "Generic PLA"); + check_double_vector(bundle.filaments.find_preset("Generic PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.8 }); + check_double_vector(bundle.filaments.find_preset("Generic PLA @System", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 1: My PETG -> Generic PLA"); + CHECK(pub.skipped_keys.empty()); + } +} + +// The stripped author name can collide with an existing library preset ("Generic PLA"): the +// created copy must uniquify with the "(Published)" suffix rather than overwrite, reuse or +// mutate any of the receiver's own presets. +TEST_CASE("Published 3MF uniquifies an imported full material name on collision", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + // A legacy bundle preset literally named "Generic PLA" - collides with the stripped name. + Preset &bare = add_inmemory_preset(bundle.filaments, "Generic PLA"); + bare.config.opt_string("filament_type", 0u) = "PLA"; + bare.config.opt_string("filament_vendor", 0u) = "Generic"; + bare.config.opt("filament_retraction_length", true)->values = { 0.5 }; + // The author's exact preset. + Preset &qidi = add_inmemory_preset(bundle.filaments, "Generic PLA @Qidi Q2 0.4 nozzle"); + qidi.config.opt_string("filament_type", 0u) = "PLA"; + qidi.config.opt_string("filament_vendor", 0u) = "Generic"; + qidi.config.opt("filament_retraction_length", true)->values = { 0.5 }; + // The Orca library preset. + Preset &sys = add_inmemory_preset(bundle.filaments, "Generic PLA @System"); + sys.config.opt_string("filament_type", 0u) = "PLA"; + sys.config.opt_string("filament_vendor", 0u) = "Generic"; + sys.config.opt("filament_retraction_length", true)->values = { 0.5 }; + bundle.filament_presets = { "My PETG" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.preset_name = "Generic PLA @Qidi Q2 0.4 nozzle"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = published_pla_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The copy lands beside the collision, suffixed; every pre-existing preset keeps its own + // values. + CHECK(bundle.filament_presets[0] == "Generic PLA (Published)"); + check_double_vector(bundle.filaments.find_preset("Generic PLA (Published)", false, true)->config.opt("filament_retraction_length")->values, { 0.9 }); + check_double_vector(bundle.filaments.find_preset("Generic PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + check_double_vector(bundle.filaments.find_preset("Generic PLA @Qidi Q2 0.4 nozzle", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + check_double_vector(bundle.filaments.find_preset("Generic PLA @System", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Generic PLA (Published)"); + CHECK(pub.skipped_keys.empty()); +} + +// The overlay writes onto the edited layer only when that layer survives the load (slot 0's +// preset still matches the edited preset). When the user views a non-first slot's material and +// the published entry targets that slot, the final re-select would destroy the edited layer - +// so the values must land on the stored preset instead and survive. +TEST_CASE("Published 3MF writes to the stored preset when the edited layer is re-selected away", "[Preset][Bundle][Published]") +{ + // Two-slot author config: slot 1 carries the published retraction value. + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + config.opt("filament_type")->values = { "PETG", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFT99", "GFL99" }; + config.option("filament_retraction_length", true)->values = { 0.6, 0.9 }; + return config; + }; + + PresetBundle bundle; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + bundle.filament_presets = { "My PETG", "My PLA" }; + // The user is viewing slot 1's material. + REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false)); + + PublishedMaterialEntry entry; + entry.slot = 1; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.publish_color = true; + entry.color = "#ABCDEF"; + entry.keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The published values survived on the stored preset (the edited layer was re-selected to + // slot 0's material and must not have been the only copy). + Preset *stored = bundle.filaments.find_preset("My PLA", false, true); + REQUIRE(stored != nullptr); + CHECK(stored->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + check_double_vector(stored->config.opt("filament_retraction_length")->values, { 0.9 }); + // The load re-selected slot 0's material, mirroring a normal project load. + CHECK(bundle.filaments.get_edited_preset().name == "My PETG"); + // Selecting the slot's material afterwards surfaces the applied values. + REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false)); + CHECK(bundle.filaments.get_edited_preset().config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + check_double_vector(bundle.filaments.get_edited_preset().config.opt("filament_retraction_length")->values, { 0.9 }); + CHECK(pub.skipped_keys.empty()); +} + +// The created copy is named after the author's preset with the "@variant" tail stripped; +// trailing whitespace left behind by the truncation must be trimmed away, and names without +// a tail pass through unchanged. +TEST_CASE("publish_material_base_name strips the variant tail from a published preset name", "[Preset][Bundle][Published]") +{ + CHECK(publish_material_base_name("Generic PLA @System") == "Generic PLA"); + CHECK(publish_material_base_name("Generic PLA @Qidi Q2 0.4 nozzle") == "Generic PLA"); + // Truncation at '@' leaves the space before the tail; it must not survive. + CHECK(publish_material_base_name("Generic PLA @System") == "Generic PLA"); + CHECK(publish_material_base_name("Voron Generic PLA") == "Voron Generic PLA"); + CHECK(publish_material_base_name("") == ""); + // A tail-only name strips to nothing; the caller falls back to identity fields. + CHECK(publish_material_base_name("@System").empty()); +} + +// A full-published material arrives as a brand-new standalone preset: parentless, visible, +// project-embedded ("Preset Inside Project"), carrying the author's values and colour - and +// never touching any of the receiver's own presets. +TEST_CASE("Published 3MF imports a full material as a detached project-embedded preset", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + Preset &spare = add_inmemory_preset(bundle.filaments, "Spare PLA"); + spare.config.opt_string("filament_type", 0u) = "PLA"; + spare.config.opt("filament_retraction_length", true)->values = { 0.4 }; + bundle.filament_presets = { "My PETG" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.publish_color = true; + entry.color = "#ABCDEF"; + entry.filament_id = "AFL01"; + entry.setting_id = "Sid000111222"; + entry.preset_name = "Author PLA @Vendor"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = published_pla_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The slot lands on the freshly created copy, named after the stripped author name. + CHECK(bundle.filament_presets[0] == "Author PLA"); + Preset *copy = bundle.filaments.find_preset("Author PLA", false, true); + REQUIRE(copy != nullptr); + // Detached + project-embedded contract. + CHECK(copy->is_project_embedded); + CHECK(copy->inherits().empty()); + CHECK(copy->setting_id.empty()); + CHECK(copy->vendor == nullptr); + CHECK_FALSE(copy->is_system); + CHECK_FALSE(copy->is_default); + CHECK_FALSE(copy->is_external); + CHECK(copy->is_visible); + CHECK(copy->filament_id == "AFL01"); + CHECK(copy->config.opt("filament_settings_id")->values == std::vector{ "Author PLA" }); + // The published values and colour live on the copy. + check_double_vector(copy->config.opt("filament_retraction_length")->values, { 0.9 }); + CHECK(copy->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + // Universally compatible: no printer/print restrictions survive the import. + CHECK(copy->config.opt("compatible_printers")->values.empty()); + CHECK(copy->config.opt("compatible_prints")->values.empty()); + CHECK(copy->config.opt("compatible_printers_condition")->value.empty()); + CHECK(copy->config.opt("compatible_prints_condition")->value.empty()); + // Nothing pre-existing was touched. + check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt("filament_retraction_length")->values, { 0.6 }); + check_double_vector(bundle.filaments.find_preset("Spare PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.4 }); + CHECK(pub.skipped_keys.empty()); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Author PLA"); +} + +// Compatibility restrictions riding on the receiver's baseline preset must not leak onto the +// imported copy: a detached full material is usable with every printer and print profile. +TEST_CASE("Published 3MF clears printer restrictions on the imported full material", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &restricted = add_inmemory_preset(bundle.filaments, "Restricted PLA"); + restricted.config.opt_string("filament_type", 0u) = "PLA"; + restricted.config.opt("filament_retraction_length", true)->values = { 0.5 }; + restricted.config.set_key_value("compatible_printers", new ConfigOptionStrings({ "Unrelated Printer" })); + restricted.config.set_key_value("compatible_prints", new ConfigOptionStrings({ "Unrelated Print" })); + restricted.config.option("compatible_printers_condition", true)->value = "printer_settings_id==\"Nope\""; + restricted.config.option("compatible_prints_condition", true)->value = "print_settings_id==\"Nope\""; + bundle.filament_presets = { "Restricted PLA" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = published_pla_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The copy (named from the published type; no identity fields) has no restrictions left. + CHECK(bundle.filament_presets[0] == "PLA"); + Preset *copy = bundle.filaments.find_preset("PLA", false, true); + REQUIRE(copy != nullptr); + CHECK(copy->config.opt("compatible_printers")->values.empty()); + CHECK(copy->config.opt("compatible_prints")->values.empty()); + CHECK(copy->config.opt("compatible_printers_condition")->value.empty()); + CHECK(copy->config.opt("compatible_prints_condition")->value.empty()); + // The receiver's own restricted preset keeps its restrictions. + const Preset *original = bundle.filaments.find_preset("Restricted PLA", false, true); + REQUIRE(original != nullptr); + CHECK(original->config.opt("compatible_printers")->values == std::vector{ "Unrelated Printer" }); + CHECK(original->config.opt("compatible_printers_condition")->value == "printer_settings_id==\"Nope\""); + CHECK(pub.skipped_keys.empty()); +} + +// Identical Full materials (same setting_id + preset_name identity) share one created +// instance: an author who pointed several slots at one material gets one standalone copy, +// and the first entry's slot values win. +TEST_CASE("Published 3MF shares one imported copy between identical full slots", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + config.opt("filament_type")->values = { "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + config.opt("filament_ids")->values = { "AFL01", "AFL01" }; + config.option("filament_retraction_length", true)->values = { 0.9, 0.8 }; + return config; + }; + auto make_entry = [](int slot) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.filament_id = "AFL01"; + entry.setting_id = "Sid000111222"; + entry.preset_name = "Author PLA @Vendor"; + entry.full_keys = { "filament_retraction_length" }; + return entry; + }; + + PresetBundle bundle; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + Preset &other = add_inmemory_preset(bundle.filaments, "Other PETG"); + other.config.opt_string("filament_type", 0u) = "PETG"; + other.config.opt("filament_retraction_length", true)->values = { 0.65 }; + bundle.filament_presets = { "My PETG", "Other PETG" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_entry(0), make_entry(1) }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // Both slots point at the single shared copy; no second "(Published)" instance exists. + REQUIRE(bundle.filament_presets.size() == 2); + CHECK(bundle.filament_presets[0] == "Author PLA"); + CHECK(bundle.filament_presets[1] == "Author PLA"); + CHECK(bundle.filaments.find_preset("Author PLA", false, true) != nullptr); + CHECK(bundle.filaments.find_preset("Author PLA (Published)", false, true) == nullptr); + // The first entry's slot values won. + check_double_vector(bundle.filaments.find_preset("Author PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.9 }); + // Both slots reported, same target; originals untouched. + REQUIRE(pub.material_replacements.size() == 2); + CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Author PLA"); + CHECK(pub.material_replacements[1] == "slot 1: Other PETG -> Author PLA"); + check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt("filament_retraction_length")->values, { 0.6 }); + check_double_vector(bundle.filaments.find_preset("Other PETG", false, true)->config.opt("filament_retraction_length")->values, { 0.65 }); + CHECK(pub.skipped_keys.empty()); +} + +// Without a preset name the copy falls back to the stable material id; fully anonymous +// hand-crafted entries never share instances between slots (their dedup key is slot-scoped). +TEST_CASE("Published 3MF names unidentified full materials from their fallback fields", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + config.opt("filament_type")->values = { "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFT99" }; + config.option("filament_retraction_length", true)->values = { 0.9, 1.2 }; + return config; + }; + + SECTION("empty preset_name falls back to the filament_id") { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + bundle.filament_presets = { "My PLA" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.filament_id = "AFL01"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = published_pla_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + CHECK(bundle.filament_presets[0] == "AFL01"); + CHECK(bundle.filaments.find_preset("AFL01", false, true) != nullptr); + CHECK(pub.skipped_keys.empty()); + } + + SECTION("fully anonymous entries get slot-scoped copies") { + PresetBundle bundle; + Preset &first = add_inmemory_preset(bundle.filaments, "First PLA"); + first.config.opt_string("filament_type", 0u) = "PLA"; + first.config.opt("filament_retraction_length", true)->values = { 0.5 }; + Preset &second = add_inmemory_preset(bundle.filaments, "Second PLA"); + second.config.opt_string("filament_type", 0u) = "PLA"; + second.config.opt("filament_retraction_length", true)->values = { 0.55 }; + bundle.filament_presets = { "First PLA", "Second PLA" }; + + PublishedMaterialEntry entry0; + entry0.slot = 0; + entry0.full = true; + entry0.publish_type = true; + entry0.publish_type_value = "ABS"; // the only naming field present + entry0.full_keys = { "filament_retraction_length" }; + PublishedMaterialEntry entry1 = entry0; + entry1.slot = 1; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry0, entry1 }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // No shared identity: each slot gets its own uniquified copy. + CHECK(bundle.filament_presets[0] == "ABS"); + CHECK(bundle.filament_presets[1] == "ABS (Published)"); + CHECK(bundle.filaments.find_preset("ABS", false, true) != nullptr); + CHECK(bundle.filaments.find_preset("ABS (Published)", false, true) != nullptr); + CHECK(pub.skipped_keys.empty()); + } +} + +// Within-load dedup does not span loads: importing the same published file again into the same +// session creates a second, uniquified copy instead of mutating or reusing the first. +TEST_CASE("Re-importing a published full material uniquifies the second copy", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + bundle.filament_presets = { "My PETG" }; + + auto make_entry = [] { + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.filament_id = "AFL01"; + entry.setting_id = "Sid000111222"; + entry.preset_name = "Author PLA @Vendor"; + entry.full_keys = { "filament_retraction_length" }; + return entry; + }; + + for (int round = 0; round < 2; ++round) { + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_entry() }; + DynamicPrintConfig config = published_pla_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + if (round == 0) { + CHECK(bundle.filament_presets[0] == "Author PLA"); + CHECK(bundle.filaments.find_preset("Author PLA (Published)", false, true) == nullptr); + } else { + // The second import uniquifies beside the first instead of touching it. + CHECK(bundle.filament_presets[0] == "Author PLA (Published)"); + check_double_vector(bundle.filaments.find_preset("Author PLA (Published)", false, true)->config.opt("filament_retraction_length")->values, { 0.9 }); + check_double_vector(bundle.filaments.find_preset("Author PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.9 }); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: Author PLA -> Author PLA (Published)"); + } + CHECK(pub.skipped_keys.empty()); + } +} + +// A partially-published slot can carry a curated type and/or colour. The colour is applied +// regardless of the type match; a type mismatch with no same-type replacement keeps the +// receiver's material and reports the slot's keys as skipped. The receiver's slot count grows +// only as far as the highest slot with published content. +TEST_CASE("Published 3MF partial slots apply colour and gate keys by the published type", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + config.opt("filament_type")->values = { "PLA", "PETG" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFT99" }; + config.option("filament_retraction_length", true)->values = { 0.9, 1.2 }; + return config; + }; + + SECTION("matching type applies the keys and the colour") { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + bundle.filament_presets = { "My PLA" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.publish_color = true; + entry.color = "#ABCDEF"; + entry.keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // Type matched: keys and colour applied onto the receiver's preset in place. + Preset *pla_preset = bundle.filaments.find_preset("My PLA", false, true); + REQUIRE(pla_preset != nullptr); + check_double_vector(pla_preset->config.opt("filament_retraction_length")->values, { 0.9 }); + CHECK(pla_preset->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + CHECK(pub.skipped_keys.empty()); + CHECK(pub.material_replacements.empty()); + } + + SECTION("type mismatch without a replacement keeps the material and skips the keys") { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + bundle.filament_presets = { "My PLA" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.publish_type = true; + entry.publish_type_value = "ABS"; // no ABS in the receiver library + entry.publish_color = true; + entry.color = "#ABCDEF"; + entry.keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The slot keeps the receiver's material: the colour applies in place, the keys are + // skipped. + CHECK(bundle.filament_presets[0] == "My PLA"); + Preset *pla_preset = bundle.filaments.find_preset("My PLA", false, true); + REQUIRE(pla_preset != nullptr); + check_double_vector(pla_preset->config.opt("filament_retraction_length")->values, { 0.5 }); + CHECK(pla_preset->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + CHECK(contains_key(pub.skipped_keys, "material:ABS (filament_retraction_length)")); + CHECK(pub.material_replacements.empty()); + } + + SECTION("receiver slot count grows to fit the highest published slot and assigns matching type preset") { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.8 }; + // The receiver has a single slot; the file carries two, only slot 1 is published. + bundle.filament_presets = { "My PLA" }; + + PublishedMaterialEntry entry; + entry.slot = 1; + entry.publish_type = true; + entry.publish_type_value = "PETG"; + entry.keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The slot list was grown so author slot 1 has a material (the PETG preset), which + // then receives the author's values in place. + REQUIRE(bundle.filament_presets.size() == 2); + CHECK(bundle.filament_presets[1] == "My PETG"); + check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt("filament_retraction_length")->values, { 1.2 }); + check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + } +} + +// The receiver's slot list grows only as far as the highest published slot: a file whose author +// published nothing (or only a low slot) must not pull filler materials into the receiver's +// setup, and the receiver never grows to the file's count. +TEST_CASE("Published 3MF grows the receiver's slots only as far as the published slots", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + // Four author slots (a 4-filament model). + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3, 4 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" }; + config.opt("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" }; + return config; + }; + auto add_pla_preset = [](PresetBundle &bundle) { + Preset &preset = add_inmemory_preset(bundle.filaments, "My PLA"); + preset.config.opt_string("filament_type", 0u) = "PLA"; + preset.config.opt("filament_colour", true)->values = { "#123456" }; + return &preset; + }; + auto make_color_entry = [](int slot) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.publish_color = true; + entry.color = "#ABCDEF"; + return entry; + }; + + // A file whose author published nothing for any slot: the receiver's setup is untouched. + { + PresetBundle bundle; + add_pla_preset(bundle); + bundle.filament_presets = { "My PLA" }; + + PublishedConfig pub; + pub.published = true; // no material entries at all + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + CHECK(bundle.filament_presets.size() == 1); + CHECK(bundle.filaments.find_preset("My PLA")->config.opt("filament_colour")->values == std::vector{ "#123456" }); + CHECK(pub.skipped_keys.empty()); + } + + // Only slot 0 published: a single-slot receiver keeps its single slot; the file's other + // three slots pull nothing in. + { + PresetBundle bundle; + add_pla_preset(bundle); + bundle.filament_presets = { "My PLA" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_color_entry(0) }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + CHECK(bundle.filament_presets.size() == 1); + // The published colour lands on the slot's preset in place. + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + } + + // Slot 3 published: the receiver grows to 4 so the published slot exists. Unpublished + // filler slots repeat the receiver's last preset ("Add one filament" behaviour). + { + PresetBundle bundle; + add_pla_preset(bundle); + bundle.filament_presets = { "My PLA" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_color_entry(3) }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 4); + CHECK(bundle.filament_presets[1] == "My PLA"); + CHECK(bundle.filament_presets[2] == "My PLA"); + // Only "My PLA" exists in the library, so the published slot keeps the aliasing and the + // colour is written onto the shared preset (every slot references it). + CHECK(bundle.filament_presets[3] == "My PLA"); + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + // The project-level per-slot vectors were grown and seeded: fillers take their preset's + // colour, the published slot its published colour. + CHECK(bundle.project_config.opt("filament_colour")->values.size() == 4); + CHECK(bundle.project_config.opt("filament_colour")->values[1] == "#123456"); + CHECK(bundle.project_config.opt("filament_colour")->values[3] == "#ABCDEF"); + CHECK(bundle.project_config.opt("filament_multi_colour")->values.size() == 4); + CHECK(bundle.project_config.opt("filament_colour_type")->values.size() == 4); + CHECK(bundle.project_config.opt("filament_map")->values.size() == 4); + CHECK(bundle.project_config.opt("flush_volumes_matrix")->values.size() == 16); + } + + // Slots 0 and 2 published: the receiver grows to 3, never to the file's 4. + { + PresetBundle bundle; + add_pla_preset(bundle); + bundle.filament_presets = { "My PLA" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_color_entry(0), make_color_entry(2) }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + CHECK(bundle.filament_presets[1] == "My PLA"); + } + + // A receiver with more slots than the file's filament count keeps its setup: neither the + // preset list nor the project-level vectors are shrunk to the file's smaller size. + { + PresetBundle bundle; + add_pla_preset(bundle); + bundle.filament_presets = { "My PLA", "My PLA", "My PLA" }; + // Distinct project colours make a shrink observable. + bundle.project_config.opt("filament_colour")->values = { "#111111", "#222222", "#333333" }; + bundle.project_config.opt("filament_multi_colour")->values = { "#111111", "#222222", "#333333" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_color_entry(0) }; // highest published slot: 0 + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + // A one-filament file: num_filaments (1) is below the receiver's slot count (3). + config.opt("filament_diameter")->values = { 1.75 }; + config.opt("filament_self_index")->values = { 1 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt("filament_type")->values = { "PLA" }; + config.opt("filament_vendor")->values = { "Generic" }; + config.opt("filament_ids")->values = { "GFL99" }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + // No shrink: all three project entries survive, with slot 0 synced to the published + // colour at its unshifted index and slots 1-2 untouched. + CHECK(bundle.project_config.opt("filament_colour")->values == std::vector{ "#ABCDEF", "#222222", "#333333" }); + CHECK(bundle.project_config.opt("filament_multi_colour")->values == std::vector{ "#ABCDEF", "#222222", "#333333" }); + CHECK(bundle.project_config.opt("filament_map")->values.size() == 3); + // The published colour still reached slot 0's preset in place. + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + } +} + +// A published slot is seeded from an unused library preset and the values are written onto it +// in place, so the receiver's own material (slot 0) is never overwritten. +TEST_CASE("Published 3MF grows published slots to the receiver's last preset and recolors it in place", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3, 4 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" }; + config.opt("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" }; + return config; + }; + + // Receiver with its own material plus one more library preset; author publishes only slot 4 + // (Red). Growth always repeats the receiver's last filament ("Add one filament"), so the + // grown slot references the shared "My PLA" preset and the published red recolors it in + // place; the unused "Other PLA" preset is left untouched. + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + mine.config.opt("filament_colour", true)->values = { "#123456" }; + Preset &other = add_inmemory_preset(bundle.filaments, "Other PLA"); + other.config.opt_string("filament_type", 0u) = "PLA"; + other.config.opt("filament_colour", true)->values = { "#654321" }; + bundle.filament_presets = { "My PLA" }; + + PublishedMaterialEntry entry; + entry.slot = 3; + entry.publish_color = true; + entry.color = "#ABCDEF"; + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 4); + // Every grown slot (published or filler) repeats the receiver's last preset. + CHECK(bundle.filament_presets[1] == "My PLA"); + CHECK(bundle.filament_presets[2] == "My PLA"); + // The published red recolors the shared "My PLA" preset in place; the unused "Other PLA" + // preset is left untouched. + CHECK(bundle.filament_presets[3] == "My PLA"); + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + CHECK(bundle.filaments.find_preset("Other PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#654321" }); + // The project-level colours are sized and seeded for every grown slot. + CHECK(bundle.project_config.opt("filament_colour")->values.size() == 4); + CHECK(bundle.project_config.opt("filament_colour")->values[1] == "#123456"); + CHECK(bundle.project_config.opt("filament_colour")->values[3] == "#ABCDEF"); + CHECK(bundle.project_config.opt("filament_multi_colour")->values.size() == 4); + CHECK(bundle.project_config.opt("filament_colour_type")->values.size() == 4); + CHECK(bundle.project_config.opt("filament_map")->values.size() == 4); +} + +// Growth always repeats the receiver's last preset; a published slot only lands on its +// material identity when the aliased grown slot is re-pointed (de-alias fires on a preset +// key). Lock the identity priority there: an exact filament_id outranks an arbitrary unused +// preset, and an entry carrying only a family constrains the pick to that family. +TEST_CASE("Published 3MF re-points an aliased grown slot by published identity or family without a type requirement", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3, 4 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" }; + config.opt("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" }; + return config; + }; + + PublishedMaterialEntry entry; + entry.slot = 2; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.keys = { "filament_retraction_length" }; + // An unused preset sorting before everything else: an unconstrained pick would take it. + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + Preset &arbitrary = add_inmemory_preset(bundle.filaments, "Aaa PLA"); + arbitrary.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA" }; + + SECTION("an exact filament_id outranks the first unused preset") { + Preset &authored = add_inmemory_preset(bundle.filaments, "Zzz PLA"); + authored.config.opt_string("filament_type", 0u) = "PLA"; + authored.filament_id = "GFA00"; + entry.filament_id = "GFA00"; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + CHECK(bundle.filament_presets[1] == "My PLA"); + CHECK(bundle.filament_presets[2] == "Zzz PLA"); + // The exact-id preset outranks the type-only "Aaa PLA"; the re-point is reported. + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 2: My PLA -> Zzz PLA"); + } + + SECTION("a family-only entry picks an unused preset of that family") { + Preset &petg = add_inmemory_preset(bundle.filaments, "Bbb PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + entry.filament_type = "PETG"; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + // The family pick lands on the only PETG preset (the PLA presets and the receiver's own + // material lose), and the re-point is reported. + CHECK(bundle.filament_presets[2] == "Bbb PETG"); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 2: My PLA -> Bbb PETG"); + } +} + +// The GUI displays the edited preset, a snapshot of the selected collection preset taken at +// selection time. Since the overlay mutates the collection presets in place, the load must +// re-select the first slot's filament so the applied values - and slot replacements - surface +// in the GUI. +TEST_CASE("Published 3MF refreshes the edited preset so the applied material values surface", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75 }; + config.opt("filament_self_index")->values = { 1 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt("filament_type")->values = { "PLA" }; + config.opt("filament_vendor")->values = { "Generic" }; + config.opt("filament_ids")->values = { "GFL99" }; + config.option("filament_retraction_length", true)->values = { 0.9 }; + return config; + }; + auto make_entry = [] { + PublishedMaterialEntry entry; + entry.slot = 0; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.publish_color = true; + entry.color = "#ABCDEF"; + entry.keys = { "filament_retraction_length" }; + return entry; + }; + + SECTION("the edited preset carries the applied colour and keys") { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + bundle.filament_presets = { "My PLA" }; + // Mirror the GUI: the displayed preset is the collection's edited preset. + REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false)); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_entry() }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + const Preset &edited = bundle.filaments.get_edited_preset(); + // The slot references the edited preset, so the overlay lands on the edited layer: + // visible as a modification while the stored preset stays untouched. + CHECK(edited.name == "My PLA"); + CHECK(edited.config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + check_double_vector(edited.config.opt("filament_retraction_length")->values, { 0.9 }); + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#123456" }); + check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + // The overlay is a visible, revertible modification of the edited preset. + CHECK(bundle.filaments.current_is_dirty()); + CHECK(pub.skipped_keys.empty()); + } + + SECTION("a slot replacement is reflected in the edited preset") { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + Preset &abs = add_inmemory_preset(bundle.filaments, "My ABS"); + abs.config.opt_string("filament_type", 0u) = "ABS"; + abs.config.opt("filament_retraction_length", true)->values = { 0.3 }; + bundle.filament_presets = { "My PLA" }; + REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false)); + + PublishedMaterialEntry entry = make_entry(); + entry.publish_type_value = "ABS"; // mismatch: replaced by the library's ABS + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets[0] == "My ABS"); + // The edited preset now displays the replacement with the author's values on top. + const Preset &edited = bundle.filaments.get_edited_preset(); + CHECK(edited.name == "My ABS"); + CHECK(edited.config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + check_double_vector(edited.config.opt("filament_retraction_length")->values, { 0.9 }); + } +} + +// The overlay lands on the edited layer when the slot references the collection's edited +// preset, so the user's unsaved in-memory edits on it survive a published load (only the +// published keys are touched) and the change shows as a visible, revertible modification. +TEST_CASE("Published 3MF preserves unsaved edits on the edited filament preset", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + pla.config.opt("filament_z_hop", true)->values = { 0.1 }; + bundle.filament_presets = { "My PLA" }; + REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false)); + + // The user has unsaved in-memory edits on the preset being shown. + bundle.filaments.get_edited_preset().config.opt("filament_z_hop")->values = { 0.7 }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.publish_color = true; + entry.color = "#ABCDEF"; + entry.keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = published_pla_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // Published values land on the edited layer... + const Preset &edited = bundle.filaments.get_edited_preset(); + CHECK(edited.name == "My PLA"); + CHECK(edited.config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + check_double_vector(edited.config.opt("filament_retraction_length")->values, { 0.9 }); + // ...the user's unsaved edit on a non-published key survives... + check_double_vector(edited.config.opt("filament_z_hop")->values, { 0.7 }); + // ...and the stored preset is untouched. + Preset *stored = bundle.filaments.find_preset("My PLA", false, true); + REQUIRE(stored != nullptr); + CHECK(stored->config.opt("filament_colour")->values == std::vector{ "#123456" }); + check_double_vector(stored->config.opt("filament_retraction_length")->values, { 0.5 }); + check_double_vector(stored->config.opt("filament_z_hop")->values, { 0.1 }); + // The overlay is a visible, revertible modification of the edited preset. + CHECK(bundle.filaments.current_is_dirty()); + CHECK(pub.skipped_keys.empty()); + CHECK(pub.material_replacements.empty()); +} + +// The published overlay must validate '#' variant indices: an out-of-range index is reported as +// skipped and must NOT resize/corrupt the receiver's vector, and a variant suffix on a scalar +// key is rejected instead of silently no-op'd. +TEST_CASE("Published 3MF rejects out-of-range vector variants and variant-suffixed scalar keys", "[Preset][Bundle][Published]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + // Vector key, size 2 (matches the receiver's resized vector); distinct values make the + // applied element observable. + config.opt("wiping_volumes_extruders")->values = { 140., 150. }; + config.opt_float("layer_height") = 0.28; + Preset::normalize(config); + + PresetBundle bundle; + bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values = { 10., 20. }; + bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1; + + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "wiping_volumes_extruders#5", "wiping_volumes_extruders#1", "wiping_volumes_extruders#abc", "layer_height#0" }; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // In-range variant applied element-wise; the out-of-range one did not resize the vector. + check_double_vector(bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values, { 10., 150. }); + CHECK(bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values.size() == 2); + // Out-of-range variant, malformed variant and variant-suffixed scalar are reported as + // skipped; the malformed one must not fall back to element 0. + CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders#5")); + CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders#abc")); + CHECK(contains_key(pub.skipped_keys, "layer_height#0")); + CHECK_FALSE(contains_key(pub.skipped_keys, "wiping_volumes_extruders#1")); + // The scalar was never applied. + CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.1, 0.000001)); +} + +// The print/printer overlay guards option types like the material pass does: a published key +// whose file-side option kind differs from the receiver's is reported as skipped instead of +// throwing ConfigurationError out of load_config_model, which would abort the whole project +// load. (Nullable variants share the type() of their non-nullable base, so this covers +// genuinely different option kinds - e.g. a string where a float vector is expected.) +TEST_CASE("Published 3MF reports type-mismatched keys as skipped instead of aborting", "[Preset][Bundle][Published]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + // The file carries the vector key as a string option (equal size to the receiver's)... + config.set_key_value("wiping_volumes_extruders", new ConfigOptionStrings({ "140", "150" })); + config.opt_float("layer_height") = 0.28; + + PresetBundle bundle; + // ...while the receiver's edited print preset holds the float variant of the same key: + // without the type guard, ConfigOptionVector::set() throws ConfigurationError out of + // load_config_model. + bundle.prints.get_edited_preset().config.set_key_value("wiping_volumes_extruders", new ConfigOptionFloats({ 10., 20. })); + bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1; + + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "wiping_volumes_extruders", "layer_height" }; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The load completes; the type-mismatched key is reported as skipped and the receiver's + // value is untouched; the matching scalar key still applies. + CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders")); + check_double_vector(bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values, { 10., 20. }); + CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 0.000001)); + CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height")); +} + +// A receiver filament preset missing its material identity (hand-edited file) must not crash +// the type gate: the gate reads it as a type mismatch, and the slot falls back to the +// "no replacement" path (keys skipped, colour still applied to the slot's preset). +TEST_CASE("Published 3MF survives a receiver preset missing its material identity", "[Preset][Bundle][Published]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75 }; + config.opt("filament_self_index")->values = { 1 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt("filament_type")->values = { "PLA" }; + config.opt("filament_vendor")->values = { "Generic" }; + config.opt("filament_ids")->values = { "GFL99" }; + config.option("filament_retraction_length", true)->values = { 0.9 }; + Preset::normalize(config); + + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + // Malformed receiver preset: the identity options are missing entirely. + pla.config.erase("filament_type"); + pla.config.erase("filament_vendor"); + pla.config.opt("filament_colour", true)->values = { "#123456" }; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + bundle.filament_presets = { "My PLA" }; + + PublishedMaterialEntry entry; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.filament_id = "GFL99"; + entry.slot = 0; + entry.publish_type = true; // exercises the type gate against the missing identity + // Require ABS: the receiver library (PLA-typed default preset, typeless slot preset) has no + // ABS candidate, so the gate falls into the "no replacement" path (a PLA requirement would + // legitimately replace the slot with the visible PLA default). + entry.publish_type_value = "ABS"; + entry.publish_color = true; + entry.color = "#ABCDEF"; + entry.keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The gate reads the missing identity as a type mismatch; no same-type replacement exists, + // so the keys are skipped while the colour applies to the slot's preset in place. No crash. + CHECK(contains_key(pub.skipped_keys, "material:GFL99 (filament_retraction_length)")); + CHECK(bundle.filament_presets[0] == "My PLA"); + Preset *mine_preset = bundle.filaments.find_preset("My PLA", false, true); + REQUIRE(mine_preset != nullptr); + CHECK(mine_preset->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); +} + +// Lock the exact contents and order of the printer allowlist (the union of the tab's +// "Retraction" and "Z-Hop" optgroup lists, Tab.cpp). +TEST_CASE("Printer publishable allowlist matches the printer tab's Retraction and Z-Hop optgroups", "[Preset][Bundle][Published]") +{ + auto keys_of = [](const std::vector& opts) { + std::vector keys; + keys.reserve(opts.size()); + for (const PublishablePrinterOption& opt : opts) + keys.emplace_back(opt.key); + return keys; + }; + + const std::vector expected_retraction = { + "retraction_length", "retract_restart_extra", "retraction_speed", "deretraction_speed", + "retraction_minimum_travel", "retract_when_changing_layer", "wipe", "wipe_distance", + "retract_before_wipe", "retract_after_wipe" + }; + const std::vector expected_z_hop = { + "retract_lift_enforce", "z_hop_types", "z_hop", "travel_slope", "retract_lift_above", + "retract_lift_below" + }; + + CHECK(keys_of(publishable_printer_retraction_options()) == expected_retraction); + CHECK(keys_of(publishable_printer_z_hop_options()) == expected_z_hop); + + std::set expected_union(expected_retraction.begin(), expected_retraction.end()); + expected_union.insert(expected_z_hop.begin(), expected_z_hop.end()); + CHECK(publishable_printer_keys() == expected_union); +} + +// Loading the same published file twice must not compound values on the receiver's presets: +// each load re-applies the same absolute values, so the result is idempotent. +TEST_CASE("Published 3MF reloading does not compound values on the receiver's presets", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75 }; + config.opt("filament_self_index")->values = { 1 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt("filament_type")->values = { "PLA" }; + config.opt("filament_vendor")->values = { "Generic" }; + config.opt("filament_ids")->values = { "GFL99" }; + config.option("filament_retraction_length", true)->values = { 0.9 }; + return config; + }; + auto make_entry = [] { + PublishedMaterialEntry entry; + entry.slot = 0; + entry.publish_color = true; + entry.color = "#ABCDEF"; + entry.keys = { "filament_retraction_length" }; + return entry; + }; + + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + bundle.filament_presets = { "My PLA" }; + + auto load = [&] { + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_entry() }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + }; + load(); + // First load: the receiver's preset carries the published values (mutated in place). + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + load(); + // Each load re-applies the same values onto the (already mutated) preset: no accumulation. + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.9 }); +} + +// A receiver with several slots aliasing the same preset (multi-extruder profile with one +// filament) and an author publishing keys on several slots: each published slot is re-pointed +// at its own distinct preset so values never leak between slots. +TEST_CASE("Published 3MF gives each published slot its own preset on an aliased receiver", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3, 4 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" }; + config.opt("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" }; + config.option("filament_retraction_length", true)->values = { 0.6, 0.9, 1.2, 1.5 }; + return config; + }; + auto make_key_entry = [](int slot) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.keys = { "filament_retraction_length" }; + return entry; + }; + + // A 4-extruder receiver with a single filament preset: the slots alias [A, A, A, A] before + // the published pass. + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + mine.config.opt("filament_retraction_length", true)->values = { 0.5 }; + // Spare library presets for the re-pointing to fall back on. + for (const char *name : { "Extra PLA A", "Extra PLA B", "Extra PLA C" }) { + Preset &extra = add_inmemory_preset(bundle.filaments, name); + extra.config.opt_string("filament_type", 0u) = "PLA"; + extra.config.opt("filament_retraction_length", true)->values = { 0.5 }; + } + bundle.filament_presets = { "My PLA", "My PLA", "My PLA", "My PLA" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_key_entry(0), make_key_entry(1), make_key_entry(2), make_key_entry(3) }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 4); + // Every published slot references its own distinct preset: slot 0 keeps the receiver's + // material, slots 1-3 are re-pointed at the spare library presets. + CHECK(bundle.filament_presets[0] == "My PLA"); + CHECK(bundle.filament_presets[1] != bundle.filament_presets[0]); + CHECK(bundle.filament_presets[2] != bundle.filament_presets[0]); + CHECK(bundle.filament_presets[2] != bundle.filament_presets[1]); + CHECK(bundle.filament_presets[3] != bundle.filament_presets[0]); + CHECK(bundle.filament_presets[3] != bundle.filament_presets[1]); + CHECK(bundle.filament_presets[3] != bundle.filament_presets[2]); + // Each slot's stored preset carries its own slot's retraction (mutated in place). + const std::vector expected = { 0.6, 0.9, 1.2, 1.5 }; + for (size_t slot = 0; slot < 4; ++slot) { + Preset *preset = bundle.filaments.find_preset(bundle.filament_presets[slot], false, true); + REQUIRE(preset != nullptr); + check_double_vector(preset->config.opt("filament_retraction_length")->values, { expected[slot] }); + } + CHECK(pub.skipped_keys.empty()); +} + +// De-aliasing runs on the exported identity even without a checked Type row: the re-pointed +// slot lands on the exact published material (by filament_id) rather than an arbitrary spare, +// and the formerly silent re-point is surfaced through the replacements notification list. +TEST_CASE("Published 3MF de-aliases an aliased slot by published identity without a type requirement", "[Preset][Bundle][Published]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + config.opt("filament_type")->values = { "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99" }; + config.option("filament_retraction_length", true)->values = { 0.6, 0.9 }; + + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + mine.config.opt("filament_retraction_length", true)->values = { 0.5 }; + // A spare sorting before the exact match: an unconstrained pick would take it. + Preset &spare = add_inmemory_preset(bundle.filaments, "Aaa PLA"); + spare.config.opt_string("filament_type", 0u) = "PLA"; + spare.config.opt("filament_retraction_length", true)->values = { 0.5 }; + Preset &match = add_inmemory_preset(bundle.filaments, "Zzz PLA"); + match.config.opt_string("filament_type", 0u) = "PLA"; + match.config.opt("filament_retraction_length", true)->values = { 0.5 }; + match.filament_id = "GFA00"; + bundle.filament_presets = { "My PLA", "My PLA" }; + + PublishedMaterialEntry entry; + entry.slot = 1; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.filament_id = "GFA00"; + entry.keys = { "filament_retraction_length" }; + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 2); + CHECK(bundle.filament_presets[0] == "My PLA"); + CHECK(bundle.filament_presets[1] == "Zzz PLA"); + // The published key was written onto the re-pointed slot's own preset. + Preset *target = bundle.filaments.find_preset("Zzz PLA", false, true); + REQUIRE(target != nullptr); + check_double_vector(target->config.opt("filament_retraction_length")->values, { 0.9 }); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 1: My PLA -> Zzz PLA"); + CHECK(pub.skipped_keys.empty()); +} + +// Printer retraction keys are published per-extruder ("#N"): a receiver with a different +// extruder count still receives the in-range elements; out-of-range variants are reported as +// skipped instead of corrupting the receiver's vector. +TEST_CASE("Published 3MF applies per-extruder printer keys across extruder-count mismatches", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + // Author has 4 extruders. + config.opt("retraction_length")->values = { 0.6, 0.9, 1.2, 1.5 }; + Preset::normalize(config); + return config; + }; + + // Receiver with a single extruder: only "#0" is in range; "#1..#3" are skipped. + { + PresetBundle bundle; + bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8 }; + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "retraction_length#0", "retraction_length#1", "retraction_length#2", "retraction_length#3" }; + DynamicPrintConfig config = make_file_config(); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.6 }); + CHECK(contains_key(pub.skipped_keys, "retraction_length#1")); + CHECK(contains_key(pub.skipped_keys, "retraction_length#2")); + CHECK(contains_key(pub.skipped_keys, "retraction_length#3")); + CHECK_FALSE(contains_key(pub.skipped_keys, "retraction_length#0")); + } + + // Receiver with four extruders and a 1-extruder author: only "#0" is published; the + // receiver's other extruders keep their own values. + { + PresetBundle bundle; + bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8, 0.8, 0.8, 0.8 }; + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "retraction_length#0" }; + DynamicPrintConfig config = make_file_config(); + // The author's file carries a single-extruder value. + config.opt("retraction_length")->values = { 0.7 }; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.7, 0.8, 0.8, 0.8 }); + CHECK(pub.skipped_keys.empty()); + } +} + +// A published mixed filament serializes its definition (components, ratios, gradient) into the +// receiver's project_config - the project-level parallel arrays, not a filament preset. The +// mix's own blended colour is carried as publish_color so the receiver renders the swatch. +TEST_CASE("Published 3MF applies a mixed filament definition onto the receiver's project config", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + // Three author slots: two physical PLA/PETG plus one virtual mixed slot (index 2) + // blending slots 1 and 2 at 60/40 with a gradient. + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3 }; + config.opt("filament_extruder_variant")->values = { + "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" + }; + config.opt("filament_colour")->values = { "#FF0000", "#0000FF", "#800080" }; + config.opt("filament_type")->values = { "PLA", "PETG", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFT99", "GFL99" }; + // The mixed slot's definition. These keys are project-level arrays in the full config; + // on export they are masked so only the published slot's entry survives. + config.opt("filament_is_mixed")->values = { 0, 0, 1 }; + config.opt("filament_mixed_components")->values = { "", "", "1,2" }; + config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" }; + config.opt("filament_mixed_gradient")->values = { 0, 0, 1 }; + config.opt("filament_mixed_gradient_range")->values = { "", "", "0.9,0.1" }; + config.opt("filament_mixed_gradient_curve")->values = { "", "", "0,0.1|1,0.9" }; + config.opt("filament_mixed_gradient_per_part")->values = { 0, 0, 1 }; + return config; + }; + + // A receiver that already carries the mix slot at index 2 as an actual mixed slot (e.g. a + // two-physical-plus-one-mix project with the same layout): the incoming definition is a + // like-for-like override of the virtual slot and applies in place without relocation. + { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + bundle.filament_presets = { "My PLA", "My PETG", "My PLA" }; + + // Grow the receiver's project arrays to 3 slots first, as set_num_filaments would, + // then mark the third slot as the receiver's own mixed filament. + bundle.set_num_filaments(3); + bundle.project_config.opt("filament_is_mixed")->values[2] = 1; + bundle.project_config.opt("filament_mixed_components")->values[2] = "1,1"; + bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[2] = "0.5,0.5"; + + PublishedMaterialEntry mix; + mix.filament_type = "PLA"; + mix.filament_vendor = "Generic"; + mix.filament_id = "GFL99"; + mix.slot = 2; + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", + "filament_mixed_sublayer_ratios", "filament_mixed_gradient", + "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The definition landed in project_config's parallel arrays at the author slot. + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 3); + CHECK(is_mixed[2]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 3); + CHECK(components[2] == "1,2"); + const auto &ratios = bundle.project_config.opt("filament_mixed_sublayer_ratios")->values; + REQUIRE(ratios.size() == 3); + CHECK(ratios[2] == "0.6,0.4"); + const auto &gradient = bundle.project_config.opt("filament_mixed_gradient")->values; + CHECK(gradient[2]); + const auto &range = bundle.project_config.opt("filament_mixed_gradient_range")->values; + CHECK(range[2] == "0.9,0.1"); + const auto &curve = bundle.project_config.opt("filament_mixed_gradient_curve")->values; + CHECK(curve[2] == "0,0.1|1,0.9"); + const auto &per_part = bundle.project_config.opt("filament_mixed_gradient_per_part")->values; + CHECK(per_part[2]); + // The mix's blended colour crossed into project_config for the swatch. + const auto &colour = bundle.project_config.opt("filament_colour")->values; + REQUIRE(colour.size() == 3); + CHECK(colour[2] == "#800080"); + // The other slots were not overwritten by the mask. + CHECK_FALSE(is_mixed[0]); + CHECK_FALSE(is_mixed[1]); + // Nothing skipped: every serialized mixed key was applied. + CHECK(pub.skipped_keys.empty()); + // Like-for-like override: no slot was relocated. + CHECK(pub.material_replacements.empty()); + } + + // A receiver with fewer slots: the slot is grown and seeded before the definition applies. + { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA" }; + + PublishedMaterialEntry mix; + mix.slot = 2; + mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 3); + CHECK(is_mixed[2]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 3); + CHECK(components[2] == "1,2"); + CHECK(pub.skipped_keys.empty()); + } +} + +// A published mixed filament whose definition cannot be applied is reported as skipped instead +// of aborting the load: the entry lists a mixed key that the file's payload does not carry. +TEST_CASE("Published 3MF reports an unappliable mixed filament definition as skipped", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA" }; + + // Two author slots (so slot 1 is in range) but the payload omits the mixed arrays: the + // entry lists them, the file config does not. + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + config.opt("filament_type")->values = { "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + + PublishedMaterialEntry mix; + mix.slot = 1; + mix.keys = { "filament_mixed_components", "filament_mixed_sublayer_ratios" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The receiver grows to two slots; the missing payload keys are reported as skipped rather + // than dropped silently (material_label is empty for this entry). + REQUIRE(bundle.filament_presets.size() == 2); + CHECK(contains_key(pub.skipped_keys, "material: (filament_mixed_components)")); + CHECK(contains_key(pub.skipped_keys, "material: (filament_mixed_sublayer_ratios)")); +} + +// A published mixed filament must never convert one of the receiver's real, physical slots +// into a virtual mix: definitions that collide with a physical slot are relocated past every +// positional (real-filament) destination, while ones colliding with an existing mixed slot +// override it in place. +TEST_CASE("Published 3MF relocates a mixed filament instead of overwriting a physical slot", "[Preset][Bundle][Published]") +{ + // An author project with slots whose last slot is a mixed filament. + auto make_file_config = [](size_t num_author_slots, size_t num_tail_mixes = 1) { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + std::vector diameters(num_author_slots, 1.75); + std::vector self_index; + std::vector variants; + std::vector types; + for (size_t i = 0; i < num_author_slots; ++i) { + self_index.push_back(int(i + 1)); + variants.emplace_back("Direct Drive Standard"); + types.push_back(i % 2 == 0 ? "PLA" : "PETG"); + } + config.opt("filament_diameter")->values = diameters; + config.opt("filament_self_index")->values = self_index; + config.opt("filament_extruder_variant")->values = variants; + config.opt("filament_colour")->values = { "#FF0000", "#00AA00", "#0000FF", "#FFFF00", "#800080" }; + config.opt("filament_colour")->values.resize(num_author_slots, "#808080"); + config.opt("filament_type")->values = types; + config.opt("filament_vendor")->values.assign(num_author_slots, "Generic"); + config.opt("filament_ids")->values.resize(num_author_slots); + // The last author slots are mixed ones (components differ per slot so + // the definitions are distinguishable after relocation). + const size_t first_mix_slot = num_author_slots - num_tail_mixes; + config.opt("filament_is_mixed")->values.assign(num_author_slots, 0); + config.opt("filament_mixed_components")->values.assign(num_author_slots, ""); + config.opt("filament_mixed_sublayer_ratios")->values.assign(num_author_slots, ""); + for (size_t i = first_mix_slot; i < num_author_slots; ++i) { + config.opt("filament_is_mixed")->values[i] = 1; + config.opt("filament_mixed_components")->values[i] = + i % 2 == 0 ? std::string("1,2") : std::string("1,3"); + config.opt("filament_mixed_sublayer_ratios")->values[i] = + i % 2 == 0 ? std::string("0.6,0.4") : std::string("0.3,0.7"); + } + return config; + }; + + // The reported bug: an author publishes with physical filaments on slots 1-2 and a mixed + // filament on slot 5; the receiver runs five real filaments of his own. Slot 5 must stay + // untouched and the mix lands as a newly appended virtual slot 6. + { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA", "My PLA", "My PLA", "My PLA", "My PLA" }; + bundle.set_num_filaments(5, "#123456"); + const std::vector receiver_colours = + bundle.project_config.opt("filament_colour")->values; + + PublishedMaterialEntry mix; + mix.filament_type = "PLA"; + mix.filament_vendor = "Generic"; + mix.slot = 4; + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", + "filament_mixed_sublayer_ratios", "filament_mixed_gradient", + "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + DynamicPrintConfig config = make_file_config(5); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The receiver grew by exactly one extra virtual slot. + REQUIRE(bundle.filament_presets.size() == 6); + // All five physical slots kept their meaning: no mixed flag, untouched names/colours. + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 6); + CHECK_FALSE(is_mixed[0]); + CHECK_FALSE(is_mixed[1]); + CHECK_FALSE(is_mixed[2]); + CHECK_FALSE(is_mixed[3]); + CHECK_FALSE(is_mixed[4]); + CHECK(is_mixed[5]); + CHECK(std::equal(receiver_colours.begin(), receiver_colours.end(), + bundle.project_config.opt("filament_colour")->values.begin())); + CHECK(bundle.filament_presets[0] == "My PLA"); + CHECK(bundle.filament_presets[4] == "My PLA"); + // The definition itself is readable at the new index. + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 6); + CHECK(components[5] == "1,2"); + const auto &ratios = bundle.project_config.opt("filament_mixed_sublayer_ratios")->values; + REQUIRE(ratios.size() == 6); + CHECK(ratios[5] == "0.6,0.4"); + // The blended colour seeds the swatch of the new slot only. + const auto &colour = bundle.project_config.opt("filament_colour")->values; + REQUIRE(colour.size() == 6); + CHECK(colour[5] == "#800080"); + // The relocation is surfaced to the user through the post-import notice (the de-alias + // pass may contribute further messages, so presence is asserted, not the count). + bool relocated_reported = false; + for (const std::string &message : pub.material_replacements) + if (message.find("slot 4 -> slot 5") != std::string::npos) + relocated_reported = true; + CHECK(relocated_reported); + CHECK(pub.skipped_keys.empty()); + } + + // A definition colliding with the receiver's own mixed filament is overridden in place: + // nothing grows, nothing is reported as moved. + { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA", "My PLA", "My PLA" }; + bundle.set_num_filaments(3); + bundle.project_config.opt("filament_is_mixed")->values[2] = 1; + bundle.project_config.opt("filament_mixed_components")->values[2] = "1,1"; + bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[2] = "0.9,0.1"; + + PublishedMaterialEntry mix; + mix.slot = 2; + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", + "filament_mixed_sublayer_ratios" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + DynamicPrintConfig config = make_file_config(3); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + CHECK(bundle.filament_presets.size() == 3); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 3); + CHECK_FALSE(is_mixed[0]); + CHECK_FALSE(is_mixed[1]); + CHECK(is_mixed[2]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 3); + CHECK(components[2] == "1,2"); + const auto &ratios = bundle.project_config.opt("filament_mixed_sublayer_ratios")->values; + REQUIRE(ratios.size() == 3); + CHECK(ratios[2] == "0.6,0.4"); + CHECK(pub.skipped_keys.empty()); + CHECK(pub.material_replacements.empty()); + } + + // Author publishes four physical filaments plus two mixed ones on slots 5 and 6; the + // receiver runs five real filaments. Both mixes relocate onto consecutive fresh slots, + // preserving their author order (slot 5 -> slot 6, slot 6 -> slot 7); no receiver slot is + // converted into a virtual mix. + { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA", "My PLA", "My PLA", "My PLA", "My PLA" }; + bundle.set_num_filaments(5, "#123456"); + const std::vector receiver_colours = + bundle.project_config.opt("filament_colour")->values; + + auto make_mix_entry = [](int authored_slot, const char *color) { + PublishedMaterialEntry entry; + entry.slot = authored_slot; + entry.publish_color = true; + entry.color = color; + entry.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" }; + return entry; + }; + PublishedMaterialEntry mix_a = make_mix_entry(4, "#800080"); + PublishedMaterialEntry mix_b = make_mix_entry(5, "#FF69B4"); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix_a, mix_b }; + DynamicPrintConfig config = make_file_config(6, 2); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // Two fresh virtual slots were appended. + REQUIRE(bundle.filament_presets.size() == 7); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 7); + for (size_t i = 0; i < 5; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(is_mixed[5]); + CHECK(is_mixed[6]); + // The definitions follow their author order. + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 7); + CHECK(components[5] == "1,2"); + CHECK(components[6] == "1,3"); + const auto &ratios = bundle.project_config.opt("filament_mixed_sublayer_ratios")->values; + REQUIRE(ratios.size() == 7); + CHECK(ratios[5] == "0.6,0.4"); + CHECK(ratios[6] == "0.3,0.7"); + // The five real slots kept their colours; each mix's blended colour seeded its new slot. + const auto &colour = bundle.project_config.opt("filament_colour")->values; + REQUIRE(colour.size() == 7); + CHECK(std::equal(receiver_colours.begin(), receiver_colours.end(), colour.begin())); + CHECK(colour[5] == "#800080"); + CHECK(colour[6] == "#FF69B4"); + // Both relocations are reported with the correct mapping. + bool a_reported = false, b_reported = false; + for (const std::string &message : pub.material_replacements) { + if (message.find("slot 4 -> slot 5") != std::string::npos) + a_reported = true; + if (message.find("slot 5 -> slot 6") != std::string::npos) + b_reported = true; + } + CHECK(a_reported); + CHECK(b_reported); + CHECK(pub.skipped_keys.empty()); + // The relocation table is exposed for the model-reference remapping. + REQUIRE(pub.mixed_slot_relocations.size() == 2); + CHECK(pub.mixed_slot_relocations.at(4) == 5); + CHECK(pub.mixed_slot_relocations.at(5) == 6); + } +} + +// A receiver that already owns a MIXED filament must keep the physical-first invariant after a +// published-3MF import: when incoming physical filaments would land on (or ahead of) the +// receiver's mixed slot, that mix is displaced to a fresh tail slot instead of being left +// interleaved with them (the R,M,R bug). +TEST_CASE("Published 3MF relocates the receiver's mixed filament past the incoming physical slots", "[Preset][Bundle][Published]") +{ + // Build the receiver's tool-changer with three slots, the third being the receiver's own + // mixed filament. A SEMM (single_extruder_multi_material) receiver sizes its slot list by + // hand, so a lower slot count than the printer's nozzle count is preserved on load - a + // non-SEMM tool-changer would top the preset list up to the nozzle count and shift the + // expected sizes (the rebalance logic under test is the same either way). + auto make_receiver = [](PresetBundle &bundle, const std::string &components, const std::string &ratios) { + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA", "My PLA", "My PLA" }; + bundle.set_num_filaments(3, "#123456"); + bundle.printers.get_edited_preset().config.opt("single_extruder_multi_material", true)->value = true; + bundle.project_config.opt("filament_is_mixed")->values[2] = 1; + bundle.project_config.opt("filament_mixed_components")->values[2] = components; + bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[2] = ratios; + bundle.project_config.opt("filament_colour")->values[2] = "#800080"; + bundle.project_config.opt("filament_multi_colour")->values[2] = "#800080"; + }; + auto make_real_entry = [](int slot, const char *color) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.publish_color = true; + entry.color = color; + return entry; + }; + // A four-physical author project with no mixed slots (colour publish only), as in the + // reported Ferrari reference file. + auto make_config_4_real = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3, 4 }; + config.opt("filament_extruder_variant")->values = { + "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" + }; + config.opt("filament_colour")->values = { "#FF0000", "#000000", "#FFFFFF", "#FFFF00" }; + config.opt("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" }; + return config; + }; + + // [R, R, M] + four colour-only physical slots at authored 0..3 -> [R, R, R, R, M]. + { + PresetBundle bundle; + make_receiver(bundle, "1,2", "0.5,0.5"); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_real_entry(0, "#FF0000"), make_real_entry(1, "#000000"), + make_real_entry(2, "#FFFFFF"), make_real_entry(3, "#FFFF00") }; + DynamicPrintConfig config = make_config_4_real(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The receiver grows by one extra virtual slot; the mix lands at the tail. + REQUIRE(bundle.filament_presets.size() == 5); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 5); + for (size_t i = 0; i < 4; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(is_mixed[4]); + // The definition travelled with its swatch colour; the vacated slot 2 became physical. + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 5); + CHECK(components[4] == "1,2"); + CHECK(components[2].empty()); + const auto &colour = bundle.project_config.opt("filament_colour")->values; + REQUIRE(colour.size() == 5); + CHECK(colour[4] == "#800080"); + CHECK(colour[2] == "#FFFFFF"); + CHECK(pub.mixed_slot_relocations.at(2) == 4); + bool relocated_reported = false; + for (const std::string &message : pub.material_replacements) + if (message.find("slot 2 -> slot 4") != std::string::npos && + message.find("mixed filament") != std::string::npos) + relocated_reported = true; + CHECK(relocated_reported); + CHECK(pub.skipped_keys.empty()); + } + + // [R, R, M] plus a payload mix authored at slot 3: the receiver mix (displaced to slot 3) sits + // ahead of the appended payload mix (slot 4), preserving physical-first tail ordering. + { + PresetBundle bundle; + make_receiver(bundle, "1,2", "0.5,0.5"); + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3, 4 }; + config.opt("filament_extruder_variant")->values = { + "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" + }; + config.opt("filament_colour")->values = { "#FF0000", "#000000", "#0000FF", "#800080" }; + config.opt("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" }; + // Authored slot 3 is a payload mixed definition blending slots 1 and 3. + config.opt("filament_is_mixed")->values = { 0, 0, 0, 1 }; + config.opt("filament_mixed_components")->values = { "", "", "", "1,3" }; + config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "", "0.6,0.4" }; + config.opt("filament_mixed_gradient")->values = { 0, 0, 0, 1 }; + config.opt("filament_mixed_gradient_range")->values = { "", "", "", "0.9,0.1" }; + config.opt("filament_mixed_gradient_curve")->values = { "", "", "", "0,0.1|1,0.9" }; + config.opt("filament_mixed_gradient_per_part")->values = { 0, 0, 0, 1 }; + + PublishedMaterialEntry mix; + mix.slot = 3; + mix.filament_type = "PLA"; + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios", + "filament_mixed_gradient", "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_real_entry(0, "#FF0000"), make_real_entry(1, "#000000"), + make_real_entry(2, "#0000FF"), mix }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 5); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 5); + CHECK_FALSE(is_mixed[0]); + CHECK_FALSE(is_mixed[1]); + CHECK_FALSE(is_mixed[2]); + CHECK(is_mixed[3]); // receiver's mix, displaced to slot 3 first + CHECK(is_mixed[4]); // payload's mix, appended after + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 5); + CHECK(components[3] == "1,2"); + CHECK(components[4] == "1,3"); + REQUIRE(pub.mixed_slot_relocations.size() == 2); + CHECK(pub.mixed_slot_relocations.at(2) == 3); + CHECK(pub.mixed_slot_relocations.at(3) == 4); + CHECK(pub.skipped_keys.empty()); + } +} + +// Multiple receiver mixed slots interleaved with multiple incoming physical slots all rebalance +// onto consecutive tail slots in index order (no cascade/overlap). +TEST_CASE("Published 3MF rebalances several receiver mixed slots past the physical region", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA", "My PLA", "My PLA" }; + bundle.set_num_filaments(3, "#123456"); + bundle.printers.get_edited_preset().config.opt("single_extruder_multi_material", true)->value = true; + // Receiver: slot 1 and slot 2 are mixed. + bundle.project_config.opt("filament_is_mixed")->values[1] = 1; + bundle.project_config.opt("filament_is_mixed")->values[2] = 1; + bundle.project_config.opt("filament_mixed_components")->values[1] = "1,2"; + bundle.project_config.opt("filament_mixed_components")->values[2] = "1,3"; + bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[1] = "0.5,0.5"; + bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[2] = "0.4,0.6"; + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3 }; + config.opt("filament_extruder_variant")->values = { + "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" + }; + config.opt("filament_colour")->values = { "#FF0000", "#00AA00", "#0000FF" }; + config.opt("filament_type")->values = { "PLA", "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99" }; + + auto make_real_entry = [](int slot, const char *color) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.publish_color = true; + entry.color = color; + return entry; + }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_real_entry(0, "#FF0000"), make_real_entry(1, "#00AA00"), make_real_entry(2, "#0000FF") }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 5); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 5); + for (size_t i = 0; i < 3; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(is_mixed[3]); + CHECK(is_mixed[4]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 5); + CHECK(components[3] == "1,2"); + CHECK(components[4] == "1,3"); + REQUIRE(pub.mixed_slot_relocations.size() == 2); + CHECK(pub.mixed_slot_relocations.at(1) == 3); + CHECK(pub.mixed_slot_relocations.at(2) == 4); + CHECK(pub.skipped_keys.empty()); +} + +// The receiver's printer gates how many PHYSICAL filament slots a published 3MF may add: a +// non-SEMM tool-changer feeds filament N from nozzle N, so a published slot past the nozzle +// count cannot become a physical filament. It becomes an empty mixed-filament placeholder +// instead - a virtual tail slot the GUI flags (broken mix) and the user fills with components +// from their own filaments. SEMM receivers keep the ungated behaviour. +TEST_CASE("Published 3MF turns a surplus slot past the printer's filament capacity into an empty mixed placeholder", "[Preset][Bundle][Published]") +{ + // An author project with physical slots, no mixed ones. + auto make_file_config = [](size_t num_author_slots) { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + std::vector diameters(num_author_slots, 1.75); + std::vector self_index; + std::vector variants; + for (size_t i = 0; i < num_author_slots; ++i) { + self_index.push_back(int(i + 1)); + variants.emplace_back("Direct Drive Standard"); + } + config.opt("filament_diameter")->values = diameters; + config.opt("filament_self_index")->values = self_index; + config.opt("filament_extruder_variant")->values = variants; + config.opt("filament_colour")->values.resize(num_author_slots, "#808080"); + config.opt("filament_type")->values.assign(num_author_slots, "PLA"); + config.opt("filament_vendor")->values.assign(num_author_slots, "Generic"); + config.opt("filament_ids")->values.resize(num_author_slots); + return config; + }; + // A non-SEMM receiver with nozzles running copies of one preset. + auto make_receiver = [](PresetBundle &bundle, size_t nozzles, size_t slots) { + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets.assign(slots, "My PLA"); + bundle.set_num_filaments(slots, "#123456"); + auto &printer_config = bundle.printers.get_edited_preset().config; + printer_config.opt("single_extruder_multi_material", true)->value = false; + printer_config.opt("nozzle_diameter", true)->values.assign(nozzles, 0.4); + }; + auto make_physical_entry = [](int slot, const char *color) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.publish_color = true; + entry.color = color; + return entry; + }; + + // The reported case: an author publishes with a filament on slot 5; the receiver is a + // 4-filament tool-changer. The receiver keeps its four physical slots and the surplus + // material lands as an empty mixed placeholder at the tail. + { + PresetBundle bundle; + make_receiver(bundle, 4, 4); + const std::vector receiver_colours = + bundle.project_config.opt("filament_colour")->values; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_physical_entry(4, "#ABCDEF") }; + DynamicPrintConfig config = make_file_config(5); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The receiver grew by exactly one virtual slot, not a fifth physical one. + REQUIRE(bundle.filament_presets.size() == 5); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 5); + for (size_t i = 0; i < 4; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(is_mixed[4]); + // The placeholder carries no definition: the GUI's integrity check flags it and + // blocks slicing until the user assigns components. + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 5); + CHECK(components[4].empty()); + // The four physical slots kept their meaning and colours. + CHECK(std::equal(receiver_colours.begin(), receiver_colours.end(), + bundle.project_config.opt("filament_colour")->values.begin())); + CHECK(bundle.filament_presets[0] == "My PLA"); + CHECK(bundle.filament_presets[3] == "My PLA"); + // The published colour seeds the placeholder's swatch. + CHECK(bundle.project_config.opt("filament_colour")->values[4] == "#ABCDEF"); + // The conversion is surfaced through the post-import notice. + bool placeholder_reported = false; + for (const std::string &message : pub.material_replacements) + if (message.find("unassigned mixed filament") != std::string::npos) + placeholder_reported = true; + CHECK(placeholder_reported); + CHECK(pub.skipped_keys.empty()); + CHECK(pub.mixed_slot_relocations.empty()); + } + + // Two surplus slots (5 and 6) become two consecutive empty placeholders. + { + PresetBundle bundle; + make_receiver(bundle, 4, 4); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_physical_entry(4, "#ABCDEF"), make_physical_entry(5, "#F0F0F0") }; + DynamicPrintConfig config = make_file_config(6); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 6); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 6); + for (size_t i = 0; i < 4; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(is_mixed[4]); + CHECK(is_mixed[5]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 6); + CHECK(components[4].empty()); + CHECK(components[5].empty()); + const auto &colour = bundle.project_config.opt("filament_colour")->values; + REQUIRE(colour.size() == 6); + CHECK(colour[4] == "#ABCDEF"); + CHECK(colour[5] == "#F0F0F0"); + CHECK(pub.skipped_keys.empty()); + CHECK(pub.mixed_slot_relocations.empty()); + } + + // A surplus slot past both the receiver's list and the capacity packs onto the next free + // tail slot (never max(authored, next_free), which would grow filler physical slots past + // the capacity), and the relocation is recorded for the model-reference remapping. + { + PresetBundle bundle; + make_receiver(bundle, 2, 2); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_physical_entry(3, "#ABCDEF") }; + DynamicPrintConfig config = make_file_config(4); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 3); + CHECK_FALSE(is_mixed[0]); + CHECK_FALSE(is_mixed[1]); + CHECK(is_mixed[2]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 3); + CHECK(components[2].empty()); + REQUIRE(pub.mixed_slot_relocations.size() == 1); + CHECK(pub.mixed_slot_relocations.at(3) == 2); + bool relocation_reported = false; + for (const std::string &message : pub.material_replacements) + if (message.find("slot 3 -> slot 2") != std::string::npos && + message.find("unassigned mixed filament") != std::string::npos) + relocation_reported = true; + CHECK(relocation_reported); + CHECK(pub.skipped_keys.empty()); + } + + // A Full Publish entry past the capacity becomes a placeholder too: no standalone + // detached copy is created for a material that got no physical slot. + { + PresetBundle bundle; + make_receiver(bundle, 4, 4); + + PublishedMaterialEntry entry = make_physical_entry(4, "#ABCDEF"); + entry.full = true; + entry.preset_name = "Generic PLA @System"; + entry.filament_id = "GFL99"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(5); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 5); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 5); + CHECK(is_mixed[4]); + // No detached copy under the stripped name or its uniquified forms. + CHECK(bundle.filaments.find_preset("Generic PLA", false, true) == nullptr); + CHECK(bundle.filaments.find_preset("Generic PLA (Published)", false, true) == nullptr); + CHECK(pub.skipped_keys.empty()); + } + + // A SEMM receiver (the default printer preset) sizes its slot list by hand: the published + // slot past the nozzle count still grows physically, as before the capacity gate. + { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA", "My PLA", "My PLA", "My PLA" }; + bundle.set_num_filaments(4, "#123456"); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_physical_entry(4, "#ABCDEF") }; + DynamicPrintConfig config = make_file_config(5); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 5); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 5); + for (size_t i = 0; i < 5; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(bundle.project_config.opt("filament_colour")->values[4] == "#ABCDEF"); + CHECK(pub.skipped_keys.empty()); + } + + // A pre-existing oversized slot list is never shrunk: a published entry pointing at one + // of its slots is applied positionally even though the list exceeds the nozzle count. + { + PresetBundle bundle; + make_receiver(bundle, 4, 5); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_physical_entry(4, "#ABCDEF") }; + DynamicPrintConfig config = make_file_config(5); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 5); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 5); + for (size_t i = 0; i < 5; ++i) + CHECK_FALSE(is_mixed[i]); + // The published colour reached the addressed slot's (shared) preset in place. + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == + std::vector{ "#ABCDEF" }); + CHECK(bundle.project_config.opt("filament_colour")->values[4] == "#ABCDEF"); + CHECK(pub.skipped_keys.empty()); + } + + // On a single-physical-slot receiver an empty mix could never be edited (the sidebar's + // mixed section needs two physical filaments), so the surplus entry is dropped and + // reported instead of becoming an unfixable placeholder. + { + PresetBundle bundle; + make_receiver(bundle, 1, 1); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_physical_entry(1, "#ABCDEF") }; + DynamicPrintConfig config = make_file_config(2); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + CHECK(bundle.filament_presets.size() == 1); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 1); + CHECK_FALSE(is_mixed[0]); + REQUIRE(pub.skipped_keys.size() == 1); + CHECK(pub.skipped_keys.front().find("printer supports only 1") != std::string::npos); + } + + // A payload mixed definition is exempt from the capacity gate: mixes are virtual slots + // that consume no nozzle, so a published mix past the nozzle count still lands. + { + PresetBundle bundle; + make_receiver(bundle, 4, 4); + + PublishedMaterialEntry mix; + mix.slot = 4; + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + DynamicPrintConfig config = make_file_config(5); + config.opt("filament_is_mixed")->values.assign(5, 0); + config.opt("filament_mixed_components")->values.assign(5, ""); + config.opt("filament_mixed_sublayer_ratios")->values.assign(5, ""); + config.opt("filament_is_mixed")->values[4] = 1; + config.opt("filament_mixed_components")->values[4] = "1,2"; + config.opt("filament_mixed_sublayer_ratios")->values[4] = "0.6,0.4"; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 5); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 5); + CHECK(is_mixed[4]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 5); + CHECK(components[4] == "1,2"); + const auto &ratios = bundle.project_config.opt("filament_mixed_sublayer_ratios")->values; + REQUIRE(ratios.size() == 5); + CHECK(ratios[4] == "0.6,0.4"); + CHECK(pub.skipped_keys.empty()); + } +} + +// A single-extruder receiver collapses the author's per-extruder printer slots onto its single +// slot: the first serialized variant of a base key is applied, the remaining variants of that +// base key are reported as skipped. +TEST_CASE("Published 3MF collapses a multi-extruder publish onto a single-extruder receiver", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + // Author has two extruders. + config.opt("retraction_length")->values = { 0.6, 0.9 }; + config.opt("retraction_speed")->values = { 30.0, 40.0 }; + Preset::normalize(config); + return config; + }; + + // Both extruders published: the first serialized variant (#0, left) lands on the receiver's + // single slot; the second variant (#1) is reported as skipped. + { + PresetBundle bundle; + bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8 }; + bundle.printers.get_edited_preset().config.opt("retraction_speed")->values = { 25.0 }; + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "retraction_length#0", "retraction_length#1", "retraction_speed#0", "retraction_speed#1" }; + DynamicPrintConfig config = make_file_config(); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.6 }); + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_speed")->values, { 30.0 }); + CHECK(contains_key(pub.skipped_keys, "retraction_length#1")); + CHECK(contains_key(pub.skipped_keys, "retraction_speed#1")); + CHECK_FALSE(contains_key(pub.skipped_keys, "retraction_length#0")); + CHECK_FALSE(contains_key(pub.skipped_keys, "retraction_speed#0")); + } + + // Only the second extruder published: the single-extruder receiver still applies it (the + // author's "right" is the only serialized slot) and reports nothing skipped. + { + PresetBundle bundle; + bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8 }; + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "retraction_length#1" }; + DynamicPrintConfig config = make_file_config(); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.9 }); + CHECK(pub.skipped_keys.empty()); + } +} + +// A multi-extruder "similar setup" receiver overrides each published extruder slot element-wise +// (no collapsing): each '#N' variant applies to the matching receiver slot, out-of-range ones are +// reported as skipped. +TEST_CASE("Published 3MF overrides each extruder slot on a similar multi-extruder receiver", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + // Author has two extruders. + config.opt("retraction_length")->values = { 0.6, 0.9 }; + Preset::normalize(config); + return config; + }; + + // Receiver with two extruders: both published slots override element-wise. + { + PresetBundle bundle; + bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8, 0.8 }; + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "retraction_length#0", "retraction_length#1" }; + DynamicPrintConfig config = make_file_config(); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.6, 0.9 }); + CHECK(pub.skipped_keys.empty()); + } + + // Receiver with three extruders: slots 0 and 1 override, slot 2 keeps its own value. + { + PresetBundle bundle; + bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8, 0.8, 0.7 }; + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "retraction_length#0", "retraction_length#1" }; + DynamicPrintConfig config = make_file_config(); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.6, 0.9, 0.7 }); + CHECK(pub.skipped_keys.empty()); + } +} + // The nozzle-count top-up in update_multi_material_filament_presets() grows filament_presets on // its own, so a physical count derived from that list reports a slot no per-filament array has // yet. That is what made the extruder-count handler conclude there was nothing to add and leave @@ -1409,6 +4245,868 @@ TEST_CASE("Sizing down to the nozzle count plus mixes is what eats the mixed tai } } +// After a published-3MF import relocated mixed-filament definitions, the freshly loaded +// model's slot references must follow: object/volume "extruder" configs and multi-material +// color-painting states (which store the one-based slot number) are re-pointed to where each +// definition landed; everything else keeps its state. +TEST_CASE("remap_model_filament_slots repoints extruder configs and color painting", "[Preset][Bundle][Published]") +{ + auto make_model = [] { + Model model; + ModelObject *object_a = model.add_object(); + object_a->name = "relocated mix"; + ModelVolume *vol_a = object_a->add_volume(make_cube(10., 10., 10.)); + vol_a->config.set_key_value("extruder", new ConfigOptionInt(5)); // author slot 5 (0-based 4) + // Author painted one facet with the mix (slot 5) and another with a physical (slot 2). + { + TriangleSelector selector(vol_a->mesh()); + selector.set_facet(0, EnforcerBlockerType(5)); + selector.set_facet(1, EnforcerBlockerType(2)); + vol_a->mmu_segmentation_facets.set_data(selector.serialize()); + } + // A second object that does not reference the relocated slot at all. Painted with a + // real, non-relocated state (NONE is never serialized: an unsplit triangle without a + // state is the unpainted default and is skipped by TriangleSelector::serialize()). + ModelObject *object_b = model.add_object(); + object_b->name = "untouched"; + object_b->config.set_key_value("extruder", new ConfigOptionInt(1)); + ModelVolume *vol_b = object_b->add_volume(make_cube(5., 5., 5.)); + vol_b->config.set_key_value("extruder", new ConfigOptionInt(2)); + { + TriangleSelector selector(vol_b->mesh()); + selector.set_facet(0, EnforcerBlockerType(2)); + vol_b->mmu_segmentation_facets.set_data(selector.serialize()); + } + return model; + }; + + const std::map relocations = {{4, 5}}; + + Model model = make_model(); + Slic3r::remap_model_filament_slots(model, relocations); + + const ModelVolume *vol_a = model.objects[0]->volumes.front(); + CHECK(vol_a->config.extruder() == 6); // author slot 5 -> final slot 6 + // Painted states follow: the mix facet moved 5 -> 6, the physical one is untouched. + REQUIRE(TriangleSelector::has_facets(vol_a->mmu_segmentation_facets.get_data(), EnforcerBlockerType(6))); + REQUIRE_FALSE(TriangleSelector::has_facets(vol_a->mmu_segmentation_facets.get_data(), EnforcerBlockerType(5))); + CHECK(TriangleSelector::has_facets(vol_a->mmu_segmentation_facets.get_data(), EnforcerBlockerType(2))); + + const ModelVolume *vol_b = model.objects[1]->volumes.front(); + CHECK(vol_b->config.extruder() == 2); + // The untouched volume's paint (a non-relocated state) survives as-is. + CHECK(TriangleSelector::has_facets(vol_b->mmu_segmentation_facets.get_data(), EnforcerBlockerType(2))); + + // The mapping is applied simultaneously: each entry reads the original slot number, so + // relocating onto another relocated-from slot number must not chase chains. With the + // 0-based relocations {3->4, 4->6} the 1-based config map is {4->5, 5->7}: a volume on + // 1-based slot 4 lands on 5 and does NOT continue to 7. + Model chained = make_model(); + chained.objects[0]->volumes.front()->config.set_key_value("extruder", new ConfigOptionInt(4)); + Slic3r::remap_model_filament_slots(chained, std::map{{3, 4}, {4, 6}}); + CHECK(chained.objects[0]->volumes.front()->config.extruder() == 5); + // The chained model's paint follows its own single-step mapping: painted state 5 -> 7, + // and nothing lands back on 5. + CHECK(TriangleSelector::has_facets(chained.objects[0]->volumes.front()->mmu_segmentation_facets.get_data(), + EnforcerBlockerType(7))); + CHECK_FALSE(TriangleSelector::has_facets(chained.objects[0]->volumes.front()->mmu_segmentation_facets.get_data(), + EnforcerBlockerType(5))); +} + +// The slot ceiling (EnforcerBlockerType::ExtruderMax) is what the color-painting encoding can +// address, so a mixed filament that does not fit must be dropped and reported instead of being +// forced onto one of the receiver's physical filaments. +TEST_CASE("Published 3MF drops a mixed filament that does not fit the slot limit and reports it", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA" }; + // A receiver already at the format's slot ceiling. + bundle.set_num_filaments(unsigned(EnforcerBlockerType::ExtruderMax), "#123456"); + const std::vector receiver_colours = + bundle.project_config.opt("filament_colour")->values; + + PublishedMaterialEntry mix; + mix.filament_type = "PLA"; + mix.filament_vendor = "Generic"; + mix.slot = int(EnforcerBlockerType::ExtruderMax) + 6; // beyond the ceiling + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", + "filament_mixed_sublayer_ratios", "filament_mixed_gradient", + "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // Nothing grew and no slot became a virtual mix. + REQUIRE(bundle.filament_presets.size() == size_t(EnforcerBlockerType::ExtruderMax)); + for (size_t i = 0; i < bundle.filament_presets.size(); ++i) + CHECK_FALSE(bundle.is_mixed_filament(i)); + // The receiver's colours were not touched. + CHECK(bundle.project_config.opt("filament_colour")->values == receiver_colours); + // The mix was reported instead of being applied. + REQUIRE(contains_key(pub.skipped_keys, "material:PLA (mixed filament definition: filament slot limit reached)")); + CHECK(pub.material_replacements.empty()); +} + +// The publish dialog can only produce definitions whose components reference existing physical +// slots, so a payload whose components point at slots that do not exist (or at another mixed +// slot) or that carries fewer than two components is broken. The load reports it through the +// same channel as every other rejected input instead of shipping a mix the GUI integrity check +// would only flag later. +TEST_CASE("Published 3MF rejects a mixed filament definition with impossible components", "[Preset][Bundle][Published]") +{ + // A two-physical-plus-one-mix author file; the definition under test sits on slot 2. + auto make_file_config = [](const std::string &components) { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3 }; + config.opt("filament_extruder_variant")->values = { + "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" + }; + config.opt("filament_colour")->values = { "#FF0000", "#0000FF", "#800080" }; + config.opt("filament_type")->values = { "PLA", "PETG", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic" }; + config.opt("filament_is_mixed")->values = { 0, 0, 1 }; + config.opt("filament_mixed_components")->values = { "", "", components }; + config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" }; + return config; + }; + + PublishedMaterialEntry mix; + mix.filament_type = "PLA"; + mix.filament_vendor = "Generic"; + mix.filament_id = "GFL99"; + mix.slot = 2; + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" }; + + for (const char *components : { "1,4", "1,3", "1" }) { + // The claimed components: "1,4" names a slot past the final count, "1,3" names the mix + // slot itself (1-based), "1" is not enough components to blend. + INFO("components = " << components); + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + bundle.filament_presets = { "My PLA" }; + + mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" }; + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + DynamicPrintConfig config = make_file_config(components); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The definition was rejected, not applied: the grown slot is finalized as an empty + // mixed placeholder instead of keeping the seeded preset and masquerading as a real + // filament. + REQUIRE(bundle.filament_presets.size() == 3); + CHECK(bundle.is_mixed_filament(2)); + CHECK(bundle.project_config.opt("filament_mixed_components")->values[2].empty()); + CHECK(bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[2].empty()); + // Reported through the shared rejection channel. + if (std::string(components) == "1") + CHECK(contains_key(pub.skipped_keys, "material:GFL99 (mixed filament definition: needs at least two components)")); + else + CHECK(contains_key(pub.skipped_keys, "material:GFL99 (mixed filament definition: components reference missing slots)")); + // The slot change was surfaced like the other slot adaptations. + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0].find("slot 2: mixed filament definition could not be imported") != std::string::npos); + // The blended colour was not written into the (shared) slot preset either. + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == + std::vector{ "#123456" }); + } +} + +// The reported scenario: an author publishes two mixed filaments whose components are +// full-published physical slots; the receiver is a smaller tool-changer, so some of those +// component slots become empty mixed placeholders. A mix whose component turned into a +// placeholder can never be valid (mixes cannot reference mixes): it is rejected, and its +// grown slot must be finalized as an empty mixed placeholder too - not keep the seeded +// preset and masquerade as a real filament carrying the mix identity. +TEST_CASE("Published 3MF finalizes a mixed filament rejected over a placeholder component as an empty placeholder", "[Preset][Bundle][Published]") +{ + // An author project: six physical slots plus two tail mixes, the second referencing the + // sixth physical slot (H2C-style: slot 7 = 1+2, slot 8 = 2+6). + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = std::vector(8, 1.75); + config.opt("filament_self_index")->values = { 1, 2, 3, 4, 5, 6, 7, 8 }; + config.opt("filament_extruder_variant")->values = std::vector(8, "Direct Drive Standard"); + config.opt("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00", + "#FF00FF", "#00FFFF", "#800080", "#804000" }; + config.opt("filament_type")->values.assign(8, "PLA"); + config.opt("filament_vendor")->values.assign(8, "Generic"); + config.opt("filament_is_mixed")->values = { 0, 0, 0, 0, 0, 0, 1, 1 }; + config.opt("filament_mixed_components")->values = { "", "", "", "", "", "", "1,2", "2,6" }; + config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "", "", "", "", "0.6,0.4", "0.5,0.5" }; + // The export always serializes all seven masked mixed arrays, not just the ones in + // use; the unused gradient arrays ride along as defaults. + config.opt("filament_mixed_gradient")->values = { 0, 0, 0, 0, 0, 0, 0, 0 }; + config.opt("filament_mixed_gradient_range")->values.assign(8, ""); + config.opt("filament_mixed_gradient_curve")->values.assign(8, ""); + config.opt("filament_mixed_gradient_per_part")->values = { 0, 0, 0, 0, 0, 0, 0, 0 }; + return config; + }; + // A non-SEMM receiver with four nozzles and four slots (tool-changer style). + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets.assign(4, "My PLA"); + bundle.set_num_filaments(4, "#123456"); + auto &printer_config = bundle.printers.get_edited_preset().config; + printer_config.opt("single_extruder_multi_material", true)->value = false; + printer_config.opt("nozzle_diameter", true)->values.assign(4, 0.4); + + auto make_full_entry = [](int slot) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.full = true; + entry.full_keys = { "filament_retraction_length" }; + return entry; + }; + auto make_mix_entry = [](int slot, const char *color) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.publish_color = true; + entry.color = color; + entry.keys = { "filament_is_mixed", "filament_mixed_components", + "filament_mixed_sublayer_ratios", "filament_mixed_gradient", + "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }; + return entry; + }; + + // The dialog's emit order: the full-published physical slots (1, 2, 5, 6) and both mixes. + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_full_entry(0), make_full_entry(1), make_full_entry(4), make_full_entry(5), + make_mix_entry(6, "#800080"), make_mix_entry(7, "#804000") }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The receiver grew to the author's slot count, all virtual territory at the tail. + REQUIRE(bundle.filament_presets.size() == 8); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 8); + // Slots 0-3 stayed physical; authored slots 5 and 6 (0-based 4 and 5) became capacity + // placeholders. + for (size_t i = 0; i < 4; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(is_mixed[4]); + CHECK(is_mixed[5]); + CHECK(bundle.project_config.opt("filament_mixed_components")->values[4].empty()); + CHECK(bundle.project_config.opt("filament_mixed_components")->values[5].empty()); + // The first mix applied onto its uncontended tail slot. + CHECK(is_mixed[6]); + CHECK(bundle.project_config.opt("filament_mixed_components")->values[6] == "1,2"); + // The second mix was rejected - its second component (authored slot 6) turned into a + // placeholder - and its slot was finalized as an empty mixed placeholder instead of + // keeping the seeded preset as a phantom real filament. + CHECK(is_mixed[7]); + CHECK(bundle.project_config.opt("filament_mixed_components")->values[7].empty()); + CHECK(bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[7].empty()); + REQUIRE(pub.skipped_keys.size() == 1); + CHECK(pub.skipped_keys[0] == "material:PLA (mixed filament definition: components reference missing slots)"); + // Two Full Publish detach lines (slots 0-1), two capacity placeholder lines (slots 4-5), + // and the rejected mix's finalization line (slot 7). + REQUIRE(pub.material_replacements.size() == 5); + CHECK(std::any_of(pub.material_replacements.begin(), pub.material_replacements.end(), + [](const std::string &line) { + return line.find("slot 7: mixed filament definition could not be imported") != std::string::npos; + })); +} + +// The receiver must not overflow its physical capacity when it grows slots to reach a published +// mixed definition: an unpublished mixed slot that lands as a gap past the nozzle count becomes +// an empty mixed placeholder, not a physical filament. An author with six physical slots (0-5) +// and two tail mixes (slots 6 and 7) publishes only 0-5 and 7; the receiver has four nozzles. +// Slots 4 and 5 become surplus placeholders, slot 7 keeps its authored mix position, and the +// unpublished gap slot 6 is finalized as a virtual placeholder - never a fifth physical slot. +TEST_CASE("Published 3MF turns an unpublished gap slot past the printer's capacity into an empty mixed placeholder", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets.assign(4, "My PLA"); + bundle.set_num_filaments(4, "#123456"); + auto &printer_config = bundle.printers.get_edited_preset().config; + printer_config.opt("single_extruder_multi_material", true)->value = false; + printer_config.opt("nozzle_diameter", true)->values.assign(4, 0.4); + + // 8 authored slots: 0-5 physical, 6 unpublished mixed, 7 published mixed. The payload masks + // the unpublished slot's mixed flag (filter_published_config), so slot 6 reads as physical. + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = std::vector(8, 1.75); + config.opt("filament_self_index")->values = { 1, 2, 3, 4, 5, 6, 7, 8 }; + config.opt("filament_extruder_variant")->values = std::vector(8, "Direct Drive Standard"); + config.opt("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00", + "#FF00FF", "#00FFFF", "#800080", "#804000" }; + config.opt("filament_type")->values.assign(8, "PLA"); + config.opt("filament_vendor")->values.assign(8, "Generic"); + config.opt("filament_is_mixed")->values = { 0, 0, 0, 0, 0, 0, 0, 1 }; + config.opt("filament_mixed_components")->values = { "", "", "", "", "", "", "", "1,2" }; + config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "", "", "", "", "", "0.5,0.5" }; + config.opt("filament_mixed_gradient")->values = std::vector(8, 0); + config.opt("filament_mixed_gradient_range")->values.assign(8, ""); + config.opt("filament_mixed_gradient_curve")->values.assign(8, ""); + config.opt("filament_mixed_gradient_per_part")->values = std::vector(8, 0); + + auto make_full_entry = [](int slot) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.filament_type = "PLA"; + entry.full = true; + entry.full_keys = { "filament_retraction_length" }; + return entry; + }; + auto make_mix_entry = [](int slot, const char *color) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.filament_type = "PLA"; + entry.publish_color = true; + entry.color = color; + entry.keys = { "filament_is_mixed", "filament_mixed_components", + "filament_mixed_sublayer_ratios", "filament_mixed_gradient", + "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }; + return entry; + }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_full_entry(0), make_full_entry(1), make_full_entry(2), make_full_entry(3), + make_full_entry(4), make_full_entry(5), make_mix_entry(7, "#804000") }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 8); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 8); + // Slots 0-3 stay physical; 4 and 5 are surplus placeholders; 6 is the unpublished gap; 7 is + // the published mix. All four tail slots are virtual. + for (size_t i = 0; i < 4; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(is_mixed[4]); + CHECK(is_mixed[5]); + CHECK(is_mixed[6]); + CHECK(is_mixed[7]); + // Surplus physical slots (4,5) and the unpublished gap (6) carry no definition; the + // published mix on slot 7 keeps its own. + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 8); + CHECK(components[4].empty()); + CHECK(components[5].empty()); + CHECK(components[6].empty()); + CHECK(components[7] == "1,2"); + // Exactly four physical slots remain (never a fifth past the nozzle count). + size_t physical_count = 0; + for (bool mixed : is_mixed) + if (!mixed) + ++physical_count; + CHECK(physical_count == 4); + // The unpublished gap's conversion is surfaced through the post-import notice. + bool gap_reported = false; + for (const std::string &message : pub.material_replacements) + if (message.find("slot 6: unassigned mixed filament") != std::string::npos) + gap_reported = true; + CHECK(gap_reported); + CHECK(pub.skipped_keys.empty()); +} + +// The relocation shifts cells inside the file's per-slot mixed arrays; a payload too short to +// actually carry the definition degrades to empty cells, which the definition validation then +// reports - an empty mix must not ship as a virtual slot. +TEST_CASE("Published 3MF reports a relocated mixed filament whose payload cells are missing", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA", "My PLA", "My PLA", "My PLA", "My PLA" }; + bundle.set_num_filaments(5, "#123456"); + const std::vector receiver_colours = + bundle.project_config.opt("filament_colour")->values; + + PublishedMaterialEntry mix; + mix.filament_type = "PLA"; + mix.filament_vendor = "Generic"; + mix.filament_id = "GFL99"; + mix.slot = 3; // authored slot 3; the receiver's five real slots occupy 0-4 + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", + "filament_mixed_sublayer_ratios", "filament_mixed_gradient", + "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + // The file's mixed arrays only cover its single physical slot: the definition data for + // slot 3 does not exist in the payload. + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75 }; + config.opt("filament_self_index")->values = { 1 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt("filament_type")->values = { "PLA" }; + config.opt("filament_vendor")->values = { "Generic" }; + config.opt("filament_is_mixed")->values = { 0 }; + config.opt("filament_mixed_components")->values = { "" }; + config.opt("filament_mixed_sublayer_ratios")->values = { "" }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The mix was relocated past the physical territory... + REQUIRE(pub.mixed_slot_relocations.size() == 1); + CHECK(pub.mixed_slot_relocations.at(3) == 5); + REQUIRE(pub.material_replacements.size() == 2); + CHECK(pub.material_replacements[0].find("slot 3 -> slot 5") != std::string::npos); + // ...and the receiver grew to hold the destination slot, but the definition itself was + // rejected: the relocated cells degraded to empty defaults, the slot was finalized as an + // empty mixed placeholder (not a real filament), and both facts were reported. + REQUIRE(bundle.filament_presets.size() == 6); + CHECK(bundle.is_mixed_filament(5)); + CHECK(bundle.project_config.opt("filament_mixed_components")->values[5].empty()); + CHECK(bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[5].empty()); + CHECK(contains_key(pub.skipped_keys, "material:GFL99 (mixed filament definition: needs at least two components)")); + CHECK(pub.material_replacements[1].find("slot 5: mixed filament definition could not be imported") != std::string::npos); + // The five real slots kept their colours. + CHECK(std::equal(receiver_colours.begin(), receiver_colours.end(), + bundle.project_config.opt("filament_colour")->values.begin())); +} + +// A grown published slot always repeats the receiver's last preset; it can only move to the +// published material's identity when a replacement is warranted (an aliased slot that would +// otherwise leak keys, or a type mismatch). The tier priority candidate_score uses is locked +// here: exact preset name > bare name > exact setting_id > exact filament_id > vendor+type, +// with the lower tiers reported as a substitute. +TEST_CASE("Published 3MF re-points an aliased grown slot's material by identity tiers", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3, 4 }; + config.opt("filament_extruder_variant")->values = { + "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" + }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" }; + config.opt("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" }; + return config; + }; + + PublishedMaterialEntry entry; + entry.slot = 2; + entry.filament_type = "PLA"; + + SECTION("an exact preset name outranks the bare-name form") + { + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + Preset &bare = add_inmemory_preset(bundle.filaments, "Authored PLA"); + bare.config.opt_string("filament_type", 0u) = "PLA"; + Preset &exact = add_inmemory_preset(bundle.filaments, "Authored PLA @Vendor"); + exact.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA" }; + + entry.preset_name = "Authored PLA @Vendor"; + entry.keys = { "filament_retraction_length" }; + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + CHECK(bundle.filament_presets[1] == "My PLA"); + // The aliased grown slot is re-pointed at the exact-name preset; the bare-name and the + // receiver's own preset lose. + CHECK(bundle.filament_presets[2] == "Authored PLA @Vendor"); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 2: My PLA -> Authored PLA @Vendor"); + } + + SECTION("a bare name outranks an exact setting_id") + { + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + Preset &bare = add_inmemory_preset(bundle.filaments, "Authored PLA"); + bare.config.opt_string("filament_type", 0u) = "PLA"; + Preset &sid = add_inmemory_preset(bundle.filaments, "Bbb PLA"); + sid.config.opt_string("filament_type", 0u) = "PLA"; + sid.setting_id = "SID123"; + bundle.filament_presets = { "My PLA" }; + + entry.preset_name = "Authored PLA @Vendor"; // no library preset carries this name + entry.setting_id = "SID123"; + entry.keys = { "filament_retraction_length" }; + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + CHECK(bundle.filament_presets[1] == "My PLA"); + CHECK(bundle.filament_presets[2] == "Authored PLA"); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 2: My PLA -> Authored PLA"); + } + + SECTION("an exact setting_id outranks an exact filament_id") + { + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + Preset &sid = add_inmemory_preset(bundle.filaments, "Bbb PLA"); + sid.config.opt_string("filament_type", 0u) = "PLA"; + sid.setting_id = "SID123"; + Preset &fid = add_inmemory_preset(bundle.filaments, "Ccc PLA"); + fid.config.opt_string("filament_type", 0u) = "PLA"; + fid.filament_id = "GFA00"; + bundle.filament_presets = { "My PLA" }; + + entry.preset_name = "Authored PLA @Vendor"; // no library preset carries this name + entry.setting_id = "SID123"; + entry.filament_id = "GFA00"; + entry.keys = { "filament_retraction_length" }; + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + CHECK(bundle.filament_presets[1] == "My PLA"); + CHECK(bundle.filament_presets[2] == "Bbb PLA"); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 2: My PLA -> Bbb PLA"); + } + + SECTION("a vendor+type match is reported as a substitute") + { + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PETG"); + mine.config.opt_string("filament_type", 0u) = "PETG"; + Preset &exact_vendor = add_inmemory_preset(bundle.filaments, "Aaa PLA"); + exact_vendor.config.opt_string("filament_type", 0u) = "PLA"; + exact_vendor.config.opt_string("filament_vendor", 0u) = "Generic"; + Preset &other_vendor = add_inmemory_preset(bundle.filaments, "Zzz PLA"); + other_vendor.config.opt_string("filament_type", 0u) = "PLA"; + other_vendor.config.opt_string("filament_vendor", 0u) = "Other"; + bundle.filament_presets = { "My PETG" }; + + entry.filament_vendor = "Generic"; // no name or id identity: the family tiers decide + entry.publish_type = true; + entry.publish_type_value = "PLA"; // the grown slot seeds "My PETG" -> the gate reads a mismatch + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + CHECK(bundle.filament_presets[1] == "My PETG"); + // The same-vendor PLA outranks the type-only candidate... + CHECK(bundle.filament_presets[2] == "Aaa PLA"); + // ...and since it is not an exact material match, the load says so. + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 2: My PETG -> Aaa PLA (substitute)"); + } +} + +// Structural keys (identity links like filament_ids / inherits / printer_settings_id) are +// never applied onto the receiver and never reported: a hand-crafted file listing them must +// not trigger the "could not be applied" warning, while unknown keys still do. +TEST_CASE("Published 3MF silently ignores structural keys in published_keys", "[Preset][Bundle][Published]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt_float("layer_height") = 0.28; + Preset::normalize(config); + + PresetBundle bundle; + bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1; + const std::vector ids_before = + bundle.filaments.get_edited_preset().config.opt("filament_settings_id")->values; + + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "filament_ids", "inherits", "printer_settings_id", "layer_height", "not_a_setting" }; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The real setting applied... + CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 1e-6)); + CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height")); + // ...the structural keys were neither applied nor reported... + CHECK_FALSE(contains_key(pub.skipped_keys, "filament_ids")); + CHECK_FALSE(contains_key(pub.skipped_keys, "inherits")); + CHECK_FALSE(contains_key(pub.skipped_keys, "printer_settings_id")); + CHECK(bundle.filaments.get_edited_preset().config.opt("filament_settings_id")->values == ids_before); + // ...while an unknown key still reports. + CHECK(contains_key(pub.skipped_keys, "not_a_setting")); +} + +// A whole-vector key requires the receiver's vector to have the same number of elements as the +// author's: pasting a 3-extruder list into a 2-extruder machine would overwrite the wrong +// elements, so the key is reported as skipped and the receiver keeps its own values. +TEST_CASE("Published 3MF skips a whole-vector key whose size does not match the receiver", "[Preset][Bundle][Published]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt_float("layer_height") = 0.28; + // Author's wiping matrix sized for three extruders. + config.set_key_value("wiping_volumes_extruders", new ConfigOptionFloats({ 10., 20., 30. })); + Preset::normalize(config); + + PresetBundle bundle; + // Receiver sized for two extruders. + bundle.prints.get_edited_preset().config.set_key_value("wiping_volumes_extruders", new ConfigOptionFloats({ 40., 50. })); + bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1; + + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "wiping_volumes_extruders", "layer_height" }; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + check_double_vector(bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values, + { 40., 50. }); + CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders")); + // The matching scalar key still applied. + CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height")); + CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 1e-6)); +} + +// The uniquify chain continues past the first suffix: with both "X" and "X (Published)" already +// present, the next imported copy of "X" lands as "X (Published 2)" and leaves the others alone. +TEST_CASE("Published 3MF uniquifies a second imported full material as (Published 2)", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + Preset &bare = add_inmemory_preset(bundle.filaments, "Generic PLA"); + bare.config.opt_string("filament_type", 0u) = "PLA"; + bare.config.opt("filament_retraction_length", true)->values = { 0.5 }; + Preset &pub1 = add_inmemory_preset(bundle.filaments, "Generic PLA (Published)"); + pub1.config.opt_string("filament_type", 0u) = "PLA"; + pub1.config.opt("filament_retraction_length", true)->values = { 0.5 }; + bundle.filament_presets = { "My PETG" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.preset_name = "Generic PLA @Qidi Q2 0.4 nozzle"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = published_pla_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + CHECK(bundle.filament_presets[0] == "Generic PLA (Published 2)"); + check_double_vector(bundle.filaments.find_preset("Generic PLA (Published 2)", false, true) + ->config.opt("filament_retraction_length")->values, + { 0.9 }); + check_double_vector(bundle.filaments.find_preset("Generic PLA", false, true) + ->config.opt("filament_retraction_length")->values, + { 0.5 }); + check_double_vector(bundle.filaments.find_preset("Generic PLA (Published)", false, true) + ->config.opt("filament_retraction_length")->values, + { 0.5 }); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Generic PLA (Published 2)"); + CHECK(pub.skipped_keys.empty()); +} + +// A mixed filament's blended colour is a swatch for the project's colour strip only: it must +// never be written into the slot's (possibly shared) preset config, or every slot referencing +// that preset would turn into the blend colour. +TEST_CASE("Published 3MF never writes a mixed filament's blended colour into the slot's preset", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + bundle.filament_presets = { "My PLA", "My PLA", "My PLA" }; + bundle.set_num_filaments(3); + bundle.project_config.opt("filament_is_mixed")->values[2] = 1; + bundle.project_config.opt("filament_mixed_components")->values[2] = "1,1"; + bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[2] = "0.5,0.5"; + + PublishedMaterialEntry mix; + mix.filament_type = "PLA"; + mix.filament_vendor = "Generic"; + mix.filament_id = "GFL99"; + mix.slot = 2; + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", + "filament_mixed_sublayer_ratios", "filament_mixed_gradient", + "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3 }; + config.opt("filament_extruder_variant")->values = { + "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" + }; + config.opt("filament_colour")->values = { "#FF0000", "#0000FF", "#800080" }; + config.opt("filament_type")->values = { "PLA", "PETG", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic" }; + config.opt("filament_is_mixed")->values = { 0, 0, 1 }; + config.opt("filament_mixed_components")->values = { "", "", "1,2" }; + config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" }; + config.opt("filament_mixed_gradient")->values = { 0, 0, 1 }; + config.opt("filament_mixed_gradient_range")->values = { "", "", "0.9,0.1" }; + config.opt("filament_mixed_gradient_curve")->values = { "", "", "0,0.1|1,0.9" }; + config.opt("filament_mixed_gradient_per_part")->values = { 0, 0, 1 }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The definition applied like-for-like onto the virtual slot... + CHECK(bundle.filament_presets.size() == 3); + CHECK(bundle.is_mixed_filament(2)); + CHECK(bundle.project_config.opt("filament_mixed_components")->values[2] == "1,2"); + // ...the blend colour landed in the project strip only... + CHECK(bundle.project_config.opt("filament_colour")->values[2] == "#800080"); + // ...and the shared preset kept its own colour: slots 0 and 1 render unchanged. + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == + std::vector{ "#123456" }); + CHECK(bundle.filament_presets[0] == "My PLA"); + CHECK(bundle.filament_presets[1] == "My PLA"); + CHECK(pub.skipped_keys.empty()); + CHECK(pub.material_replacements.empty()); +} + +// Duplicate entries for the same authored slot only occur in hand-crafted files (the dialog +// emits one entry per slot); the load's contract under that input is deterministic last-wins, +// not corruption. +TEST_CASE("Published 3MF applies duplicate entries for one slot last-wins", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_colour", true)->values = { "#000000" }; + bundle.filament_presets = { "My PLA" }; + + auto make_entry = [](const char *color) { + PublishedMaterialEntry entry; + entry.slot = 0; + entry.publish_color = true; + entry.color = color; + entry.keys = { "filament_retraction_length" }; + return entry; + }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_entry("#AA0000"), make_entry("#BB0000") }; + DynamicPrintConfig config = published_pla_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The second entry won both the project strip and the slot's preset. + CHECK(bundle.project_config.opt("filament_colour")->values[0] == "#BB0000"); + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == + std::vector{ "#BB0000" }); + check_double_vector(bundle.filaments.find_preset("My PLA", false, true) + ->config.opt("filament_retraction_length")->values, + { 0.9 }); + CHECK(pub.skipped_keys.empty()); + CHECK(pub.material_replacements.empty()); +} + +// normalize_filament_type maps "PLA High Speed" onto the canonical family "PLA" (a space- +// separated modifier is dropped) but leaves dash-separated composite types like "PA-CF" intact, +// and passes through unknown types and the empty string unchanged. +TEST_CASE("normalize_filament_type strips a space modifier but keeps dash types", "[Preset][Bundle][Published]") +{ + CHECK(normalize_filament_type("PLA High Speed") == "PLA"); + CHECK(normalize_filament_type("PA-CF") == "PA-CF"); + CHECK(normalize_filament_type("PETG-CF") == "PETG-CF"); + CHECK(normalize_filament_type("PLA") == "PLA"); + CHECK(normalize_filament_type("ABC") == "ABC"); + CHECK(normalize_filament_type("") == ""); +} + +// collect_dirty_settings_keys feeds the Publish dialog's pre-check: it must be the set union of +// the dirty options across the edited print, printer and filament presets. +TEST_CASE("collect_dirty_settings_keys unions the dirty settings from all three presets", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + // The edited preset is initialised as a copy of the selected (default) preset, so a single + // edit makes exactly that option dirty. deep_diff reports scalar keys by name but per-element + // vector keys as "key#", so a vector edit surfaces as "key#0". + bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.28; + bundle.filaments.get_edited_preset().config.opt("filament_type", true)->values = { "ABS" }; + bundle.printers.get_edited_preset().config.opt("nozzle_diameter", true)->values = { 0.6 }; + + const std::vector dirty = collect_dirty_settings_keys(bundle); + for (const char *key : { "layer_height", "filament_type#0", "nozzle_diameter#0" }) + CHECK(contains_key(dirty, key)); +} + +// The publish denylist and the mixed-key list are single sources of truth for the import path: +// lock their members so a silent edit to either cannot drift away from the contract the import +// and export masks rely on. +TEST_CASE("Published 3MF denylist and mixed-key sets match the import/export contract", "[Preset][Bundle][Published]") +{ + const std::set& structural = publish_structural_keys(); + // Structural / inheritance keys must never be applied onto a receiver's presets. + for (const char *key : { "printer_settings_id", "filament_settings_id", "print_settings_id", + "compatible_printers", "compatible_prints", "compatible_printers_condition", + "compatible_prints_condition", "default_filament_profile", "default_print_profile", + "inherits", "extruder_count", "printer_model", "filament_ids" }) + CHECK(structural.count(key) == 1); + // ...but a per-slot publishable material key is not structural. + CHECK(structural.count("filament_retraction_length") == 0); + CHECK(structural.count("filament_colour") == 0); + + const std::set& mixed = publish_mixed_keys(); + CHECK(mixed == std::set{ + "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios", + "filament_mixed_gradient", "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }); + // Mixed keys are project-level arrays, not material-preset keys, so none is structural. + for (const std::string &key : mixed) + CHECK(structural.count(key) == 0); +} + namespace { // data_dir() is a process-wide global that import_presets extracts into; scope it to the test.